@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.
- package/dist/packages/cli/src/args.js +11 -3
- package/dist/packages/cli/src/cli.js +3 -1
- package/dist/packages/cli/src/commands/parallel.js +5 -0
- package/dist/packages/cli/src/commands/review.js +92 -1
- package/dist/packages/cli/src/commands/task.js +17 -4
- package/dist/packages/cli/src/debug-intake.js +2 -2
- package/dist/packages/cli/src/run-entry.js +7 -5
- package/dist/runtime/src/install/harness.js +1 -1
- package/dist/runtime/src/logging/local-log.js +17 -0
- package/dist/runtime/src/parallel/finish.js +20 -3
- package/dist/runtime/src/parallel/receipt.js +6 -0
- package/dist/runtime/src/review/packet.js +13 -1
- package/dist/runtime/src/review/runner.js +24 -0
- package/dist/runtime/src/run/authorization.js +8 -4
- package/dist/runtime/src/run/planning.js +5 -2
- package/dist/runtime/src/schema/validate.js +12 -1
- package/dist/runtime/src/surface/discovery.js +221 -0
- package/dist/runtime/src/surface/schema.js +127 -0
- package/dist/runtime/src/task/contract.js +3 -1
- package/dist/runtime/src/task/debug-intake.js +3 -0
- package/dist/runtime/src/task/debug-user-event.js +4 -2
- package/dist/runtime/src/task/debug.js +4 -1
- package/dist/runtime/src/task/service.js +17 -2
- package/docs/en/spec/acceptance-and-evidence.md +54 -0
- package/docs/en/spec/judgment.md +7 -0
- package/docs/en/spec/loop-engineering.md +2 -2
- package/docs/en/spec/review.md +5 -0
- package/docs/zh-TW/spec/acceptance-and-evidence.md +48 -0
- package/docs/zh-TW/spec/judgment.md +6 -0
- package/docs/zh-TW/spec/loop-engineering.md +2 -2
- package/docs/zh-TW/spec/review.md +1 -0
- package/package.json +1 -1
- 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
|
-
|
|
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"
|
|
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.
|
package/docs/en/spec/judgment.md
CHANGED
|
@@ -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
|
|
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.`
|
package/docs/en/spec/review.md
CHANGED
|
@@ -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
|
|