@kylecheng3146/agent-ops 0.8.6 → 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.
@@ -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) }))
@@ -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
 
@@ -42,6 +42,54 @@ English source version: 2026-10-06. Revalidate: when the English specification c
42
42
  - Positive: `子 agent 先記錄預定 API 修改、提交程式碼,最後完成紀錄包含該意圖與整合後驗證。`
43
43
  - Negative: `先修改程式碼,交付時才補寫一行事後意圖。`
44
44
 
45
+ ## EVIDENCE-SURFACE-001
46
+
47
+ 每個新 task MUST 在寫驗收條件前記錄變更還會影響哪些位置(surface audit);discovery 找到未分類位置時,review MUST NOT 執行。
48
+
49
+ - Trigger: 建立 task(`task create`、run plan,或 DEBUG task 第一次修復之前)、修訂 surface,或審查 task。
50
+ - Action: 以記錄下來的固定字串查詢或 `n/a: 理由` 搜尋四個軸向,把每個命中分類為 included、excluded(glob + 理由)或 UNKNOWN,每列 surface 都寫出可觀察的 `behavior`,並宣告每個共享契約為 compatible 或 breaking。以 `task create|revise --surface-file <json>` 提交。
51
+ - Evidence: surface 存在 task 上、綁進 contract hash 並保留修訂歷史,review 前以 `git grep -F` 重放,並以 `surface-coverage` 條件交給兩位 reviewer。
52
+ - Positive: `diff token 命中三個複製貼上的同類頁面;review 前 audit 已把它們連同行為列入 included。`
53
+ - Negative: `以「out of scope」排除 src/**,或只列 diff 已修改的檔案。`
54
+
55
+ - 軸向:`dependents`(import、呼叫者、讀取者)、`duplicates`(複製的 markup、
56
+ 字串或不 import 任何東西的邏輯)、`otherPaths`(同一概念的其他路由、模式、
57
+ 入口或重新進入點)、`sharedState`(storage、cache、訊息、外部 API、client
58
+ storage、已發出的 URL)。設計與技術棧無關:相同軸向涵蓋前端、後端、資料庫、
59
+ cache 與訊息流。
60
+ - `included` 列含 `id`、`path` glob 與 `behavior`;`excluded` 列含 `glob` 與說明
61
+ 該 surface 為何不受影響的理由(「out of scope」不是理由);`unknown` 列記錄
62
+ repository 看不到的讀取者。`contracts` 宣告 `compatible` 或 `breaking`。只有單一
63
+ surface 也可以。
64
+ - 值的語意改變(clear、redact、default、normalize、unit/format)時,
65
+ `valueChanges` `[{producer, kind, change, consumers, indirectConsumers}]` 列出兩層
66
+ consumer。
67
+ - `scenarios` 涵蓋五個通用維度:`initialState`(空 vs 既有)、`redo`(重新載入、
68
+ 重試、重送、冪等)、`timeBoundary`(日界、TTL、時區)、`identitySwitch`(門市、
69
+ 角色、租戶)與 `concurrency`,每項為 `{applies: true, behavior}` 或 `{na: 理由}`。
70
+ - 父 task 持有 surface;子 task 宣告 `{"covers": [surface-id]}`,聯集必須等於父
71
+ task 的 included 清單。
72
+ - 任何 reviewer 執行前,discovery 以 argv 執行 `git grep -F`(絕不經過 shell,也
73
+ 不以 task 文字組 regex),由便宜的來源先跑:記錄的查詢;diff hunk header 的外層
74
+ 函式名;template literal 固定片段(至少 4 字元且以分隔符界定,例如 `order:`、
75
+ `user_`、`menu.`);新增與刪除行的複合識別字(snake_case、camelCase 或含數字,
76
+ 因此一般文字單字不會成為查詢)與字串常值(至少 6 字元;命中超過 30 個檔案視為
77
+ 太通用而略過)。diff 與 surface 清單之外的任何命中都會擋下 review。
78
+ 結果以 HEAD、base、diff、查詢集合與 config hash 快取。
79
+ - Review 以 NOT_RUN 回報:`SURFACE_MISSING`(沒有或無法解析的 surface;非 DEBUG
80
+ task 建立時也會拒絕)、`SURFACE_UNCLASSIFIED`、`SURFACE_QUERY_TOO_BROAD`(單一
81
+ 記錄查詢超過 200 個檔案)、`SURFACE_EXCLUSION_TOO_BROAD`(單一 glob 超過 10 個
82
+ 檔案,或包含 diff 內的檔案)、`SCENARIO_MISSING`、`SURFACE_BUDGET_EXCEEDED`
83
+ (總計 120 秒,絕不默默略過)或 `SURFACE_UNASSIGNED`(子 task covers 與父 task
84
+ included 不一致;每次 review 都檢查,包含 advance 與 batch)。
85
+ - 修訂 surface 等同修訂 contract:需要新的證據與兩次新的 review。在
86
+ `SURFACE_UNCLASSIFIED` 阻擋之後才加入的排除,會以 post-block exclusion 標示給
87
+ reviewer。
88
+ - 本規則上線前建立的 task 不受影響。每個 discovery 來源的 local-log 指標僅供參考、
89
+ fail-open,絕不作為證據。
90
+ - 一般 `worktree finish` 也會寫入與 advance 相同的本機 receipt,包含兩輪的 residual
91
+ risks 與 non-blocking findings,在 git note 引用它並印出 residual risks。
92
+
45
93
  ## EVIDENCE-AGGREGATE-001
