@kylecheng3146/agent-ops 0.8.5 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/packages/cli/src/args.js +11 -3
  2. package/dist/packages/cli/src/cli.js +3 -1
  3. package/dist/packages/cli/src/commands/parallel.js +5 -0
  4. package/dist/packages/cli/src/commands/review.js +92 -1
  5. package/dist/packages/cli/src/commands/task.js +17 -4
  6. package/dist/packages/cli/src/debug-intake.js +2 -2
  7. package/dist/packages/cli/src/run-entry.js +7 -5
  8. package/dist/runtime/src/install/harness.js +1 -1
  9. package/dist/runtime/src/logging/local-log.js +17 -0
  10. package/dist/runtime/src/parallel/finish.js +20 -3
  11. package/dist/runtime/src/parallel/receipt.js +6 -0
  12. package/dist/runtime/src/review/packet.js +13 -1
  13. package/dist/runtime/src/review/runner.js +24 -0
  14. package/dist/runtime/src/run/authorization.js +8 -4
  15. package/dist/runtime/src/run/planning.js +5 -2
  16. package/dist/runtime/src/schema/validate.js +12 -1
  17. package/dist/runtime/src/surface/discovery.js +221 -0
  18. package/dist/runtime/src/surface/schema.js +127 -0
  19. package/dist/runtime/src/task/contract.js +3 -1
  20. package/dist/runtime/src/task/debug-intake.js +3 -0
  21. package/dist/runtime/src/task/debug-user-event.js +4 -2
  22. package/dist/runtime/src/task/debug.js +4 -1
  23. package/dist/runtime/src/task/service.js +17 -2
  24. package/docs/en/spec/acceptance-and-evidence.md +54 -0
  25. package/docs/en/spec/judgment.md +7 -0
  26. package/docs/en/spec/loop-engineering.md +2 -2
  27. package/docs/en/spec/review.md +5 -0
  28. package/docs/zh-TW/spec/acceptance-and-evidence.md +48 -0
  29. package/docs/zh-TW/spec/judgment.md +6 -0
  30. package/docs/zh-TW/spec/loop-engineering.md +2 -2
  31. package/docs/zh-TW/spec/review.md +1 -0
  32. package/package.json +1 -1
  33. package/schemas/task.schema.json +7 -0
@@ -1,6 +1,7 @@
1
1
  import { validDebugSettings, validDebugIntake } from "../task/debug-state.js";
2
2
  import { CONFIG_SCHEMA_VERSION, EVIDENCE_SCHEMA_VERSION, MANIFEST_SCHEMA_VERSION, TASK_SCHEMA_VERSION } from "../contracts.js";
3
3
  import { acceptanceError, runnerError, commitIdentity } from "./acceptance.js";
4
+ import { parseSurface } from "../surface/schema.js";
4
5
  const ID_PATTERN = /^[a-z][a-z0-9-]{0,127}$/;
5
6
  const HASH_PATTERN = /^[a-f0-9]{64}$/;
6
7
  const WINDOWS_RESERVED_SEGMENT = /^(?:aux|com[1-9]|con|lpt[1-9]|nul|prn)(?:\..*)?$/i;
@@ -570,12 +571,22 @@ export function validateCriterion(value, path) {
570
571
  return success(value);
571
572
  }
