@sabaiway/agent-workflow-kit 1.37.1 → 1.39.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,294 @@
1
+ #!/usr/bin/env node
2
+ // changed-surface.mjs — the NEUTRAL shared read-only core (BUGFREE-2 / AD-048, D4 + D8). One home
3
+ // for the changed-surface computation that BOTH the fold-completeness runner (coverage domain) and
4
+ // the review-ledger writer (the D4 diff-size cap) consume — one computation, so the cap and the
5
+ // coverage gate can never drift (the probeVerdict single-home precedent, AD-047). The D8 telemetry
6
+ // fold-ledger read path lives beside it: fold-completeness.mjs already imports review-ledger.mjs,
7
+ // so the review-ledger telemetry must reach the fold ledger WITHOUT importing back the other
8
+ // direction — it imports THIS module instead.
9
+ //
10
+ // Import-graph invariant (pinned by import-split tests): this module imports NOTHING from the
11
+ // family — node built-ins only. Everyone may import it; it imports no one:
12
+ // review-ledger.mjs → changed-surface.mjs ← fold-completeness.mjs ← fold-completeness-run.mjs
13
+ // review-ledger-write.mjs → changed-surface.mjs (the writer NEVER imports the runner — the
14
+ // sole-tree-toucher boundary, codex R2)
15
+ //
16
+ // Read-only: never writes, never commits. It DOES spawn read-only `git` queries (diff/ls-files/
17
+ // rev-parse). Dependency-free, Node >= 18. No side effects on import.
18
+
19
+ import { readFileSync, lstatSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { spawnSync } from 'node:child_process';
22
+
23
+ const GIT_MAX_BUFFER = 256 * 1024 * 1024; // a full-tree diff can be large; never truncate
24
+
25
+ // ── the CLOSED changed-path classification rule (AD-046 Decision 5; no heuristics) ───────────────
26
+
27
+ const TEST_FILE_RE = /\.(test|spec)\.[^./]+$/; // a.test.mjs, b.spec.js, c.test.cjs, d.spec.ts
28
+ const ASSESSABLE_EXT = new Set(['.mjs', '.cjs', '.js']);
29
+ const UNSUPPORTED_EXT = new Set(['.ts', '.tsx', '.jsx', '.mts', '.cts']);
30
+
31
+ // classifyChangedPath(rel) → 'assessable' | 'unsupported' | 'out-of-domain' | 'excluded-test'.
32
+ export const classifyChangedPath = (rel) => {
33
+ const base = rel.split('/').pop();
34
+ if (TEST_FILE_RE.test(base)) return 'excluded-test';
35
+ const dot = base.lastIndexOf('.');
36
+ const ext = dot >= 0 ? base.slice(dot) : '';
37
+ if (ASSESSABLE_EXT.has(ext)) return 'assessable';
38
+ if (UNSUPPORTED_EXT.has(ext)) return 'unsupported';
39
+ return 'out-of-domain';
40
+ };
41
+
42
+ // ── diff-header path unquoting (git C-quotes paths carrying quotes/control/non-ASCII bytes) ──────
43
+
44
+ // Strip the quotes and decode escapes BYTE-wise (octal escapes are UTF-8 bytes) — an unparsed
45
+ // quoted path compares unequal to its classifier/testId form and would silently escape the
46
+ // coverage/cap surface (agy R5, AD-047).
47
+ const CQUOTE_SIMPLE = { n: 10, t: 9, r: 13, f: 12, v: 11, b: 8, a: 7, '"': 34, '\\': 92 };
48
+ export const unquoteDiffPath = (p) => {
49
+ if (!(p.length >= 2 && p.startsWith('"') && p.endsWith('"'))) return p;
50
+ const inner = p.slice(1, -1);
51
+ const bytes = [];
52
+ for (let i = 0; i < inner.length; i += 1) {
53
+ const c = inner[i];
54
+ if (c !== '\\') {
55
+ // Consume a full CODE POINT — 16-bit-unit iteration would split a surrogate pair (an
56
+ // unescaped non-BMP char, reachable under core.quotepath=false) into replacement bytes (agy R6).
57
+ const ch = String.fromCodePoint(inner.codePointAt(i));
58
+ for (const b of Buffer.from(ch, 'utf8')) bytes.push(b);
59
+ i += ch.length - 1;
60
+ continue;
61
+ }
62
+ const rest = inner.slice(i + 1);
63
+ const oct = /^[0-7]{1,3}/.exec(rest);
64
+ if (oct) {
65
+ bytes.push(Number.parseInt(oct[0], 8) & 0xff);
66
+ i += oct[0].length;
67
+ } else if (rest[0] in CQUOTE_SIMPLE) {
68
+ bytes.push(CQUOTE_SIMPLE[rest[0]]);
69
+ i += 1;
70
+ } else if (rest[0] !== undefined) {
71
+ for (const b of Buffer.from(rest[0], 'utf8')) bytes.push(b);
72
+ i += 1;
73
+ }
74
+ }
75
+ return Buffer.from(bytes).toString('utf8');
76
+ };
77
+
78
+ // ── unified-diff → new-side changed line numbers (line numbers only; content lines ignored) ──────
79
+
80
+ const DIFF_HUNK_RE = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
81
+
82
+ // parseUnifiedDiff(diffText) → Map<rel, number[]>. Robust against a content line that happens to look
83
+ // like a `+++ ` header: a `+++ ` line is a FILE header only in the header region (right after a
84
+ // `diff --git`, before any `@@`); inside a hunk body it is ignored. New-side lines come purely from the
85
+ // `@@` headers, so no content-line disambiguation is needed for the line numbers themselves.
86
+ export const parseUnifiedDiff = (diffText) => {
87
+ const map = new Map();
88
+ let current = null;
89
+ let inHeader = false;
90
+ for (const line of String(diffText).split('\n')) {
91
+ if (line.startsWith('diff --git ')) {
92
+ current = null;
93
+ inHeader = true;
94
+ continue;
95
+ }
96
+ if (inHeader && line.startsWith('--- ')) continue;
97
+ if (inHeader && line.startsWith('+++ ')) {
98
+ const p = unquoteDiffPath(line.slice(4).replace(/[\t\r]+$/, '')); // TAB/CR are git artifacts, never filename bytes (agy R6)
99
+ current = p === '/dev/null' ? null : p.startsWith('b/') ? p.slice(2) : p;
100
+ inHeader = false;
101
+ continue;
102
+ }
103
+ const m = DIFF_HUNK_RE.exec(line);
104
+ if (m) {
105
+ inHeader = false;
106
+ if (current == null) continue;
107
+ const start = Number(m[1]);
108
+ const count = m[2] === undefined ? 1 : Number(m[2]);
109
+ if (count > 0) {
110
+ const arr = map.get(current) ?? [];
111
+ for (let i = 0; i < count; i += 1) arr.push(start + i);
112
+ map.set(current, arr);
113
+ }
114
+ }
115
+ }
116
+ for (const [k, v] of map) map.set(k, [...new Set(v)].sort((a, b) => a - b));
117
+ return map;
118
+ };
119
+
120
+ // ── the changed surface (git-driven; the ONE computation both consumers read) ─────────────────────
121
+
122
+ // The one diff-invocation shape every surface pass uses. The a/ b/ prefixes are pinned EXPLICITLY
123
+ // (agy R6): a user's global diff.noprefix=true would otherwise drop them and the parsers would eat
124
+ // a real directory named "a" — user git config must never bend the parse.
125
+ export const DIFF_FLAGS = ['--unified=0', '--no-color', '--no-ext-diff', '--no-renames', '--src-prefix=a/', '--dst-prefix=b/'];
126
+
127
+ const runGit = (args, cwd) => spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, encoding: 'utf8', windowsHide: true });
128
+ const gitStdout = (args, cwd) => {
129
+ const r = runGit(args, cwd);
130
+ return r.error || r.status > 1 ? null : r.stdout;
131
+ };
132
+ const gitLine = (args, cwd) => {
133
+ const r = runGit(args, cwd);
134
+ if (r.error || r.status !== 0) return null;
135
+ return r.stdout.replace(/\r?\n$/, '');
136
+ };
137
+ // Both leaf guards are exported for the unit tests of exactly their fail-closed edges (the
138
+ // hashFileBytes precedent, AD-047) — internal consumers stay this module's own passes.
139
+ export const readFileSafe = (path) => {
140
+ try {
141
+ return readFileSync(path, 'utf8');
142
+ } catch {
143
+ return null;
144
+ }
145
+ };
146
+ // A changed LEAF is read only if it is a REGULAR file. lstat (no-follow): a symlinked or non-regular
147
+ // leaf must NEVER be read/followed — following it could read outside the work tree or HANG on a
148
+ // FIFO/device (codex R2, AD-046). A non-regular leaf fails closed (routed to `unsupported`).
149
+ export const isRegularLeaf = (abs) => {
150
+ try {
151
+ return lstatSync(abs).isFile();
152
+ } catch {
153
+ return false;
154
+ }
155
+ };
156
+
157
+ // computeChangedSurface(root) → { assessable: Map<rel, number[]>, unsupported: [rel],
158
+ // outOfDomain: [rel], unsupportedLines: Map<rel, number[]> }.
159
+ // Domain = the review-payload domain (tracked working-vs-HEAD changes + untracked-not-ignored files),
160
+ // classified by the CLOSED rule. Tracked changed lines come from `git diff HEAD -U0` (new-side only —
161
+ // pure deletions cost nothing, subtractive folds stay free); an untracked file is wholly new, so all
162
+ // its lines are "changed". `unsupportedLines` is the D4 cap's view of the SAME pass: unsupported
163
+ // SOURCE files carry their new-side lines too (excluding them would gift a large-TS-fold bypass,
164
+ // codex R2 / BUGFREE-2 D4); an unreadable/non-regular leaf counts 0 lines but stays LISTED (the
165
+ // coverage gate still fails closed on it). excluded-test and out-of-domain never carry lines.
166
+ export const computeChangedSurface = (root) => {
167
+ // Unborn branch (no HEAD yet): the plain diff alone misses files STAGED for the initial commit —
168
+ // they sit in the index, so they are neither worktree-vs-index changes nor untracked. Merge the
169
+ // --cached diff (index vs the empty tree) with the plain one; parseUnifiedDiff unions per-file
170
+ // lines across concatenated diffs (codex R1).
171
+ const trackedDiff = gitStdout(['diff', 'HEAD', ...DIFF_FLAGS], root)
172
+ ?? `${gitStdout(['diff', '--cached', ...DIFF_FLAGS], root) ?? ''}\n${gitStdout(['diff', ...DIFF_FLAGS], root) ?? ''}`;
173
+ const trackedLines = parseUnifiedDiff(trackedDiff);
174
+ const untrackedZ = gitStdout(['ls-files', '--others', '--exclude-standard', '-z'], root) ?? '';
175
+ const untracked = untrackedZ.split('\0').filter(Boolean);
176
+
177
+ const assessable = new Map();
178
+ const unsupportedLines = new Map();
179
+ const outOfDomain = [];
180
+ const markUnsupported = (rel, lines) => {
181
+ if (!unsupportedLines.has(rel)) unsupportedLines.set(rel, lines);
182
+ };
183
+ const place = (rel, cls, lines) => {
184
+ if (cls === 'excluded-test') return;
185
+ if (cls === 'assessable') {
186
+ if (isRegularLeaf(join(root, rel))) assessable.set(rel, lines);
187
+ else markUnsupported(rel, lines); // a symlinked / non-regular source → fail closed, never followed
188
+ return;
189
+ }
190
+ if (cls === 'unsupported') markUnsupported(rel, lines);
191
+ else outOfDomain.push(rel);
192
+ };
193
+ for (const [rel, lines] of trackedLines) place(rel, classifyChangedPath(rel), lines);
194
+ for (const rel of untracked) {
195
+ const cls = classifyChangedPath(rel);
196
+ if (cls === 'excluded-test' || cls === 'out-of-domain') {
197
+ place(rel, cls, []);
198
+ continue;
199
+ }
200
+ // Guard the leaf BEFORE reading — never follow a symlink to count an untracked file's lines.
201
+ const abs = join(root, rel);
202
+ if (!isRegularLeaf(abs)) {
203
+ markUnsupported(rel, []);
204
+ continue;
205
+ }
206
+ const src = readFileSafe(abs);
207
+ // Content lines only: a trailing newline terminates the last line, it does not open a phantom
208
+ // empty one — the cap must count real lines (git's new-side numbering does the same).
209
+ const parts = src == null ? [] : src.split('\n');
210
+ if (parts.length > 0 && parts[parts.length - 1] === '') parts.pop();
211
+ const all = Array.from({ length: parts.length }, (_, i) => i + 1);
212
+ if (cls === 'assessable') assessable.set(rel, all);
213
+ else markUnsupported(rel, all);
214
+ }
215
+ return { assessable, unsupported: [...unsupportedLines.keys()].sort(), outOfDomain: outOfDomain.sort(), unsupportedLines };
216
+ };
217
+
218
+ // countCapLines(surface) → the D4 counted magnitude: new-side changed lines of assessable AND
219
+ // unsupported SOURCE files (the two counted classes, pinned by named tests); excluded-test and
220
+ // out-of-domain never count; pure deletions already cost nothing (new-side lines only). Stated
221
+ // consequence, INTENTIONAL (agy R1): under --no-renames a rename of a large file costs its full
222
+ // length — the single-home invariant outweighs a rename special case.
223
+ export const countCapLines = ({ assessable, unsupportedLines }) => {
224
+ let counted = 0;
225
+ for (const lines of assessable.values()) counted += lines.length;
226
+ for (const lines of unsupportedLines.values()) counted += lines.length;
227
+ return counted;
228
+ };
229
+
230
+ // ── the shared fail-closed positive-integer knob parser (AD-047 precedent) ───────────────────────
231
+
232
+ // Zero / negative / fractional / non-numeric values are refusals by name — the parseInt(...)||default
233
+ // idiom would silently accept bad truthy values (codex R2, AD-046). Unset → the default; set → a
234
+ // positive integer, exactly. `makeError` lets each caller throw its OWN typed STOP.
235
+ export const parsePositiveIntKnob = (env, name, fallback, makeError = (m) => new Error(m)) => {
236
+ const raw = env[name];
237
+ if (raw === undefined) return fallback;
238
+ if (!/^\d+$/.test(String(raw).trim()) || Number.parseInt(raw, 10) < 1) {
239
+ throw makeError(`${name} must be a positive integer (got "${raw}") — refusing to guess (fail closed)`);
240
+ }
241
+ return Number.parseInt(raw, 10);
242
+ };
243
+
244
+ // ── the D4 probe-verdict algebra (AD-047; the SINGLE home — moved here from fold-completeness.mjs,
245
+ // which re-exports it, so the runner, the checker, AND the review-ledger telemetry read ONE algebra) ─
246
+
247
+ // probeVerdict(entry) → 'green' | 'red' | 'quarantine' | 'unresolvable'. RED/GREEN are strict N/N
248
+ // verdicts; any timeout, mixed outcome, or partial resolution is QUARANTINE — it never converts and
249
+ // has no override lane (a flaky pin proves nothing — replace the test). Zero resolved runs (or a
250
+ // defensive runs=0) reads unresolvable.
251
+ export const probeVerdict = (t) => {
252
+ const unresolved = t.runs - t.greens - t.reds - t.timeouts;
253
+ if (unresolved >= t.runs) return 'unresolvable';
254
+ if (t.timeouts > 0 || unresolved > 0 || (t.greens > 0 && t.reds > 0)) return 'quarantine';
255
+ return t.greens === t.runs ? 'green' : 'red';
256
+ };
257
+
258
+ // ── the fold-result ledger locator + a tolerant row reader (the D8 telemetry read path) ──────────
259
+
260
+ export const RESULTS_BASENAME = 'agent-workflow-fold-completeness.jsonl';
261
+
262
+ // The result-ledger path: AW_FOLD_RESULTS overrides (mirrors AW_REVIEW_LEDGER); else <git dir>/basename.
263
+ export const resolveResultsPath = (cwd, env = process.env) => {
264
+ if (env.AW_FOLD_RESULTS) return env.AW_FOLD_RESULTS;
265
+ const gitDir = gitLine(['rev-parse', '--absolute-git-dir'], cwd);
266
+ return gitDir == null ? null : join(gitDir, RESULTS_BASENAME);
267
+ };
268
+
269
+ // readJsonlRows(path) → { rows, badLines, readError? } — the TOLERANT parse the telemetry counts
270
+ // ride on (counts only, no judgment — D8): a row is any parsed JSON object; a bad line is counted,
271
+ // never dropped silently. Schema validation stays with each ledger's own reader — telemetry must
272
+ // not fork a second validator (drift) nor import one across the cycle boundary.
273
+ export const readJsonlRows = (path, readFile = readFileSync) => {
274
+ let raw;
275
+ try {
276
+ raw = readFile(path, 'utf8');
277
+ } catch (err) {
278
+ if (err && err.code === 'ENOENT') return { rows: [], badLines: 0 };
279
+ return { rows: [], badLines: 0, readError: (err && err.code) || (err && err.message) || 'read failed' };
280
+ }
281
+ const rows = [];
282
+ let badLines = 0;
283
+ for (const line of raw.split('\n')) {
284
+ if (line.trim() === '') continue;
285
+ try {
286
+ const parsed = JSON.parse(line);
287
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) rows.push(parsed);
288
+ else badLines += 1;
289
+ } catch {
290
+ badLines += 1;
291
+ }
292
+ }
293
+ return { rows, badLines };
294
+ };
@@ -96,7 +96,7 @@ const CATALOG = [
96
96
  invocation: invocationOf('gates'),
97
97
  group: 'Inspect',
98
98
  kind: PROJECT_EXEC,
99
- oneLine: 'Run the project’s own declared gate commands (docs/ai/gates.json) as one batch — a PASS/FAIL table, one summary line. The mode itself writes nothing; a separate consent-gated seeder can propose entries from your project’s own scripts (preview first, written only on your yes).',
99
+ oneLine: 'Run the project’s own declared gate commands (docs/ai/gates.json) as one batch — a PASS/FAIL table, one summary line. Writes nothing by default; opt-in --record mints a gate-run record via the review-ledger writer (the segment’s green-baseline receipt). A separate consent-gated seeder can propose entries from your project’s own scripts (preview first, written only on your yes).',
100
100
  },