46
94
 
47
95
  驗證器 MUST 在條件缺少、重複、未知或證據為空時回傳 FAIL。
@@ -12,6 +12,12 @@ English source version: 2026-07-23. Revalidate: when the English specification c
12
12
  - Positive: `套用安裝前詢問要 project 或 user scope。`
13
13
  - Negative: `因為方便就假設 project scope。`
14
14
 
15
+ Surface audit 觸發條件([EVIDENCE-SURFACE-001](acceptance-and-evidence.md#evidence-surface-001))同樣要在編輯前停止:
16
+
17
+ - 共享契約有 `breaking` 變更,且存在任何 UNKNOWN 讀取者。
18
+ - 發現的 surface 以「刻意不同」為由排除:交給使用者一張整合的行為表(surface、
19
+ 目前行為、提議行為、理由)並等待決定。
20
+
15
21
  ## JUDGMENT-RETRY-001
16
22
 
17
23
  相同失敗在沒有新資訊時重複發生,操作者 MUST 改變方法。
@@ -26,10 +26,10 @@ English source version: 2026-07-23. Revalidate: when the English specification c
26
26
 
27
27
  ## LOOP-START-001
28
28
 
29
- 操作者 MUST 在編輯前列出驗收條件,並在每項條件都有證據時停止。
29
+ 操作者 MUST 先盤點變更影響的 surface,再於編輯前列出驗收條件,並在每項條件都有證據時停止。
30
30
 
31
31
  - Trigger: 開始多步驟實作或除錯迴圈。
32
- - Action: 記錄 2–5 個可觀察條件,只檢查目前步驟所需檔案。
32
+ - Action: 記錄 surface audit([EVIDENCE-SURFACE-001](acceptance-and-evidence.md#evidence-surface-001)),再記錄 2–5 個可觀察條件,只檢查已盤點範圍內、目前步驟所需的檔案。
33
33
  - Evidence: 任務紀錄與最終報告把每項條件連到命令或讀回內容。
34
34
  - Positive: `條件:測試通過;套件建置;讀回變更檔案。`
35
35
  - Negative: `先修改,再決定成功標準。`
@@ -77,6 +77,7 @@ PASS MUST 交給第二個規劃的 target 嘗試反駁,且反駁成立時 MUST
77
77
  - Positive: `codex 判 PASS,claude 找到 blocking 缺陷,整體判定 FAIL。`
78
78
  - Negative: `為了讓複查看起來有效而編造反駁。`
79
79
  - Note: 只有一個設定 target 時,會在全新 session 再呼叫同一 target。FAIL 已是終局,不再複查。
80
+ - Note: 有 surface 的 task,第二輪也會在 `surface-coverage` 下搜尋未列出的 surface 與情境:檢查五個固定情境維度,每個軸向最多可新增 3 個 `git grep -F` 查詢。第一輪重放記錄的查詢並閱讀每個列出的 `valueChanges` consumer;這是「不要普查 repository」唯一的例外。
80
81
 
81
82
  ## REVIEW-HOST-001
82
83