572
573
  export function validateTask(value) {
573
- const root = validateRoot(value, ["criteria", "id", "intent", "goal", "contractRevision", "parentTaskId", "schemaVersion", "title", "debug"], isRecord(value) && value.schemaVersion === 1 ? 1 : TASK_SCHEMA_VERSION);
574
+ const root = validateRoot(value, ["criteria", "id", "intent", "goal", "contractRevision", "parentTaskId", "schemaVersion", "title", "debug", "surface", "surfaceRequired"], isRecord(value) && value.schemaVersion === 1 ? 1 : TASK_SCHEMA_VERSION);
574
575
  if (isFailure(root)) {
575
576
  return root;
576
577
  }
577
578
  if (root.debug !== undefined && (!validDebugIntake(root.debug) || root.schemaVersion !== TASK_SCHEMA_VERSION || root.goal === undefined))
578
579
  return failure("INVALID_DEBUG_TASK", "$.debug", "Debug tasks require immutable goal and bounded root intake.");
580
+ if (root.surfaceRequired !== undefined && root.surfaceRequired !== true)
581
+ return failure("INVALID_SURFACE", "$.surfaceRequired", "surfaceRequired is either absent or true.");
582
+ if (root.surface !== undefined) {
583
+ try {
584
+ parseSurface(root.surface);
585
+ }
586
+ catch {
587
+ return failure("INVALID_SURFACE", "$.surface", "Task surface is invalid.");
588
+ }
589
+ }
579
590
  if (root.contractRevision !== undefined && (!Number.isSafeInteger(root.contractRevision) || root.contractRevision < 0))
580
591
  return failure("INVALID_CONTRACT_REVISION", "$.contractRevision", "Invalid contract revision.");
581
592
  if (root.goal !== undefined && (!isNonEmptyString(root.goal) || Buffer.byteLength(root.goal, "utf8") > 65536))
@@ -0,0 +1,221 @@
1
+ import { execFile } from "node:child_process";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { canonicalJson } from "../config/hash.js";
5
+ import { sha256 } from "../fs/hash.js";
6
+ import { appendLocalLog } from "../logging/local-log.js";
7
+ import { localStatePaths, readPrivateFile, writePrivateFile } from "../security/permissions.js";
8
+ import { globMatcher, isCovers, missingScenarios, parseSurface, SURFACE_AXES } from "./schema.js";
9
+ /** Cheapest first: a budget overrun stops before the long tail of diff tokens. */
10
+ export const DISCOVERY_SOURCES = ["agent", "hunk-header", "template-fragment", "diff-token"];
11
+ export const SURFACE_LIMITS = {
12
+ agentQueryFiles: 200, derivedQueryFiles: 30, exclusionFiles: 10, budgetMs: 120_000, queryTimeoutMs: 30_000,
13
+ tokenLength: 6, fragmentLength: 4, derivedQueries: 400
14
+ };
15
+ /** argv only: queries are task text and must never meet a shell or a regex engine. */
16
+ function git(root, args, timeoutMs) {
17
+ return new Promise(resolve => {
18
+ execFile("git", [...args], { cwd: root, encoding: "buffer", timeout: Math.max(1, timeoutMs), maxBuffer: 64 * 1024 * 1024, shell: false }, (error, stdout) => {
19
+ const failure = error;
20
+ resolve({ code: failure === null ? 0 : typeof failure.code === "number" ? failure.code : null,
21
+ stdout: Buffer.isBuffer(stdout) ? stdout : Buffer.alloc(0), timedOut: failure?.killed === true });
22
+ });
23
+ });
24
+ }
25
+ const zList = (value) => value.toString("utf8").split("\0").filter(Boolean);
26
+ // Language keywords long enough to pass the length floor carry no surface signal.
27
+ const STOP = new Set(["return", "import", "export", "default", "function", "extends", "implements", "interface", "public",
28
+ "private", "protected", "static", "struct", "string", "number", "boolean", "object", "symbol", "typeof", "instanceof",
29
+ "delete", "select", "insert", "update", "values", "create", "require", "module", "exports", "global", "continue",
30
+ "switch", "package", "abstract", "unsigned", "virtual", "override", "readonly", "declare", "namespace", "undefined",
31
+ "nullptr", "lambda", "elseif", "except", "finally", "assert", "yield", "async", "await", "should", "expect",
32
+ "describe", "result", "props", "children", "className", "length", "constructor", "prototype", "console", "Promise"]);
33
+ const SEPARATOR = /^[:._/#-]|[:._/#-]$/u;
34
+ const LITERAL = /"((?:[^"\\\n]|\\.)*)"|'((?:[^'\\\n]|\\.)*)'|`((?:[^`\\\n]|\\.)*)`/gu;
35
+ const COMPOUND = /[a-z0-9][A-Z]|[A-Za-z0-9][_$][A-Za-z0-9]|[A-Za-z][0-9]/u;
36
+ const INTERPOLATION = /\$\{[^}]*\}|\{[^}]*\}|%[sdifjo]/u;
37
+ function functionName(context) {
38
+ const match = /\b(?:function|def|func|fn|class|interface|struct|sub|procedure|type)\s+([A-Za-z_$][\w$]*)/u.exec(context) ??
39
+ /([A-Za-z_$][\w$]*)\s*[=:]\s*(?:async\s*)?(?:function\b|\()/u.exec(context) ?? /([A-Za-z_$][\w$]*)\s*\(/u.exec(context);
40
+ const name = match?.[1];
41
+ return name !== undefined && name.length >= SURFACE_LIMITS.fragmentLength && !STOP.has(name) ? name : undefined;
42
+ }
43
+ /** Fixed strings a change implies: literals and identifiers, template fragments, enclosing functions. */
44
+ export function extractDiffQueries(diff) {
45
+ const tokens = new Set(), fragments = new Set(), functions = new Set();
46
+ for (const line of diff.split("\n")) {
47
+ const hunk = /^@@ [^@]* @@ ?(.*)$/u.exec(line);
48
+ if (hunk !== null) {
49
+ const name = functionName(hunk[1] ?? "");
50
+ if (name !== undefined)
51
+ functions.add(name);
52
+ continue;
53
+ }
54
+ if (!/^[+-]/u.test(line) || line.startsWith("+++") || line.startsWith("---"))
55
+ continue;
56
+ const body = line.slice(1);
57
+ for (const literal of body.matchAll(LITERAL)) {
58
+ const content = literal[1] ?? literal[2] ?? literal[3] ?? "";
59
+ for (const piece of content.split(new RegExp(INTERPOLATION.source, "gu")))
60
+ if (piece.length >= SURFACE_LIMITS.fragmentLength && SEPARATOR.test(piece) && piece.trim() === piece)
61
+ fragments.add(piece);
62
+ if (!INTERPOLATION.test(content) && content.trim().length >= SURFACE_LIMITS.tokenLength)
63
+ tokens.add(content);
64
+ }
65
+ // ponytail: compound identifiers only (snake_case, camelCase, digits), so prose
66
+ // words in docs and comments are not queries; aliases are phase 2.
67
+ for (const identifier of body.replace(LITERAL, " ").matchAll(/[A-Za-z_$][A-Za-z0-9_$]*/gu))
68
+ if (identifier[0].length >= SURFACE_LIMITS.tokenLength && !STOP.has(identifier[0]) && COMPOUND.test(identifier[0]))
69
+ tokens.add(identifier[0]);
70
+ }
71
+ // ponytail: fixed caps on derived queries; the 120 s budget bounds the rest.
72
+ const cap = (values) => [...values].slice(0, SURFACE_LIMITS.derivedQueries);
73
+ return { tokens: cap(tokens), fragments: cap(fragments), functions: cap(functions) };
74
+ }
75
+ function notRun(code, message, details, discovery) {
76
+ return { status: "NOT_RUN", code, message, details: details.slice(0, 200), ...(discovery === undefined ? {} : { discovery }) };
77
+ }
78
+ /** Advisory metrics: fail-open, never evidence. */
79
+ async function logMetrics(home, rows) {
80
+ try {
81
+ const state = localStatePaths(home);
82
+ for (const row of rows)
83
+ await appendLocalLog(join(state.logDirectory, "events.ndjson"), { type: "surface-metrics", ...row }, { anchorDirectory: state.anchorDirectory });
84
+ }
85
+ catch { /* advisory only */ }
86
+ }
87
+ export async function logSurfaceResidualRisks(count, home = process.env.AGENT_OPS_HOME ?? homedir()) {
88
+ await logMetrics(home, [{ source: "review", hits: 0, included: 0, excluded: 0, postBlockExclusions: 0, residualRisks: count }]);
89
+ }
90
+ /** EVIDENCE-SURFACE-001 gate run before any reviewer: every hit must be classified. */
91
+ export async function runSurfaceGate(input) {
92
+ if (input.surface === undefined)
93
+ return input.required
94
+ ? notRun("SURFACE_MISSING", "This task requires a surface audit: task revise --surface-file <json>.", []) : { status: "PASS", discovery: null };
95
+ let surface;
96
+ try {
97
+ const parsed = parseSurface(input.surface);
98
+ if (isCovers(parsed))
99
+ return notRun("SURFACE_MISSING", "A covers-only surface needs a surfaced parent.", []);
100
+ surface = parsed;
101
+ }
102
+ catch (error) {
103
+ return notRun("SURFACE_MISSING", error instanceof Error ? error.message : "Unparseable surface.", []);
104
+ }
105
+ const scenarios = missingScenarios(surface);
106
+ if (scenarios.length > 0)
107
+ return notRun("SCENARIO_MISSING", "Every scenario dimension needs applies+behavior or n/a: reason.", scenarios);
108
+ if (input.children !== undefined && input.children.length > 0) {
109
+ const included = new Set(surface.included.map(row => row.id));
110
+ const covered = new Set(input.children.flatMap(child => child.covers));
111
+ const uncovered = [...included].filter(id => !covered.has(id));
112
+ const unknown = [...covered].filter(id => !included.has(id));
113
+ if (uncovered.length > 0 || unknown.length > 0)
114
+ return notRun("SURFACE_UNASSIGNED", "Children covers must equal the parent included surfaces.", [{ uncovered, unknown }]);
115
+ }
116
+ const now = input.now ?? Date.now;
117
+ const started = now();
118
+ const budget = input.budgetMs ?? SURFACE_LIMITS.budgetMs;
119
+ const remaining = () => budget - (now() - started);
120
+ const changed = new Set(input.changedFiles);
121
+ const tracked = await git(input.root, ["ls-files", "-z"], Math.min(SURFACE_LIMITS.queryTimeoutMs, Math.max(1, remaining())));
122
+ if (tracked.timedOut || remaining() <= 0)
123
+ return notRun("SURFACE_BUDGET_EXCEEDED", "Discovery budget exhausted listing files.", []);
124
+ const exclusions = surface.excluded.map(row => ({ ...row, match: globMatcher(row.glob) }));
125
+ for (const row of exclusions) {
126
+ const files = zList(tracked.stdout).filter(row.match);
127
+ const diffFiles = [...changed].filter(row.match);
128
+ if (files.length > SURFACE_LIMITS.exclusionFiles || diffFiles.length > 0)
129
+ return notRun("SURFACE_EXCLUSION_TOO_BROAD", diffFiles.length > 0 ? "Files in the diff cannot be excluded."
130
+ : `One exclusion may hide at most ${SURFACE_LIMITS.exclusionFiles} files.`, [{ glob: row.glob, files: files.length, diffFiles }]);
131
+ }
132
+ const head = (await git(input.root, ["rev-parse", "HEAD"], SURFACE_LIMITS.queryTimeoutMs)).stdout.toString("utf8").trim();
133
+ const diff = (await git(input.root, ["diff", "-U0", "--no-color", "--no-ext-diff", input.base ?? "HEAD", "--"], SURFACE_LIMITS.queryTimeoutMs)).stdout.toString("utf8");
134
+ const derived = extractDiffQueries(diff);
135
+ const agent = [...new Set(SURFACE_AXES.flatMap(name => { const axis = surface.axes[name]; return "queries" in axis ? axis.queries : []; }))];
136
+ const planned = [];
137
+ const seen = new Set();
138
+ for (const [source, queries] of [["agent", agent], ["hunk-header", derived.functions], ["template-fragment", derived.fragments],
139
+ ["diff-token", derived.tokens]])
140
+ for (const query of queries)
141
+ if (!seen.has(query)) {
142
+ seen.add(query);
143
+ planned.push({ source, query });
144
+ }
145
+ const key = sha256(canonicalJson({ head, base: input.base ?? null, diff: sha256(diff), queries: [...agent].sort(), config: input.configHash }));
146
+ const cachePath = join(input.root, ".agent-ops", "tasks", "surface", "cache", key + ".json");
147
+ let hits;
148
+ let cached = false;
149
+ try {
150
+ const stored = await readPrivateFile(cachePath, input.root);
151
+ if (stored !== null) {
152
+ hits = JSON.parse(stored);
153
+ cached = true;
154
+ }
155
+ }
156
+ catch {
157
+ hits = undefined;
158
+ }
159
+ if (hits === undefined) {
160
+ hits = [];
161
+ for (const { source, query } of planned) {
162
+ if (remaining() <= 0)
163
+ return notRun("SURFACE_BUDGET_EXCEEDED", `Discovery exceeded its ${budget} ms budget; narrow the diff or queries. Remaining queries were not skipped silently.`, [{ source, query, remaining: planned.length - hits.length }]);
164
+ const result = await git(input.root, ["grep", "-F", "-l", "-z", "-I", "--no-color", "-e", query, "--"], Math.min(SURFACE_LIMITS.queryTimeoutMs, remaining()));
165
+ if (result.timedOut)
166
+ return notRun("SURFACE_BUDGET_EXCEEDED", "A discovery query timed out.", [{ source, query }]);
167
+ hits.push({ source, query, files: result.code === 0 ? zList(result.stdout) : [] });
168
+ }
169
+ await writePrivateFile(cachePath, JSON.stringify(hits), input.root).catch(() => undefined);
170
+ }
171
+ const included = surface.included.map(row => globMatcher(row.path));
172
+ const unknownPaths = surface.unknown.flatMap(row => row.path === undefined ? [] : [globMatcher(row.path)]);
173
+ const blockPath = join(input.root, ".agent-ops", "tasks", "surface", input.taskId + ".blocks.json");
174
+ let blocked = [];
175
+ try {
176
+ blocked = JSON.parse(await readPrivateFile(blockPath, input.root) ?? "[]");
177
+ }
178
+ catch {
179
+ blocked = [];
180
+ }
181
+ const postBlockExclusions = blocked.flatMap(path => {
182
+ const row = exclusions.find(exclusion => exclusion.match(path));
183
+ return row === undefined ? [] : [{ path, glob: row.glob, reason: row.reason }];
184
+ });
185
+ const sources = Object.fromEntries(DISCOVERY_SOURCES.map(source => [source, { queries: 0, files: 0 }]));
186
+ const skipped = [];
187
+ const unclassified = new Map();
188
+ const metrics = new Map(DISCOVERY_SOURCES.map(source => [source, { hits: new Set(), included: new Set(), excluded: new Set() }]));
189
+ for (const hit of hits) {
190
+ sources[hit.source].queries += 1;
191
+ if (hit.source === "agent" && hit.files.length > SURFACE_LIMITS.agentQueryFiles)
192
+ return notRun("SURFACE_QUERY_TOO_BROAD", `Query hits more than ${SURFACE_LIMITS.agentQueryFiles} files; record a more specific fixed string.`, [{ query: hit.query, files: hit.files.length }]);
193
+ if (hit.source !== "agent" && hit.files.length > SURFACE_LIMITS.derivedQueryFiles) {
194
+ skipped.push({ source: hit.source, query: hit.query, files: hit.files.length });
195
+ continue;
196
+ }
197
+ const metric = metrics.get(hit.source);
198
+ for (const path of hit.files) {
199
+ metric.hits.add(path);
200
+ if (changed.has(path) || included.some(match => match(path)) || unknownPaths.some(match => match(path)))
201
+ metric.included.add(path);
202
+ else if (exclusions.some(row => row.match(path)))
203
+ metric.excluded.add(path);
204
+ else
205
+ unclassified.set(path, [...(unclassified.get(path) ?? []), { source: hit.source, query: hit.query }]);
206
+ }
207
+ }
208
+ for (const source of DISCOVERY_SOURCES)
209
+ sources[source].files = metrics.get(source).hits.size;
210
+ const discovery = { cached, sources, skipped: skipped.slice(0, 50), postBlockExclusions };
211
+ if (input.metrics === true)
212
+ await logMetrics(input.logHome ?? process.env.AGENT_OPS_HOME ?? homedir(), DISCOVERY_SOURCES.map(source => ({ source,
213
+ hits: metrics.get(source).hits.size, included: metrics.get(source).included.size, excluded: metrics.get(source).excluded.size,
214
+ postBlockExclusions: postBlockExclusions.length, residualRisks: 0 })));
215
+ if (unclassified.size > 0) {
216
+ const paths = [...unclassified.keys()].sort();
217
+ await writePrivateFile(blockPath, JSON.stringify([...new Set([...blocked, ...paths])].sort()), input.root).catch(() => undefined);
218
+ return notRun("SURFACE_UNCLASSIFIED", "Discovery found places outside the diff and the surface lists; include, exclude with a reason, or mark UNKNOWN.", paths.map(path => ({ path, hits: unclassified.get(path).slice(0, 5) })), discovery);
219
+ }
220
+ return { status: "PASS", discovery };
221
+ }
@@ -0,0 +1,127 @@
1
+ import { AgentOpsError } from "../fs/paths.js";
2
+ /** EVIDENCE-SURFACE-001: where else the change lands, recorded before criteria. */
3
+ export const SURFACE_AXES = ["dependents", "duplicates", "otherPaths", "sharedState"];
4
+ export const SCENARIO_DIMENSIONS = ["initialState", "redo", "timeBoundary", "identitySwitch", "concurrency"];
5
+ export const VALUE_CHANGE_KINDS = ["clear", "redact", "default", "normalize", "unit-format"];
6
+ export const isCovers = (surface) => "covers" in surface;
7
+ const record = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
8
+ const text = (v, max = 2048) => typeof v === "string" && v.trim().length > 0 && v.length <= max && !v.includes("\0");
9
+ const id = (v) => typeof v === "string" && /^[a-z][a-z0-9-]{0,63}$/u.test(v);
10
+ const exact = (v, required, optional = []) => required.every(k => Object.hasOwn(v, k)) && Object.keys(v).every(k => required.includes(k) || optional.includes(k));
11
+ // "out of scope" names where the work stops, not why the surface is unaffected.
12
+ const reason = (v) => text(v) && !/^\s*(?:n\/?a|none|out[ -]of[ -]scope|unrelated|not relevant|irrelevant)\s*[.!]?\s*$/iu.test(v);
13
+ const glob = (v) => text(v, 256) && !v.startsWith("/") && !v.includes("\\") &&
14
+ !/[\n\r]/u.test(v) && v.split("/").every(part => part !== "" && part !== "." && part !== "..");
15
+ const list = (v, max) => Array.isArray(v) && v.length <= max;
16
+ function invalid(message) {
17
+ throw new AgentOpsError("SURFACE_MISSING", message);
18
+ }
19
+ function axis(value, name) {
20
+ if (record(value) && exact(value, ["na"])) {
21
+ if (!reason(value.na))
22
+ invalid(`Axis ${name} n/a needs a reason the axis cannot apply.`);
23
+ return { na: value.na };
24
+ }
25
+ if (!record(value) || !exact(value, ["queries"]) || !list(value.queries, 32) || value.queries.length === 0 ||
26
+ !value.queries.every(q => text(q, 256) && !/[\n\r]/u.test(q)) || new Set(value.queries).size !== value.queries.length)
27
+ invalid(`Axis ${name} needs 1-32 unique single-line fixed-string queries or {na: reason}.`);
28
+ return { queries: [...value.queries] };
29
+ }
30
+ /** Strict parse of a stored or submitted surface; any defect is SURFACE_MISSING. */
31
+ export function parseSurface(value) {
32
+ if (record(value) && exact(value, ["covers"])) {
33
+ if (!list(value.covers, 256) || value.covers.length === 0 || !value.covers.every(id) || new Set(value.covers).size !== value.covers.length)
34
+ invalid("covers must list unique parent surface IDs.");
35
+ return { covers: [...value.covers] };
36
+ }
37
+ if (!record(value) || !exact(value, ["axes", "included", "excluded", "unknown", "contracts", "valueChanges", "scenarios"]))
38
+ invalid("Surface file needs axes, included, excluded, unknown, contracts, valueChanges and scenarios (or covers for a child).");
39
+ const axes = value.axes;
40
+ if (!record(axes) || !exact(axes, [...SURFACE_AXES]))
41
+ invalid("axes must name dependents, duplicates, otherPaths and sharedState.");
42
+ const ids = new Set();
43
+ const unique = (v) => { if (ids.has(v))
44
+ invalid(`Duplicate surface ID ${v}.`); ids.add(v); return v; };
45
+ if (!list(value.included, 256) || value.included.length === 0)
46
+ invalid("included needs at least one surface (a single surface is fine).");
47
+ const included = value.included.map(row => {
48
+ if (!record(row) || !exact(row, ["id", "path", "behavior"]) || !id(row.id) || !glob(row.path) || !text(row.behavior))
49
+ invalid("Each included surface needs id, path glob and observable behavior.");
50
+ return { id: unique(row.id), path: row.path, behavior: row.behavior };
51
+ });
52
+ if (!list(value.excluded, 256))
53
+ invalid("excluded must be a list.");
54
+ const excluded = value.excluded.map(row => {
55
+ if (!record(row) || !exact(row, ["glob", "reason"]) || !glob(row.glob))
56
+ invalid("Each exclusion needs a path glob and a reason.");
57
+ if (!reason(row.reason))
58
+ invalid(`Exclusion ${row.glob}: say why the surface is unaffected; "out of scope" is not a reason.`);
59
+ return { glob: row.glob, reason: row.reason };
60
+ });
61
+ if (!list(value.unknown, 256))
62
+ invalid("unknown must be a list.");
63
+ const unknown = value.unknown.map(row => {
64
+ if (!record(row) || !exact(row, ["id", "reader", "behavior"], ["path"]) || !id(row.id) || !text(row.reader) ||
65
+ !text(row.behavior) || (row.path !== undefined && !glob(row.path)))
66
+ invalid("Each UNKNOWN surface needs id, reader, behavior and an optional path glob.");
67
+ return { id: unique(row.id), reader: row.reader, behavior: row.behavior, ...(row.path === undefined ? {} : { path: row.path }) };
68
+ });
69
+ if (!list(value.contracts, 256))
70
+ invalid("contracts must be a list.");
71
+ const contracts = value.contracts.map(row => {
72
+ if (!record(row) || !exact(row, ["id", "name", "change", "behavior"]) || !id(row.id) || !text(row.name) ||
73
+ (row.change !== "compatible" && row.change !== "breaking") || !text(row.behavior))
74
+ invalid("Each shared contract needs id, name, change compatible|breaking and behavior.");
75
+ return { id: unique(row.id), name: row.name, change: row.change, behavior: row.behavior };
76
+ });
77
+ if (!list(value.valueChanges, 128))
78
+ invalid("valueChanges must be a list.");
79
+ const valueChanges = value.valueChanges.map(row => {
80
+ if (!record(row) || !exact(row, ["producer", "kind", "change", "consumers", "indirectConsumers"]) || !text(row.producer) ||
81
+ !VALUE_CHANGE_KINDS.includes(row.kind) || !text(row.change) ||
82
+ !list(row.consumers, 64) || row.consumers.length === 0 || !row.consumers.every(c => text(c, 512)) ||
83
+ !list(row.indirectConsumers, 64) || !row.indirectConsumers.every(c => text(c, 512)))
84
+ invalid("Each value change needs producer, kind, change, consumers (first hop) and indirectConsumers (second hop).");
85
+ return { producer: row.producer, kind: row.kind, change: row.change,
86
+ consumers: [...row.consumers], indirectConsumers: [...row.indirectConsumers] };
87
+ });
88
+ const scenarios = value.scenarios;
89
+ if (!record(scenarios) || Object.keys(scenarios).some(k => !SCENARIO_DIMENSIONS.includes(k)))
90
+ invalid(`scenarios may name only ${SCENARIO_DIMENSIONS.join(", ")}.`);
91
+ const parsedScenarios = {};
92
+ for (const [key, row] of Object.entries(scenarios)) {
93
+ if (record(row) && exact(row, ["applies", "behavior"]) && row.applies === true && text(row.behavior))
94
+ parsedScenarios[key] = { applies: true, behavior: row.behavior };
95
+ else if (record(row) && exact(row, ["na"]) && reason(row.na))
96
+ parsedScenarios[key] = { na: row.na };
97
+ else
98
+ invalid(`Scenario ${key} needs {applies: true, behavior} or {na: reason}.`);
99
+ }
100
+ return {
101
+ axes: Object.fromEntries(SURFACE_AXES.map(name => [name, axis(axes[name], name)])),
102
+ included, excluded, unknown, contracts, valueChanges, scenarios: parsedScenarios
103
+ };
104
+ }
105
+ export function missingScenarios(surface) {
106
+ return SCENARIO_DIMENSIONS.filter(name => surface.scenarios[name] === undefined);
107
+ }
108
+ /** `**` crosses directories, `*` and `?` stay within one segment. */
109
+ export function globMatcher(pattern) {
110
+ let source = "";
111
+ for (let i = 0; i < pattern.length; i += 1) {
112
+ const c = pattern[i];
113
+ if (c === "*" && pattern[i + 1] === "*") {
114
+ const slash = pattern[i + 2] === "/";
115
+ source += slash ? "(?:.*/)?" : ".*";
116
+ i += slash ? 2 : 1;
117
+ }
118
+ else if (c === "*")
119
+ source += "[^/]*";
120
+ else if (c === "?")
121
+ source += "[^/]";
122
+ else
123
+ source += c.replace(/[.+^${}()|[\]\\]/gu, "\\$&");
124
+ }
125
+ const expression = new RegExp(`^${source}$`, "u");
126
+ return path => expression.test(path);
127
+ }
@@ -9,7 +9,9 @@ export function criterionContractHash(criterion) {
9
9
  export function taskContractHash(task) {
10
10
  return sha256(canonicalJson({ goalHash: goalHash(task), title: task.title,
11
11
  intent: task.intent ?? null, revision: task.contractRevision ?? 0,
12
- criteria: task.criteria.map(criterionContractHash), ...(task.debug === undefined ? {} : { debug: task.debug }) }));
12
+ criteria: task.criteria.map(criterionContractHash), ...(task.debug === undefined ? {} : { debug: task.debug }),
13
+ ...(task.surface === undefined ? {} : { surface: sha256(canonicalJson(task.surface)) }),
14
+ ...(task.surfaceRequired === undefined ? {} : { surfaceRequired: true }) }));
13
15
  }
