feat(core): add feedback tools contracts, validation, storage, and dedupe

* feat(core): add feedback report tool contracts and storage

* fix(core): stabilize feedback dedupe and clear lint blocker

* fix(core): harden feedback report parsing and resilience

* docs: add feedback routing architecture and PR explainer

* fix(core): remove dedupe collisions and share atomic writes

* docs: add final PR403 bugbot resolution status

* docs: formalize feedback pipeline and fork-aware execution design

* docs: remove PR-specific review artifacts

* docs: add durable feedback pipeline explainer

* docs: add consent gates and journal semantics to feedback design

* docs: add pr403 confidence checklist with openclaw dogfood evidence

* docs: add work openclaw validation prompt helper page

* docs: record work openclaw validation evidence

* docs: remove pr-specific review artifacts from repo

* fix(core): ignore confidence in feedback dedupe key
This commit is contained in:
prateek 2026-03-10 22:31:39 +05:30 committed by GitHub
parent 240d6423fb
commit 8e8cab771f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1064 additions and 35 deletions

View File

@ -0,0 +1,214 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Feedback Pipeline Architecture Explainer</title>
<style>
:root {
--bg: #f2efe7;
--panel: #fffdf8;
--ink: #1f1a16;
--muted: #6f6357;
--brand: #165a72;
--line: #dfd3c2;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
color: var(--ink);
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
line-height: 1.5;
background:
radial-gradient(circle at 12% 10%, #ebdfcb 0%, transparent 35%),
radial-gradient(circle at 88% 85%, #e5d4ba 0%, transparent 30%), var(--bg);
}
.wrap {
max-width: 1100px;
margin: 2rem auto;
padding: 0 1rem 3rem;
}
.hero,
.card {
border: 1px solid var(--line);
border-radius: 14px;
background: var(--panel);
}
.hero {
padding: 1.2rem;
}
.card {
padding: 1rem;
}
h1,
h2,
h3 {
font-family: "IBM Plex Serif", Georgia, serif;
margin: 0.6rem 0;
}
.muted {
color: var(--muted);
}
.grid {
margin-top: 1rem;
display: grid;
gap: 1rem;
}
@media (min-width: 900px) {
.grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid.three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
ul,
ol {
margin: 0.4rem 0 0;
padding-left: 1rem;
}
code {
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
background: #f3ebe0;
border-radius: 4px;
padding: 0.1rem 0.28rem;
}
pre {
margin-top: 0.7rem;
padding: 0.7rem;
border: 1px solid var(--line);
border-radius: 10px;
background: #faf4ec;
overflow-x: auto;
}
</style>
</head>
<body>
<main class="wrap">
<section class="hero">
<h1>Feedback Pipeline Explainer</h1>
<p class="muted">
Durable architecture reference for the report -> issue -> agent-session -> PR pipeline,
including fork-aware execution and governance controls.
</p>
</section>
<section class="grid two">
<article class="card">
<h2>Formal Pipeline</h2>
<ol>
<li>Capture and validate report payload.</li>
<li>Resolve issue via dedupe markers (create or update/comment).</li>
<li>Plan follow-up: issue-only, issue+PR, or issue+fork.</li>
<li>Execute direct SCM operations or spawn agent session for code changes.</li>
<li>Link issue/fork/PR and update operation journal.</li>
</ol>
</article>
<article class="card">
<h2>Execution Boundaries</h2>
<ul>
<li>Orchestrator is the only component allowed to perform SCM side effects.</li>
<li>Optional subagent skill can provide recommendation signals only.</li>
<li>Policy fallback remains deterministic when subagent output is invalid.</li>
</ul>
</article>
</section>
<section class="grid three">
<article class="card">
<h3>Trigger Conditions</h3>
<ul>
<li>Valid report in <code>mode=scm</code>.</li>
<li>Confidence thresholds met.</li>
<li>Governance allows attempted mutation path.</li>
</ul>
</article>
<article class="card">
<h3>Target Selection</h3>
<ul>
<li>Prefer upstream when writable and policy allows.</li>
<li>Use fork path when upstream writes are blocked.</li>
<li>Downgrade to issue-only when neither path is permitted.</li>
</ul>
</article>
<article class="card">
<h3>Idempotency</h3>
<ul>
<li><code>dedupeKey</code> for issue identity.</li>
<li><code>operationKey</code> per mutation stage.</li>
<li>Find-or-create semantics before every mutation.</li>
</ul>
</article>
</section>
<section class="grid two">
<article class="card">
<h2>Consent Gates (Default Policy)</h2>
<ul>
<li>Outside AO dogfooding, human consent is required for fork creation.</li>
<li>Outside AO dogfooding, human consent is required for PR creation.</li>
<li>Outside AO dogfooding, human consent is required before upstream/fork target switch.</li>
<li>Project-level bypass is allowed only when owner explicitly enables it.</li>
</ul>
</article>
<article class="card">
<h2>Journal (Plain Language)</h2>
<ul>
<li>The journal is a progress log for every report.</li>
<li>Each mutation updates one tracked record, not separate duplicates.</li>
<li>Retries keep the same keys and increment attempt metadata.</li>
<li>Consent outcomes are stored for auditability.</li>
</ul>
<pre><code>{
"dedupeKey": "f4d7dbe5b0f8...",
"stage": "create_pr",
"status": "failed",
"attempt": 2,
"issueUrl": ".../issues/399",
"prUrl": null
}</code></pre>
</article>
</section>
<section class="grid two">
<article class="card">
<h2>PR Requirements</h2>
<ul>
<li>Issue reference must exist in PR metadata/body.</li>
<li>Stable lineage markers link issue, session, and PR.</li>
<li>Existing PR for same dedupe context must be linked, not duplicated.</li>
</ul>
</article>
<article class="card">
<h2>Governance Hooks</h2>
<ul>
<li>canCreateIssue / canCreateFork / canCreatePR / canSpawnSession checks.</li>
<li>Per-fork-owner allow/deny and optional approval gates.</li>
<li>Denied operations are journaled with explicit reason codes.</li>
</ul>
</article>
</section>
</main>
</body>
</html>

View File

@ -0,0 +1,290 @@
# Feedback Routing and Follow-up Design (Formalized Pipeline v2)
## Status
This is a design formalization update after PR #403 discussion.
- PR #403 remains implementation-scoped to feedback contracts/validation/storage.
- This document defines the next-step architecture for report -> issue -> agent-session -> PR.
- This update is design-only (no new runtime behavior introduced by this document itself).
## Scope and Decisions
1. Feedback routing mode is exclusive: `local` OR `scm` (never both).
2. Privacy guardrails are intentionally deferred to a dedicated follow-up PR.
3. Side effects stay deterministic in orchestrator control code.
4. Optional subagent/skill can recommend decisions, but cannot execute SCM mutations.
## Formal Pipeline
1. **Report capture**: validate feedback tool payload and compute dedupe key.
2. **Issue resolution**: find existing issue by markers; create or comment/update.
3. **Follow-up planning**: decide issue-only vs issue+PR vs issue+fork from policy + context.
4. **Execution**:
- direct SCM action path (issue/fork/PR metadata operations), or
- agent-session path (spawn session to produce code changes).
5. **Linking and journal update**: persist outcome state and references.
## 1) Trigger Conditions
The pipeline is triggered when all of the following are true:
1. A valid `bug_report` or `improvement_suggestion` is captured.
2. Routing mode is `scm` for the active project.
3. Confidence threshold is met for that report type.
4. Governance policy allows target/fork mutation for this actor/project.
Decision triggers for follow-up action:
1. `self_blocking_now = false` -> issue-only.
2. `self_blocking_now = true` + ready branch/commits -> issue + PR link/create path.
3. `self_blocking_now = true` + no writable upstream path -> issue + fork path.
4. `self_blocking_now = true` + no code yet -> spawn agent-session path.
## 2) Session Spawning Contract
When follow-up requires code (not just metadata operations), orchestrator spawns a worker session.
### Inputs
1. `reportId`, `dedupeKey`, `issueUrl` (resolved in prior stage).
2. `targetRepo` and `targetBranchPolicy`.
3. `followUpIntent` (`fix_now`, `draft_solution`, etc.).
4. Optional `forkContext` (fork owner/repo/branch if fork path selected).
### Preconditions
1. Issue is already resolved/created and has stable URL.
2. Spawn policy permits automatic coding session for this project.
3. Repo target has been selected (upstream or fork).
### Required outputs
1. Session ID.
2. Branch reference used by the session.
3. Optional PR URL if created by orchestrator or agent flow.
4. Terminal state recorded in publish journal (`done`, `failed`, `cancelled`).
## 3) Target Selection (Upstream vs Fork)
Target selection is deterministic and policy-driven:
1. If upstream write is allowed and policy is `upstream`, target upstream.
2. If upstream write is blocked and policy allows fork, target fork.
3. If fork exists and policy says reuse, reuse existing fork.
4. If fork missing and policy allows creation, create fork and continue.
5. If neither upstream nor fork is allowed, downgrade to issue-only and mark follow-up blocked.
Example policy knobs:
```yaml
feedback:
mode: scm
scm:
provider: github
targetRepo: auto # auto | upstream | fork
forkStrategy: upstream # upstream | fork | skip
```
## Consent Gates (Default Policy)
For projects other than AO dogfooding, these are hard defaults:
1. Explicit human consent is required before creating a fork.
2. Explicit human consent is required before creating a PR.
3. Explicit human consent is required before switching execution target between upstream and fork.
4. No silent infrastructure flip is allowed by default.
Override model:
1. Project-level override is optional and must be explicitly enabled by project owner.
2. Overrides are scoped per operation (`createFork`, `createPR`, `switchTarget`) and must be auditable.
3. Without explicit owner override, consent gate defaults remain enforced.
## 4) PR Creation/Linking Requirements
For any PR action, orchestrator enforces:
1. Issue URL exists and is referenced in PR body.
2. Dedupe marker is present in issue/PR metadata for traceability.
3. If PR already exists for dedupe key + branch, link existing PR rather than creating duplicate.
4. If fork path is used, PR must include fork repo/branch references.
5. Issue must be updated with final PR URL and state transitions.
Canonical markers in issue/PR body:
1. `<!-- ao:feedback-tool:<tool> -->`
2. `<!-- ao:dedupe-key:<dedupeKey> -->`
3. `<!-- ao:session:<sessionId> -->` (if session spawned)
## 5) Idempotency and Retry Semantics
Idempotency keys:
1. `dedupeKey` for issue-level identity.
2. `operationKey` for each side effect (create issue, create fork, create PR, add comment).
Retry semantics:
1. Retry only retryable transport/server failures.
2. Exponential backoff with bounded attempts.
3. Non-retryable errors transition to terminal failure with actionable reason.
At-least-once safety:
1. Replays must first check existing issue/fork/PR using markers before creating anything.
2. Side effects must be written as "find-or-create" operations.
Journal semantics:
1. Minimal journal record per report in `scm` mode:
- `dedupeKey`, `stage`, `status`, `issueUrl`, `prUrl`, `targetRepo`, `lastError`.
2. Journal drives recovery and prevents duplicate creation on restart.
Plain-language journal behavior:
1. Think of the journal as a progress log for each report.
2. Before each mutation attempt, orchestrator writes what it is about to do.
3. After attempt completion, orchestrator updates the same record with success/failure and links.
4. On retry, orchestrator keeps the same identity keys and increments attempt metadata instead of creating a parallel track.
5. Consent decisions (approved/denied) are written so operators can audit why a path was or was not taken.
Minimal journal schema example:
```json
{
"reportId": "fr_01HT2H2F3H4A5",
"dedupeKey": "f4d7dbe5b0f8...",
"mode": "scm",
"stage": "create_pr",
"status": "failed",
"attempt": 2,
"operationKey": "create_pr:f4d7dbe5b0f8:upstream",
"targetRepo": "ComposioHQ/agent-orchestrator",
"issueUrl": "https://github.com/ComposioHQ/agent-orchestrator/issues/399",
"prUrl": null,
"consent": {
"createFork": "approved",
"createPR": "approved",
"switchTarget": "not-needed"
},
"lastError": {
"code": "FORBIDDEN",
"message": "PR creation blocked by repository policy"
},
"updatedAt": "2026-03-10T15:45:00Z"
}
```
## 6) Governance Hooks Per Fork Owner Policy
Governance is evaluated before each mutating operation.
Policy hooks:
1. `canCreateIssue(project, actor, targetRepo)`
2. `canCreateFork(project, actor, forkOwner)`
3. `canCreatePR(project, actor, targetRepo, sourceRepo)`
4. `canSpawnSession(project, actor, followUpIntent)`
Per-fork-owner controls:
1. Allowed fork owner list / deny list.
2. Require human approval for fork creation under selected owners.
3. Optional restriction to pre-registered fork remotes.
If governance denies an operation:
1. Do not attempt mutation.
2. Downgrade path if possible (e.g., issue-only).
3. Record explicit denial reason in journal.
## Responsibilities: Orchestrator vs Subagent Skill
### Orchestrator (required)
1. Owns all SCM side effects.
2. Owns retries, idempotency checks, and journal updates.
3. Enforces governance hooks and policy fallbacks.
### Optional ephemeral subagent skill
1. Produces recommendation payload only:
- `self_blocking_now`
- `recommended_action`
- `reason`
- `confidence`
2. Must return strict JSON schema.
3. If invalid/low confidence, orchestrator falls back to deterministic rules.
## Proposed Components
1. `FeedbackRouter`: local vs scm dispatch.
2. `IssueResolver`: dedupe-aware issue create/update/comment.
3. `FollowUpPlanner`: issue-only vs issue+PR vs issue+fork decision.
4. `TargetResolver`: upstream/fork target determination.
5. `FollowUpExecutor`: direct SCM or agent-session execution path.
6. `FeedbackPublishJournal`: status, links, retries, recovery metadata.
## Config Proposal (Extended)
```yaml
feedback:
mode: scm # local | scm
scm:
provider: github # github | gitlab
targetRepo: auto # auto | upstream | fork
forkStrategy: upstream # upstream | fork | skip
prReference: if_present # required | if_present | never
minConfidence:
bug_report: 0.6
improvement_suggestion: 0.75
followUp:
enableAgentSession: true
requireIssueBeforeSession: true
consent:
defaultPolicy: require_human_for_major_mutations # hard default outside AO dogfooding
requireFor:
createFork: true
createPR: true
switchTarget: true
projectOverride:
enabled: false # must be explicitly enabled by project owner
governance:
allowedForkOwners: ["<org-or-user>"]
requireApprovalForForkCreation: true
```
## Testing Strategy
### Unit tests
1. Trigger matrix and planner decision table.
2. Target resolver behavior for upstream/fork permutations.
3. Marker generation and dedupe matching.
4. Governance hook allow/deny behavior.
5. Retry classifier and idempotent replay logic.
### Integration tests
1. Issue create/update on GitHub and GitLab.
2. Fork ensure/reuse path under different policies.
3. PR create/link and issue back-link updates.
4. Agent-session contract handoff and journal transitions.
### End-to-end tests
1. report -> issue-only path.
2. report -> issue -> session -> PR path.
3. report -> issue -> fork -> session -> PR-from-fork path.
## Rollout
1. Ship with `mode: local` default and `scm` opt-in.
2. Enable on a small set of projects first.
3. Track duplicate suppression rate, retry outcomes, and failure classes.
4. Expand once deterministic behavior and governance policy outcomes are stable.
## Summary
The formal pipeline is now explicit: report -> issue -> (optional) agent-session -> PR, with fork-aware execution and governance hooks. Orchestrator remains the only executor of side effects; agentic skill remains advisory.

View File

@ -0,0 +1,33 @@
# AO22 Design Iteration Summary
## What was updated
1. `docs/design/feedback-routing-and-followup-design.md`
- Formalized the full pipeline: report -> issue -> agent-session -> PR.
- Added explicit sections for:
- trigger conditions,
- session spawning contract,
- target selection (upstream vs fork),
- PR creation/linking requirements,
- idempotency/retry semantics,
- governance hooks per fork-owner policy.
2. `docs/design/feedback-pipeline-explainer.html`
- Added a durable architecture explainer page (non-PR-specific).
- Mirrors the same six design contracts and the formal pipeline.
## Intent
This iteration is design formalization only. No runtime/code behavior was introduced by this request.
## Feedback incorporated
- Added a dedicated `Consent Gates (Default Policy)` section with hard defaults for non-AO dogfooding:
- explicit approval required for `createFork`, `createPR`, and upstream/fork target switching.
- Clarified that project-level override is optional and only active when explicitly enabled by the owner.
- Expanded journal behavior in plain language to describe how records are written/updated/retried.
- Added a minimal journal schema example for the feedback pipeline.
## Ready for review
The docs now provide a deterministic contract for implementing fork-aware report->issue->session->PR execution in follow-up PRs.

View File

@ -139,6 +139,51 @@ Loads and validates `agent-orchestrator.yaml`:
2. Wire it up in the polling loop
3. Add config schema in `src/config.ts` if new reaction type
### Feedback Tools (v1)
`@composio/ao-core` exports two structured feedback tool contracts:
- `bug_report`
- `improvement_suggestion`
Both share the same required input fields:
- `title`
- `body`
- `evidence` (array of strings)
- `session`
- `source`
- `confidence` (0..1)
Example:
```ts
import { FEEDBACK_TOOL_NAMES, FeedbackReportStore, getFeedbackReportsDir } from "@composio/ao-core";
const reportsDir = getFeedbackReportsDir(configPath, projectPath);
const store = new FeedbackReportStore(reportsDir);
const saved = store.persist(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
title: "SSO login loop",
body: "Google SSO redirects back to /login repeatedly.",
evidence: ["trace_id=abc123", "screenshot: login-loop.png"],
session: "ao-22",
source: "agent",
confidence: 0.84,
});
```
Storage format:
- Reports are persisted under `~/.agent-orchestrator/{hash}-{projectId}/feedback-reports`
- Each report is a typed key=value file (`report_<timestamp>_<id>.kv`) for easy inspection
- A deterministic dedupe key (`sha256`, 16 hex chars) is generated from normalized tool+content
Migration notes:
- No migration needed for existing AO installs
- The `feedback-reports` directory is created lazily on first persisted report
## Testing
```bash

View File

@ -0,0 +1,205 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import {
FEEDBACK_TOOL_CONTRACTS,
FEEDBACK_TOOL_NAMES,
FeedbackReportStore,
generateFeedbackDedupeKey,
validateFeedbackToolInput,
} from "../feedback-tools.js";
const validPayload = {
title: "Login failure for SSO users",
body: "Users with Google SSO are looped back to login.",
evidence: ["trace_id=abc123", "Video capture from session"],
session: "ao-22",
source: "agent",
confidence: 0.82,
};
describe("feedback tool contracts", () => {
it("defines both v1 tool contracts", () => {
expect(Object.keys(FEEDBACK_TOOL_CONTRACTS).sort()).toEqual([
FEEDBACK_TOOL_NAMES.BUG_REPORT,
FEEDBACK_TOOL_NAMES.IMPROVEMENT_SUGGESTION,
]);
});
it("validates required fields for bug_report", () => {
expect(() =>
validateFeedbackToolInput(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
...validPayload,
title: "",
}),
).toThrow();
expect(() =>
validateFeedbackToolInput(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
...validPayload,
body: "",
}),
).toThrow();
expect(() =>
validateFeedbackToolInput(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
...validPayload,
evidence: [],
}),
).toThrow();
expect(() =>
validateFeedbackToolInput(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
...validPayload,
session: "",
}),
).toThrow();
expect(() =>
validateFeedbackToolInput(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
...validPayload,
source: "",
}),
).toThrow();
});
it("rejects malformed confidence", () => {
expect(() =>
validateFeedbackToolInput(FEEDBACK_TOOL_NAMES.IMPROVEMENT_SUGGESTION, {
...validPayload,
confidence: -0.1,
}),
).toThrow();
expect(() =>
validateFeedbackToolInput(FEEDBACK_TOOL_NAMES.IMPROVEMENT_SUGGESTION, {
...validPayload,
confidence: 1.1,
}),
).toThrow();
expect(() =>
validateFeedbackToolInput(FEEDBACK_TOOL_NAMES.IMPROVEMENT_SUGGESTION, {
...validPayload,
confidence: Number.NaN,
}),
).toThrow();
});
});
describe("feedback dedupe key", () => {
it("is stable for whitespace/case differences and evidence ordering", () => {
const keyA = generateFeedbackDedupeKey(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
...validPayload,
title: " Login failure FOR SSO users ",
body: "Users with Google SSO are looped back to login.",
evidence: ["Video capture from session", "trace_id=abc123"],
source: "Agent",
});
const keyB = generateFeedbackDedupeKey(FEEDBACK_TOOL_NAMES.BUG_REPORT, validPayload);
expect(keyA).toBe(keyB);
});
it("is stable when case-only evidence changes alter default sort order", () => {
const keyA = generateFeedbackDedupeKey(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
...validPayload,
evidence: ["BETA", "alpha"],
});
const keyB = generateFeedbackDedupeKey(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
...validPayload,
evidence: ["beta", "alpha"],
});
expect(keyA).toBe(keyB);
});
it("is stable when only confidence changes", () => {
const keyA = generateFeedbackDedupeKey(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
...validPayload,
confidence: 0.21,
});
const keyB = generateFeedbackDedupeKey(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
...validPayload,
confidence: 0.99,
});
expect(keyA).toBe(keyB);
});
it("changes when normalized content differs", () => {
const keyA = generateFeedbackDedupeKey(FEEDBACK_TOOL_NAMES.BUG_REPORT, validPayload);
const keyB = generateFeedbackDedupeKey(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
...validPayload,
body: "Users with Google SSO see a 500 after callback.",
});
expect(keyA).not.toBe(keyB);
});
it("does not collide when one evidence item contains pipes", () => {
const keyA = generateFeedbackDedupeKey(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
...validPayload,
evidence: ["a|b"],
});
const keyB = generateFeedbackDedupeKey(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
...validPayload,
evidence: ["a", "b"],
});
expect(keyA).not.toBe(keyB);
});
});
describe("feedback report store", () => {
let reportsDir: string;
beforeEach(() => {
reportsDir = join(tmpdir(), `ao-feedback-${randomUUID()}`);
mkdirSync(reportsDir, { recursive: true });
});
afterEach(() => {
rmSync(reportsDir, { recursive: true, force: true });
});
it("persists and reads structured feedback reports", () => {
const store = new FeedbackReportStore(reportsDir);
const saved = store.persist(FEEDBACK_TOOL_NAMES.BUG_REPORT, validPayload);
const records = store.list();
expect(records).toHaveLength(1);
expect(records[0]).toEqual(saved);
expect(records[0]?.tool).toBe(FEEDBACK_TOOL_NAMES.BUG_REPORT);
expect(records[0]?.dedupeKey).toMatch(/^[a-f0-9]{16}$/);
});
it("does not allow invalid reports to be persisted", () => {
const store = new FeedbackReportStore(reportsDir);
expect(() =>
store.persist(FEEDBACK_TOOL_NAMES.IMPROVEMENT_SUGGESTION, {
...validPayload,
evidence: [],
}),
).toThrow();
});
it("skips corrupt report files and returns valid ones", () => {
const store = new FeedbackReportStore(reportsDir);
const saved = store.persist(FEEDBACK_TOOL_NAMES.BUG_REPORT, validPayload);
writeFileSync(
join(reportsDir, "report_2026-03-10T00-00-00-000Z_bad.kv"),
"version=1\nid=report_2026-03-10T00-00-00-000Z_bad\ntool=bug_report\n",
"utf-8",
);
const records = store.list();
expect(records).toEqual([saved]);
});
});

View File

@ -23,6 +23,7 @@ import {
getProjectBaseDir,
getSessionsDir,
getWorktreesDir,
getFeedbackReportsDir,
getArchiveDir,
getOriginFilePath,
generateSessionName,
@ -271,6 +272,12 @@ describe("Path Construction", () => {
expect(worktreesDir).toMatch(/\.agent-orchestrator\/[a-f0-9]{12}-integrator\/worktrees$/);
});
it("getFeedbackReportsDir returns {baseDir}/feedback-reports", () => {
const reportsDir = getFeedbackReportsDir(configPath, "/repos/integrator");
expect(reportsDir).toMatch(/\.agent-orchestrator\/[a-f0-9]{12}-integrator\/feedback-reports$/);
});
it("getArchiveDir returns {baseDir}/sessions/archive", () => {
const archiveDir = getArchiveDir(configPath, "/repos/integrator");
@ -287,10 +294,12 @@ describe("Path Construction", () => {
const baseDir = getProjectBaseDir(configPath, "/repos/integrator");
const sessionsDir = getSessionsDir(configPath, "/repos/integrator");
const worktreesDir = getWorktreesDir(configPath, "/repos/integrator");
const reportsDir = getFeedbackReportsDir(configPath, "/repos/integrator");
const archiveDir = getArchiveDir(configPath, "/repos/integrator");
expect(sessionsDir).toContain(baseDir);
expect(worktreesDir).toContain(baseDir);
expect(reportsDir).toContain(baseDir);
expect(archiveDir).toContain(baseDir);
});
});

View File

@ -0,0 +1,11 @@
import { renameSync, writeFileSync } from "node:fs";
/**
* Atomically write a file by writing to a temp file then renaming.
* rename() is atomic on POSIX, so concurrent writers never produce torn data.
*/
export function atomicWriteFileSync(filePath: string, content: string): void {
const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
writeFileSync(tmpPath, content, "utf-8");
renameSync(tmpPath, filePath);
}

View File

@ -0,0 +1,204 @@
import { createHash, randomUUID } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { z } from "zod";
import { atomicWriteFileSync } from "./atomic-write.js";
import { parseKeyValueContent } from "./key-value.js";
export const FEEDBACK_TOOL_NAMES = {
BUG_REPORT: "bug_report",
IMPROVEMENT_SUGGESTION: "improvement_suggestion",
} as const;
export type FeedbackToolName = (typeof FEEDBACK_TOOL_NAMES)[keyof typeof FEEDBACK_TOOL_NAMES];
const normalizeText = (value: string): string => value.trim().replace(/\s+/g, " ");
const NonEmptyTextSchema = z.string().transform(normalizeText).pipe(z.string().min(1));
const FeedbackInputSchema = z
.object({
title: NonEmptyTextSchema,
body: NonEmptyTextSchema,
evidence: z.array(NonEmptyTextSchema).min(1),
session: NonEmptyTextSchema,
source: NonEmptyTextSchema,
confidence: z.number().finite().min(0).max(1),
})
.strict();
export const BugReportSchema = FeedbackInputSchema;
export const ImprovementSuggestionSchema = FeedbackInputSchema;
export type BugReportInput = z.infer<typeof BugReportSchema>;
export type ImprovementSuggestionInput = z.infer<typeof ImprovementSuggestionSchema>;
export type FeedbackToolInput = BugReportInput | ImprovementSuggestionInput;
export interface FeedbackToolContract {
name: FeedbackToolName;
description: string;
schema: typeof FeedbackInputSchema;
}
export const FEEDBACK_TOOL_CONTRACTS: Record<FeedbackToolName, FeedbackToolContract> = {
bug_report: {
name: FEEDBACK_TOOL_NAMES.BUG_REPORT,
description: "Capture reproducible bugs found while working in AO sessions.",
schema: BugReportSchema,
},
improvement_suggestion: {
name: FEEDBACK_TOOL_NAMES.IMPROVEMENT_SUGGESTION,
description: "Capture actionable improvement suggestions discovered in AO sessions.",
schema: ImprovementSuggestionSchema,
},
};
export interface PersistedFeedbackReport extends FeedbackToolInput {
id: string;
tool: FeedbackToolName;
createdAt: string;
dedupeKey: string;
}
const ReportIdSchema = z.string().regex(/^report_[A-Za-z0-9_-]+$/);
const ISODateSchema = z.string().datetime({ offset: true });
const DedupeKeySchema = z.string().regex(/^[a-f0-9]{16}$/);
export function validateFeedbackToolInput(
tool: FeedbackToolName,
input: FeedbackToolInput,
): FeedbackToolInput {
return FEEDBACK_TOOL_CONTRACTS[tool].schema.parse(input);
}
export function generateFeedbackDedupeKey(
tool: FeedbackToolName,
input: FeedbackToolInput,
): string {
const canonicalEvidence = [...input.evidence]
.map((item) => normalizeText(item).toLowerCase())
.sort();
const canonical = JSON.stringify({
tool,
title: normalizeText(input.title).toLowerCase(),
body: normalizeText(input.body).toLowerCase(),
session: normalizeText(input.session).toLowerCase(),
source: normalizeText(input.source).toLowerCase(),
evidence: canonicalEvidence,
});
return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
}
function serializeReport(report: PersistedFeedbackReport): string {
const lines: string[] = [
"version=1",
`id=${report.id}`,
`tool=${report.tool}`,
`createdAt=${report.createdAt}`,
`dedupeKey=${report.dedupeKey}`,
`title=${report.title}`,
`body=${report.body}`,
`session=${report.session}`,
`source=${report.source}`,
`confidence=${report.confidence}`,
];
for (const [index, evidenceItem] of report.evidence.entries()) {
lines.push(`evidence.${index}=${evidenceItem}`);
}
return `${lines.join("\n")}\n`;
}
function parseReportFile(content: string): PersistedFeedbackReport {
const raw = parseKeyValueContent(content);
const evidence = Object.entries(raw)
.filter(([key]) => key.startsWith("evidence."))
.map(([key, value]) => ({
index: Number.parseInt(key.split(".")[1] ?? "0", 10),
value,
}))
.sort((a, b) => a.index - b.index)
.map((item) => item.value);
const tool = raw["tool"];
if (
tool !== FEEDBACK_TOOL_NAMES.BUG_REPORT &&
tool !== FEEDBACK_TOOL_NAMES.IMPROVEMENT_SUGGESTION
) {
throw new Error(`Invalid feedback report tool type: ${tool ?? "unknown"}`);
}
const parsedInput = FEEDBACK_TOOL_CONTRACTS[tool].schema.parse({
title: raw["title"] ?? "",
body: raw["body"] ?? "",
evidence,
session: raw["session"] ?? "",
source: raw["source"] ?? "",
confidence: Number(raw["confidence"]),
});
return {
id: ReportIdSchema.parse(raw["id"] ?? ""),
tool,
createdAt: ISODateSchema.parse(raw["createdAt"] ?? ""),
dedupeKey: DedupeKeySchema.parse(raw["dedupeKey"] ?? ""),
...parsedInput,
};
}
function isReportFileName(fileName: string): boolean {
return /^report_[A-Za-z0-9_-]+\.kv$/.test(fileName);
}
export class FeedbackReportStore {
constructor(private readonly reportsDir: string) {}
persist(tool: FeedbackToolName, input: FeedbackToolInput): PersistedFeedbackReport {
const validated = validateFeedbackToolInput(tool, input);
const createdAt = new Date().toISOString();
const dedupeKey = generateFeedbackDedupeKey(tool, validated);
const id = `report_${createdAt.replace(/[:.]/g, "-")}_${randomUUID().slice(0, 8)}`;
const report: PersistedFeedbackReport = {
id,
tool,
createdAt,
dedupeKey,
...validated,
};
mkdirSync(this.reportsDir, { recursive: true });
const filePath = join(this.reportsDir, `${id}.kv`);
atomicWriteFileSync(filePath, serializeReport(report));
return report;
}
list(): PersistedFeedbackReport[] {
if (!existsSync(this.reportsDir)) return [];
const reports: PersistedFeedbackReport[] = [];
for (const name of readdirSync(this.reportsDir)) {
if (!isReportFileName(name)) continue;
const reportPath = join(this.reportsDir, name);
try {
if (!statSync(reportPath).isFile()) continue;
} catch {
continue;
}
try {
const content = readFileSync(reportPath, "utf-8");
const parsed = parseReportFile(content);
reports.push(parsed);
} catch {
continue;
}
}
return reports.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
}
}

View File

@ -99,6 +99,25 @@ export { asValidOpenCodeSessionId } from "./opencode-session-id.js";
export { normalizeOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js";
export type { NormalizedOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js";
// Feedback tools — contracts, validation, and report storage
export {
FEEDBACK_TOOL_NAMES,
FEEDBACK_TOOL_CONTRACTS,
BugReportSchema,
ImprovementSuggestionSchema,
validateFeedbackToolInput,
generateFeedbackDedupeKey,
FeedbackReportStore,
} from "./feedback-tools.js";
export type {
FeedbackToolName,
FeedbackToolContract,
BugReportInput,
ImprovementSuggestionInput,
FeedbackToolInput,
PersistedFeedbackReport,
} from "./feedback-tools.js";
// Path utilities — hash-based directory structure
export {
generateConfigHash,
@ -108,6 +127,7 @@ export {
getProjectBaseDir,
getSessionsDir,
getWorktreesDir,
getFeedbackReportsDir,
getArchiveDir,
getOriginFilePath,
generateSessionName,

View File

@ -0,0 +1,18 @@
/**
* Parse a key=value file into a record.
* Lines starting with # are comments. Empty lines are skipped.
* Only the first "=" is used as the delimiter (values can contain "=").
*/
export function parseKeyValueContent(content: string): Record<string, string> {
const result: Record<string, string> = {};
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIndex = trimmed.indexOf("=");
if (eqIndex === -1) continue;
const key = trimmed.slice(0, eqIndex).trim();
const value = trimmed.slice(eqIndex + 1).trim();
if (key) result[key] = value;
}
return result;
}

View File

@ -22,7 +22,6 @@
import {
readFileSync,
writeFileSync,
renameSync,
existsSync,
mkdirSync,
unlinkSync,
@ -34,25 +33,8 @@ import {
} from "node:fs";
import { join, dirname } from "node:path";
import type { SessionId, SessionMetadata } from "./types.js";
/**
* Parse a key=value metadata file into a record.
* Lines starting with # are comments. Empty lines are skipped.
* Only the first `=` is used as the delimiter (values can contain `=`).
*/
function parseMetadataFile(content: string): Record<string, string> {
const result: Record<string, string> = {};
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIndex = trimmed.indexOf("=");
if (eqIndex === -1) continue;
const key = trimmed.slice(0, eqIndex).trim();
const value = trimmed.slice(eqIndex + 1).trim();
if (key) result[key] = value;
}
return result;
}
import { atomicWriteFileSync } from "./atomic-write.js";
import { parseKeyValueContent } from "./key-value.js";
/** Serialize a record back to key=value format. */
function serializeMetadata(data: Record<string, string>): string {
@ -64,16 +46,6 @@ function serializeMetadata(data: Record<string, string>): string {
);
}
/**
* Atomically write a file by writing to a temp file then renaming.
* rename() is atomic on POSIX, so concurrent writers never produce torn data.
*/
function atomicWriteFileSync(filePath: string, content: string): void {
const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
writeFileSync(tmpPath, content, "utf-8");
renameSync(tmpPath, filePath);
}
/** Validate sessionId to prevent path traversal. */
const VALID_SESSION_ID = /^[a-zA-Z0-9_-]+$/;
@ -97,7 +69,7 @@ export function readMetadata(dataDir: string, sessionId: SessionId): SessionMeta
if (!existsSync(path)) return null;
const content = readFileSync(path, "utf-8");
const raw = parseMetadataFile(content);
const raw = parseKeyValueContent(content);
return {
worktree: raw["worktree"] ?? "",
@ -133,7 +105,7 @@ export function readMetadataRaw(
): Record<string, string> | null {
const path = metadataPath(dataDir, sessionId);
if (!existsSync(path)) return null;
return parseMetadataFile(readFileSync(path, "utf-8"));
return parseKeyValueContent(readFileSync(path, "utf-8"));
}
/**
@ -187,7 +159,7 @@ export function updateMetadata(
let existing: Record<string, string> = {};
if (existsSync(path)) {
existing = parseMetadataFile(readFileSync(path, "utf-8"));
existing = parseKeyValueContent(readFileSync(path, "utf-8"));
}
// Merge updates — remove keys set to empty string
@ -254,7 +226,7 @@ export function readArchivedMetadataRaw(
if (!latest) return null;
try {
return parseMetadataFile(readFileSync(join(archiveDir, latest), "utf-8"));
return parseKeyValueContent(readFileSync(join(archiveDir, latest), "utf-8"));
} catch {
return null;
}
@ -284,7 +256,7 @@ export function updateArchivedMetadata(
const archivePath = join(archiveDir, latest);
let existing: Record<string, string>;
try {
existing = parseMetadataFile(readFileSync(archivePath, "utf-8"));
existing = parseKeyValueContent(readFileSync(archivePath, "utf-8"));
} catch {
return false;
}

View File

@ -102,6 +102,14 @@ export function getWorktreesDir(configPath: string, projectPath: string): string
return join(getProjectBaseDir(configPath, projectPath), "worktrees");
}
/**
* Get the feedback reports directory for a project.
* Format: ~/.agent-orchestrator/{hash}-{projectId}/feedback-reports
*/
export function getFeedbackReportsDir(configPath: string, projectPath: string): string {
return join(getProjectBaseDir(configPath, projectPath), "feedback-reports");
}
/**
* Get the archive directory for a project.
* Format: ~/.agent-orchestrator/{hash}-{projectId}/archive