@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.
- package/dist/packages/cli/src/args.js +9 -1
- package/dist/packages/cli/src/cli.js +2 -0
- 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/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.js +3 -0
- 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
|
@@ -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.
|
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
|
|
|
@@ -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
|
|