14
16
  export function treeContractHash(tasks) {
15
17
  return sha256(canonicalJson(tasks.map(task => ({ id: task.id, hash: taskContractHash(task) }))
@@ -2,6 +2,9 @@
2
2
  export function isDebugIssue(prompt) {
3
3
  if (prompt.length === 0 || prompt.length > 64 * 1024 || prompt.includes("\0"))
4
4
  return false;
5
+ // Harness-injected turns (subagent hand-backs, task notifications) are not a human report.
6
+ if (/^\s*<(?:agent-message|task-notification|teammate-message)\b/u.test(prompt))
7
+ return false;
5
8
  const value = prompt.normalize("NFKC").toLowerCase();
6
9
  if (/\b(?:explain|describe|what is|how does|refactor|feature|implement|design)\b|說明|解释|解釋|重構|新增功能|設計|實作功能/u.test(value))
7
10
  return false;
@@ -38,7 +38,8 @@ function codexUserText(row) {
38
38
  return undefined;
39
39
  }
40
40
  /** Host transcripts are the user-event authority; CLI actor/text fields are never accepted. */
41
- export async function readDebugUserEvent(root, sessionId, home = homedir(), kind = "latest") {
41
+ /** `worktreeRoot` is the session's owned worktree: Claude stamps each row with the shell's cwd, which moves there after `cd`. */
42
+ export async function readDebugUserEvent(root, sessionId, home = homedir(), kind = "latest", worktreeRoot) {
42
43
  if (!/^[a-f0-9-]{36}$/u.test(sessionId))
43
44
  throw new AgentOpsError("TASK_DEBUG_GRANT_UNSUPPORTED", "This host has no identifiable native user-event source.");
44
45
  const canonicalHome = await realpath(home).catch(() => home);
@@ -77,7 +78,8 @@ export async function readDebugUserEvent(root, sessionId, home = homedir(), kind
77
78
  for (let index = rows.length - 1; index >= 0; index--) {
78
79
  const row = rows[index];
79
80
  const text = codexSource ? codexUserText(row) :
80
- row?.type === "user" && row.sessionId === sessionId && row.cwd === root && row.message?.role === "user" ? row.message.content : undefined;
81
+ row?.type === "user" && row.sessionId === sessionId && (row.cwd === root || (worktreeRoot !== undefined && row.cwd === worktreeRoot)) && row.message?.role === "user" &&
82
+ row.isMeta !== true && (row.origin === undefined || row.origin?.kind === "human") ? row.message.content : undefined;
81
83
  if (typeof text !== "string" || text.length > 64 * 1024 || (kind === "control" && debugUserControl(text) === null))
82
84
  continue;
83
85
  const timestamp = Date.parse(row.timestamp);
@@ -248,7 +248,7 @@ export class DebugService {
248
248
  const record = await readWorktreeRecord(this.options.root);
249
249
  const origin = record?.mainRoot ?? this.options.root;
250
250
  try {
251
- return await readDebugUserEvent(origin, sessionId, home, kind);
251
+ return await readDebugUserEvent(origin, sessionId, home, kind, origin === this.options.root ? undefined : this.options.root);
252
252
  }
253
253
  catch (error) {
254
254
  if (origin === this.options.root || !(error instanceof AgentOpsError) || error.code !== "TASK_DEBUG_GRANT_UNSUPPORTED")
@@ -348,6 +348,9 @@ export class DebugService {
348
348
  if (root.debug.receipts.length >= 256)
349
349
  fail("TASK_DEBUG_HISTORY_LIMIT", "Execution history is full; retain it and report the blocker.");
350
350
  const productDigest = await this.#productDigest(root.debug);
351
+ // EVIDENCE-SURFACE-001: intake may start without a surface, the first repair may not.
352
+ if (kind === "verify" && task.task.surfaceRequired === true && task.task.surface === undefined && productDigest !== sha256(canonicalJson([])))
353
+ fail("SURFACE_MISSING", "Add the surface audit with task revise --surface-file before verifying the first repair.");
351
354
  const previousCandidate = root.debug.receipts.filter(r => r.kind === "verify" && (r.outcome === "PASS" || r.outcome === "FAIL")).at(-1)?.productDigest ?? sha256("[]");
352
355
  const cause = debugStopCause(root.debug);
353
356
  // Recovery reruns the original verifier; changed product content still needs fresh proof.
@@ -18,6 +18,7 @@ import { commitIdentity } from "../schema/acceptance.js";
18
18
  import { createDebugState, DEBUG_DEFAULTS } from "./debug-state.js";
19
19
  import { DebugService } from "./debug.js";
20
20
  import { sameDebugText } from "./debug-intake.js";
21
+ import { isCovers, parseSurface } from "../surface/schema.js";
21
22
  const SESSION_ID_PATTERN = /^[^\0\r\n]{1,256}$/u;
22
23
  function defaultTaskId() {
23
24
  return `task-${randomUUID()}`;
@@ -123,9 +124,12 @@ export class TaskService {
123
124
  const criteria = structuredClone(input.criteria).map(criterion => ({ ...criterion,
124
125
  ...(criterion.acceptance === undefined ? {} : { acceptance: { ...criterion.acceptance,
125
126
  baselineCommit: criterion.acceptance.baselineCommit ?? createdSourceCommit } }) }));
127
+ const surface = input.surface === undefined ? undefined : parseSurface(input.surface);
126
128
  const task = {
127
129
  schemaVersion: TASK_SCHEMA_VERSION,
128
130
  id: this.#generateId(),
131
+ ...(surface === undefined ? {} : { surface }),
132
+ ...(input.requireSurface === true ? { surfaceRequired: true } : {}),
129
133
  title: input.title,
130
134
  ...(input.intent === undefined ? {} : { intent: input.intent }),
131
135
  criteria,
@@ -186,6 +190,14 @@ export class TaskService {
186
190
  validation.value.debug = structuredClone(parent.task.debug);
187
191
  }
188
192
  }
193
+ if (input.requireSurface === true && validation.value.debug === undefined) {
194
+ const parentSurface = state.tasks.find(record => record.task.id === input.parentTaskId)?.task.surface;
195
+ const surfacedParent = parentSurface !== undefined && !isCovers(parentSurface);
196
+ if (surface === undefined || isCovers(surface) !== surfacedParent)
197
+ throw taskError("SURFACE_MISSING", surfacedParent
198
+ ? "A subtask of a surfaced parent declares --surface-file {\"covers\": [surface-id]}."
199
+ : "A new task needs --surface-file with the four-axis surface audit (EVIDENCE-SURFACE-001).");
200
+ }
189
201
  const record = {
190
202
  task: validation.value,
191
203
  ...(input.debug === undefined ? {} : { debug: createDebugState(task.id, createdSourceCommit, debugSettings, this.#completion.root) }),
@@ -250,8 +262,11 @@ export class TaskService {
250
262
  throw taskError("TASK_NOT_ACTIVE", "Only active tasks can revise acceptance.");
251
263
  if (taskContractHash(previous.task) !== input.expectedContractHash)
252
264
  throw taskError("TASK_CONTRACT_CHANGED", "Task contract changed.");
265
+ if (input.criteria === undefined && input.surface === undefined)
266
+ throw taskError("TASK_REVISION_INVALID", "Revision requires criteria, a surface, or both.");
267
+ const surface = input.surface === undefined ? previous.task.surface : parseSurface(input.surface);
253
268
  const baseline = input.baseline ?? previous.createdSourceCommit;
254
- const criteria = structuredClone(input.criteria).map(criterion => {
269
+ const criteria = input.criteria === undefined ? structuredClone(previous.task.criteria) : structuredClone(input.criteria).map(criterion => {
255
270
  if (criterion.acceptance === undefined)
256
271
  return criterion;
257
272
  const sha = criterion.acceptance.baselineCommit ?? baseline;
@@ -263,7 +278,7 @@ export class TaskService {
263
278
  return { ...criterion, acceptance: { ...criterion.acceptance, baselineCommit: sha } };
264
279
  });
265
280
  const next = { ...previous.task, schemaVersion: TASK_SCHEMA_VERSION,
266
- contractRevision: (previous.task.contractRevision ?? 0) + 1, criteria };
281
+ contractRevision: (previous.task.contractRevision ?? 0) + 1, criteria, ...(surface === undefined ? {} : { surface }) };
267
282
  const checked = validateTask(next);
268
283
  if (!checked.ok)
269
284
  throw taskError("TASK_INVALID", checked.errors[0]?.message ?? "Invalid criterion revision.");
@@ -68,6 +68,60 @@ Each child worktree MUST record its modification intent before editing.
68
68
  - Positive: `A child records its planned API change, commits it, and the final receipt includes that intent and integrated verification.`
69
69
  - Negative: `A child edits first and supplies a retrospective one-line intent at delivery.`
70
70
 
71
+ ## EVIDENCE-SURFACE-001
72
+
73
+ Every new task MUST record a surface audit of where else its change lands before writing criteria, and review MUST NOT run while discovery finds an unclassified place.
74
+
75
+ - Trigger: Creating a task (`task create`, a run plan, or before a DEBUG task's first repair), revising its surface, or reviewing it.
76
+ - Action: Search four axes with recorded fixed-string queries or `n/a: reason`, classify every hit as included, excluded (glob + reason) or UNKNOWN, give every surface row an observable `behavior`, and declare each shared contract compatible or breaking. Pass it with `task create|revise --surface-file <json>`.
77
+ - Evidence: The surface is stored on the task, bound into its contract hash with revision history, replayed by `git grep -F` before review, and handed to both reviewers under the `surface-coverage` criterion.
78
+ - Positive: `A diff token hits three copy-pasted sibling pages; the audit includes them with their behavior before review runs.`
79
+ - Negative: `Exclude src/** because it is "out of scope", or list only the files the diff already touches.`
80
+
81
+ - Axes: `dependents` (importers, callers, readers), `duplicates` (copied
82
+ markup, strings, logic that imports nothing), `otherPaths` (other routes,
83
+ modes, entry points or re-entries to the same concept) and `sharedState`
84
+ (storage, cache, messages, external APIs, client storage, issued URLs). The
85
+ design is stack-agnostic: the same axes cover frontend, backend, database,
86
+ cache and message flows.
87
+ - `included` rows carry `id`, `path` glob and `behavior`; `excluded` rows carry
88
+ `glob` and a reason saying why the surface is unaffected ("out of scope" is
89
+ not a reason); `unknown` rows name a reader the repository cannot see.
90
+ `contracts` declare `compatible` or `breaking`. A single surface is allowed.
91
+ - `valueChanges` `[{producer, kind, change, consumers, indirectConsumers}]`
92
+ list two hops whenever a value's semantics change (clear, redact, default,
93
+ normalize, unit/format).
94
+ - `scenarios` cover five generic dimensions — `initialState` (empty vs
95
+ existing), `redo` (reload, retry, redelivery, idempotency), `timeBoundary`
96
+ (day, TTL, timezone), `identitySwitch` (store, role, tenant) and
97
+ `concurrency` — each `{applies: true, behavior}` or `{na: reason}`.
98
+ - A parent holds the surface; its children declare `{"covers": [surface-id]}`
99
+ and their union must equal the parent's included list.
100
+ - Before any reviewer runs, discovery replays with `git grep -F` through argv
101
+ (never a shell or a regex built from task text), cheapest source first:
102
+ recorded queries; enclosing function names from diff hunk headers;
103
+ template-literal fixed fragments (at least 4 characters, separator-bounded,
104
+ such as `order:`, `user_`, `menu.`); compound identifiers (snake_case,
105
+ camelCase or containing digits, so prose words are not queries) and string
106
+ literals from added and removed lines (at least 6 characters; skipped as too
107
+ generic above 30 file hits). Every hit outside the diff and the surface lists stops review.
108
+ Results are cached by HEAD, base, diff, query set and config hash.
109
+ - Review returns NOT_RUN with: `SURFACE_MISSING` (no or unparseable surface;
110
+ also refused at create for non-DEBUG tasks), `SURFACE_UNCLASSIFIED`,
111
+ `SURFACE_QUERY_TOO_BROAD` (one recorded query over 200 files),
112
+ `SURFACE_EXCLUSION_TOO_BROAD` (one glob over 10 files, or any file in the
113
+ diff), `SCENARIO_MISSING`, `SURFACE_BUDGET_EXCEEDED` (120 s total, never
114
+ silently skipped) or `SURFACE_UNASSIGNED` (children's covers differ from the
115
+ parent's included list; checked by every review, including advance and batch).
116
+ - A surface revision is a contract revision: fresh evidence and two fresh
117
+ reviews. Exclusions added after an `SURFACE_UNCLASSIFIED` block are flagged
118
+ to reviewers as post-block exclusions.
119
+ - Tasks created before this rule shipped are grandfathered. Advisory local-log
120
+ metrics per discovery source are fail-open and never evidence.
121
+ - Plain `worktree finish` writes the same local receipt as advance, including
122
+ both rounds' residual risks and non-blocking findings, references it in the
123
+ git note and prints the residual risks.
124
+
71
125
  ## EVIDENCE-AGGREGATE-001
72
126
 
73
127
  The verifier MUST return FAIL when a criterion is missing, duplicated, unknown, or has empty evidence.
@@ -10,6 +10,13 @@ The operator MUST stop when requirements, authorization, or a required decision
10
10
  - Positive: `Ask whether project or user scope is intended before applying installation.`
11
11
  - Negative: `Assume project scope because it is more convenient.`
12
12
 
13
+ Surface audit triggers ([EVIDENCE-SURFACE-001](acceptance-and-evidence.md#evidence-surface-001)) also stop before editing:
14
+
15
+ - A `breaking` change to a shared contract with any UNKNOWN reader.
16
+ - A discovered surface excluded as "intentionally different": send the human
17
+ one consolidated behavior table (surface, current behavior, proposed
18
+ behavior, reason) and wait for the decision.
19
+
13
20
  ## JUDGMENT-RETRY-001
14
21
 
15
22
  The operator MUST change approach after the same failure repeats without new information.
@@ -53,10 +53,10 @@ This module defines the bounded loop used to plan, implement, verify, and hand o
53
53
 
54
54
  ## LOOP-START-001
55
55
 
56
- The operator MUST state acceptance criteria before editing and MUST stop when each criterion has evidence.
56
+ The operator MUST audit the change's surfaces, then state acceptance criteria before editing, and MUST stop when each criterion has evidence.
57
57
 
58
58
  - Trigger: Starting a multi-step implementation or debugging loop.
59
- - Action: Record 2–5 observable criteria, then inspect only the files needed for the current step.
59
+ - Action: Record the surface audit ([EVIDENCE-SURFACE-001](acceptance-and-evidence.md#evidence-surface-001)), then 2–5 observable criteria, then inspect only the files within the audited scope that the current step needs.
60
60
  - Evidence: The task record and final report link each criterion to a command or read-back.
61
61
  - Positive: `Criteria: tests pass; package builds; changed files are read back.`
62
62
  - Negative: `I changed the repository and will decide what success means afterward.`
@@ -84,6 +84,11 @@ successful refutation MUST make the run FAIL.
84
84
  - Negative: `Manufacture a refutation so the challenge looks effective.`
85
85
  - Note: With one configured target the same target is invoked again in a fresh
86
86
  session. A FAIL is already terminal and is never re-checked.
87
+ - Note: For a surfaced task the second round also hunts unlisted surfaces and
88
+ scenarios under `surface-coverage`: it checks the five fixed scenario
89
+ dimensions and may run at most 3 new `git grep -F` queries per axis. Round 1
90
+ replays the recorded queries and reads every listed `valueChanges` consumer;
91
+ this is the only exception to "do not survey the repository".
87
92
 
88
93
  ## REVIEW-HOST-001
89
94