101
101
  {
102
102
  key: 'setup',
@@ -180,14 +180,14 @@ const CATALOG = [
180
180
  invocation: invocationOf('review-ledger'),
181
181
  group: 'Orchestrate',
182
182
  kind: WRITER,
183
- oneLine: 'Record each review round and read the computed crossover-stop for the plan-execution loop (converged / accepted-residual / triage-required); --check turns it into a gate exit code and forces a triage before over-running the review.',
183
+ oneLine: 'Record each review round, its triage, and recorded overrides (oracle-change / red-proof / size-cap waivers) and read the computed crossover-stop for the plan-execution loop — per SEGMENT since v4 (base = HEAD; caps and teeth reset only at a gated commit; class refuted is the honest phantom lane); --check turns it into a gate exit code, --telemetry renders counts-only gate-efficacy data.',
184
184
  },
185
185
  {
186
186
  key: 'fold-completeness',
187
187
  invocation: invocationOf('fold-completeness'),
188
188
  group: 'Orchestrate',
189
189
  kind: WRITER,
190
- oneLine: 'Verify the review loop’s folded fixes are pinned by tests — one recorded coverage run proves every changed executable line is executed and each bound test starts green; --check turns the result into a gate exit code.',
190
+ oneLine: 'Verify the review loop’s folded fixes are pinned by HONEST tests — every changed executable line executed, and each bound test carries an observed-red receipt (--red, minted BEFORE the fix), N/N-green probes, and content custody (waivable per-testId only by a recorded red-proof override), over a test surface whose tampered files carry recorded oracle-change overrides; SEGMENT-scoped since v3 (a committed phase’s custody obligations close with its commit); --check turns the result into a gate exit code.',
191
191
  },
192
192
  ];
193
193