@tekyzinc/gsd-t 4.6.11 → 4.7.11

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,136 @@
1
+ /**
2
+ * gsd-t-archive-domains.cjs
3
+ *
4
+ * Backlog #40 — Deterministic archive+sweep of a completed milestone's domain dirs.
5
+ *
6
+ * complete-milestone Step 7 was prose-only ("archive domains → clear .gsd-t/domains/") with no
7
+ * enforcement, so a Level-3 agent skipped/partial-did the clear for ~30 milestones and 77 stale
8
+ * domain dirs accumulated, polluting the file-disjointness oracle. This helper makes the sweep a
9
+ * deterministic, idempotent, containment-guarded operation.
10
+ *
11
+ * Behavior — for an EXPLICIT set of the completing milestone's domains (NOT a blanket wipe; a
12
+ * later still-active milestone may legitimately have live domains, e.g. M90 completing while
13
+ * M87/M88 are queued):
14
+ * 1. Copy each domain dir → <archiveDir>/domains/<name>/ (skip if already archived — idempotent)
15
+ * 2. Remove it from .gsd-t/domains/ (skip if already gone — idempotent)
16
+ * Domains NOT in the set are left untouched.
17
+ *
18
+ * Containment guard ([[feedback_destructive_path_ops_containment]]): every removed path MUST resolve
19
+ * INSIDE .gsd-t/domains/ AND NOT equal it. Predicate: resolved.startsWith(domainsRoot + sep) &&
20
+ * resolved !== domainsRoot. Any violation aborts the whole run (fail-closed, no partial sweep).
21
+ *
22
+ * House style: { ok:true, ... } | { ok:false, error }; bad input → non-zero CLI exit; Node
23
+ * built-ins only; sync APIs; zero deps.
24
+ *
25
+ * Usage:
26
+ * gsd-t archive-domains --domains d-a,d-b,d-c --archive .gsd-t/milestones/mNN-name-DATE [--projectDir .] [--dry-run] [--json]
27
+ */
28
+
29
+ 'use strict';
30
+
31
+ const fs = require('fs');
32
+ const path = require('path');
33
+
34
+ const DOMAINS_SUBPATH = path.join('.gsd-t', 'domains');
35
+
36
+ /**
37
+ * Archive + sweep the named domains.
38
+ *
39
+ * @param {object} opts
40
+ * @param {string[]} opts.domains — explicit domain dir names (the completing milestone's set)
41
+ * @param {string} opts.archiveDir — milestone archive dir (domains land under <archiveDir>/domains/)
42
+ * @param {string} [opts.projectDir] — project root (default cwd)
43
+ * @param {boolean} [opts.dryRun] — compute the plan, write nothing
44
+ * @returns {{ok:true, archived:string[], removed:string[], skipped:string[], dryRun:boolean}
45
+ * | {ok:false, error:string}}
46
+ */
47
+ function archiveDomains({ domains, archiveDir, projectDir, dryRun = false } = {}) {
48
+ if (!Array.isArray(domains) || domains.length === 0) {
49
+ return { ok: false, error: 'archive-domains requires a non-empty --domains list (the completing milestone\'s domain set)' };
50
+ }
51
+ if (!archiveDir || typeof archiveDir !== 'string' || !archiveDir.trim()) {
52
+ return { ok: false, error: 'archive-domains requires --archive <milestone archive dir>' };
53
+ }
54
+
55
+ const root = path.resolve(projectDir || process.cwd());
56
+ const domainsRoot = path.resolve(root, DOMAINS_SUBPATH);
57
+ const archiveRootAbs = path.isAbsolute(archiveDir) ? archiveDir : path.resolve(root, archiveDir);
58
+ const archiveDomainsDir = path.join(archiveRootAbs, 'domains');
59
+
60
+ // Validate every target up front (fail-closed: no partial sweep on a bad name).
61
+ const plan = [];
62
+ for (const name of domains) {
63
+ if (!name || typeof name !== 'string' || name.includes('/') || name.includes('\\') || name === '.' || name === '..') {
64
+ return { ok: false, error: `invalid domain name (no path separators / dot-segments allowed): ${JSON.stringify(name)}` };
65
+ }
66
+ const srcAbs = path.resolve(domainsRoot, name);
67
+ // CONTAINMENT GUARD: must resolve strictly INSIDE .gsd-t/domains/ (not outside, not equal).
68
+ if (!(srcAbs.startsWith(domainsRoot + path.sep) && srcAbs !== domainsRoot)) {
69
+ return { ok: false, error: `containment violation: ${name} resolves to ${srcAbs}, outside or equal to ${domainsRoot} — refusing` };
70
+ }
71
+ plan.push({ name, srcAbs, destAbs: path.join(archiveDomainsDir, name) });
72
+ }
73
+
74
+ const archived = [];
75
+ const removed = [];
76
+ const skipped = [];
77
+
78
+ for (const { name, srcAbs, destAbs } of plan) {
79
+ const srcExists = fs.existsSync(srcAbs);
80
+ const destExists = fs.existsSync(destAbs);
81
+
82
+ // IDEMPOTENT: nothing live and already archived → skip silently.
83
+ if (!srcExists && destExists) { skipped.push(name); continue; }
84
+ // Nothing live and not archived → the domain doesn't exist at all → skip (not an error;
85
+ // re-running after a manual prune is valid).
86
+ if (!srcExists && !destExists) { skipped.push(name); continue; }
87
+
88
+ if (dryRun) {
89
+ if (!destExists) archived.push(name);
90
+ removed.push(name);
91
+ continue;
92
+ }
93
+
94
+ // 1. Archive (copy) unless already archived.
95
+ if (!destExists) {
96
+ fs.mkdirSync(archiveDomainsDir, { recursive: true });
97
+ fs.cpSync(srcAbs, destAbs, { recursive: true });
98
+ archived.push(name);
99
+ }
100
+ // 2. Remove from live domains (containment re-checked at delete time).
101
+ if (!(srcAbs.startsWith(domainsRoot + path.sep) && srcAbs !== domainsRoot)) {
102
+ return { ok: false, error: `containment re-check failed at delete: ${srcAbs}` };
103
+ }
104
+ fs.rmSync(srcAbs, { recursive: true, force: true });
105
+ removed.push(name);
106
+ }
107
+
108
+ return { ok: true, archived, removed, skipped, dryRun };
109
+ }
110
+
111
+ module.exports = { archiveDomains };
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // CLI
115
+ // ---------------------------------------------------------------------------
116
+ if (require.main === module) {
117
+ const argv = process.argv.slice(2);
118
+ const flags = {};
119
+ for (let i = 0; i < argv.length; i++) {
120
+ const a = argv[i];
121
+ if (a === '--dry-run') flags.dryRun = true;
122
+ else if (a === '--json') flags.json = true;
123
+ else if (a.startsWith('--') && i + 1 < argv.length) flags[a.slice(2)] = argv[++i];
124
+ }
125
+
126
+ const domains = (flags.domains || '').split(',').map((s) => s.trim()).filter(Boolean);
127
+ const result = archiveDomains({
128
+ domains,
129
+ archiveDir: flags.archive,
130
+ projectDir: flags.projectDir || process.cwd(),
131
+ dryRun: !!flags.dryRun,
132
+ });
133
+
134
+ process.stdout.write(JSON.stringify(result) + '\n');
135
+ process.exit(result.ok ? 0 : 1);
136
+ }
@@ -0,0 +1,519 @@
1
+ /**
2
+ * gsd-t-loop-ledger.cjs
3
+ *
4
+ * M90 §3 — Loop Ledger + Hard-Halt (non-convergence hook)
5
+ *
6
+ * Detects non-converging debug loops by computing a DETERMINISTIC symptom-signature
7
+ * from the failing assertion / surface / file-class — NOT from the agent's prose label.
8
+ * Persists the ledger cross-process via an atomic-write JSONL state file so the real
9
+ * runCli workflow (a fresh process per cycle) accumulates cycles correctly.
10
+ *
11
+ * Invariants:
12
+ * R-LOOP-1 A fix that closes signature A but opens signature B still increments
13
+ * (variant-spawning IS the pathology).
14
+ * R-LOOP-2 3rd cycle on the SAME computed signature HARD-HALTS the patch path
15
+ * (halt is a returned ledger fact — never narration).
16
+ * R-LOOP-3 On halt, emit a premise-re-examination directive routing to §2 (D-ARCH).
17
+ * R-FAIL-3 Expose a `halted-but-no-re-examination` state for the §4 fail-closed gate.
18
+ *
19
+ * Contract: .gsd-t/contracts/m90-doctrine-mechanisms-contract.md §3 v1.0.0
20
+ * House style §0: { ok:true, ... } | { ok:false, error }; zero external deps; sync APIs.
21
+ *
22
+ * CLI usage (via agent()-Bash runCli helper in workflows):
23
+ * node bin/gsd-t-loop-ledger.cjs append-cycle \
24
+ * --assertion "<failing-test-or-check>" \
25
+ * --surface "<file-or-module>" \
26
+ * --fileClass "<unit|e2e|lint|type|build>" \
27
+ * [--projectDir <path>]
28
+ *
29
+ * node bin/gsd-t-loop-ledger.cjs read-exit-state [--projectDir <path>]
30
+ *
31
+ * node bin/gsd-t-loop-ledger.cjs record-re-examination [--projectDir <path>]
32
+ *
33
+ * module.exports: { computeSignature, appendCycle, readExitState, recordReExamination }
34
+ */
35
+
36
+ 'use strict';
37
+
38
+ const fs = require('fs');
39
+ const path = require('path');
40
+ const crypto = require('crypto');
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Constants
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /** After this many same-signature cycles the patch path is HARD-HALTED. */
47
+ const HALT_THRESHOLD = 3;
48
+
49
+ /**
50
+ * Default state-file location relative to the project root.
51
+ * Stored under .gsd-t/ (gitignored state dir).
52
+ */
53
+ const STATE_SUBPATH = path.join('.gsd-t', 'loop-ledger-state.json');
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // T1 — Computed symptom-signature
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /**
60
+ * Compute a deterministic, prose-independent signature key.
61
+ *
62
+ * Inputs:
63
+ * assertion {string} — the failing test assertion / check description
64
+ * surface {string} — the file or module where the failure occurs
65
+ * fileClass {string} — broad class: "unit"|"e2e"|"lint"|"type"|"build"|etc.
66
+ *
67
+ * The key is a SHA-256 hex over the stable, normalised concat of the three
68
+ * structural inputs (lower-cased, trimmed). Prose-label fields are explicitly
69
+ * NOT inputs, so the agent's free-text description cannot influence the key.
70
+ *
71
+ * Returns { ok:true, signature } | { ok:false, error }
72
+ */
73
+ function computeSignature({ assertion, surface, fileClass }) {
74
+ const bad = validateSignatureInputs({ assertion, surface, fileClass });
75
+ if (bad) return { ok: false, error: bad };
76
+
77
+ // R-LOOP-1: key the loop on the STABLE discriminator — the SYMPTOM (assertion) only — NOT the
78
+ // file being edited. Variant-spawning (same symptom, a different file each cycle = the binvoice
79
+ // whack-a-mole M90 exists to catch) MUST accumulate toward the halt. Keying on surface/fileClass
80
+ // gave each variant a distinct signature stuck at cycles=1, so the halt never fired (Red Team
81
+ // HIGH, M90 verify). surface/fileClass remain accepted (caller context) but are NOT part of the
82
+ // loop identity. fileClass is folded in as a coarse bucket so a unit-test loop and a lint loop on
83
+ // the same assertion text stay distinct, but the changing file does not.
84
+ const canonical = [
85
+ assertion.trim().toLowerCase(),
86
+ (fileClass || '').trim().toLowerCase(),
87
+ ].join('\x00');
88
+
89
+ const signature = crypto.createHash('sha256').update(canonical).digest('hex');
90
+ return { ok: true, signature };
91
+ }
92
+
93
+ function validateSignatureInputs({ assertion, surface, fileClass }) {
94
+ if (!assertion || typeof assertion !== 'string' || !assertion.trim()) {
95
+ return 'assertion must be a non-empty string';
96
+ }
97
+ // surface is accepted for caller context but is NOT part of the loop identity (R-LOOP-1):
98
+ // requiring it stays for back-compat, but it no longer keys the signature.
99
+ if (!surface || typeof surface !== 'string' || !surface.trim()) {
100
+ return 'surface must be a non-empty string';
101
+ }
102
+ if (!fileClass || typeof fileClass !== 'string' || !fileClass.trim()) {
103
+ return 'fileClass must be a non-empty string';
104
+ }
105
+ return null;
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // State-file helpers (cross-process persistence + atomic write)
110
+ // ---------------------------------------------------------------------------
111
+
112
+ function statePath(projectDir) {
113
+ return path.join(projectDir || process.cwd(), STATE_SUBPATH);
114
+ }
115
+
116
+ /**
117
+ * Read state from disk.
118
+ * Returns { cycles: { [signature]: number }, halted: { [signature]: boolean },
119
+ * reExaminationPending: boolean } on success.
120
+ * Returns null + logs error on corrupt/bad file.
121
+ */
122
+ function readState(projectDir) {
123
+ const fp = statePath(projectDir);
124
+ if (!fs.existsSync(fp)) {
125
+ return { cycles: {}, halted: {}, reExaminationPending: {}, signatureMilestone: {} };
126
+ }
127
+ let raw;
128
+ try {
129
+ raw = fs.readFileSync(fp, 'utf8');
130
+ } catch (e) {
131
+ return null; // unreadable
132
+ }
133
+ try {
134
+ const parsed = JSON.parse(raw);
135
+ // Structural validation — FAIL CLOSED on any non-plain-object shape. `typeof [] === 'object'`,
136
+ // so a bare `typeof !== 'object'` check let ARRAY-typed cycles/halted/reExaminationPending slip
137
+ // through; an array `cycles` silently drops string-keyed writes (JSON.stringify discards them)
138
+ // → the 3-cycle HALT_THRESHOLD never persists (R-LOOP-2 bypassed), and an array
139
+ // reExaminationPending makes haltedButNoReExamination read false for a genuinely-halted sig
140
+ // (R-FAIL-3 fails OPEN). Reject arrays + non-objects explicitly (Red Team HIGH, M90 verify fc7).
141
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
142
+ if (
143
+ !isPlainObject(parsed) ||
144
+ !isPlainObject(parsed.cycles) ||
145
+ !isPlainObject(parsed.halted) ||
146
+ (parsed.reExaminationPending !== undefined &&
147
+ typeof parsed.reExaminationPending !== 'boolean' &&
148
+ !isPlainObject(parsed.reExaminationPending))
149
+ ) {
150
+ return null; // corrupt — fail closed
151
+ }
152
+ // reExaminationPending is a PER-SIGNATURE map { [signature]: true }, NOT a global boolean.
153
+ // A global boolean let one recordReExamination() clear the gate for ALL halted signatures —
154
+ // a second unresolved non-converging loop would then silently pass R-FAIL-3 (Red Team HIGH,
155
+ // M90 verify). Migrate legacy shapes: a legacy `true` boolean → mark every currently-halted
156
+ // signature pending (fail-closed); a legacy `false`/absent → empty map.
157
+ if (typeof parsed.reExaminationPending === 'boolean') {
158
+ // Legacy boolean → per-sig map. FAIL CLOSED: a halted signature is treated as still-pending
159
+ // regardless of the legacy boolean value (even `false`) — a halted-but-unresolved sig must
160
+ // NOT silently pass the gate just because an old global flag said false (Red Team LOW, M90).
161
+ const migrated = {};
162
+ for (const sig of Object.keys(parsed.halted)) {
163
+ if (parsed.halted[sig]) migrated[sig] = true;
164
+ }
165
+ parsed.reExaminationPending = migrated;
166
+ } else if (
167
+ parsed.reExaminationPending === null ||
168
+ typeof parsed.reExaminationPending !== 'object'
169
+ ) {
170
+ parsed.reExaminationPending = {};
171
+ }
172
+ // signatureMilestone: { [signature]: milestone } — per-signature milestone tag so a halt set
173
+ // while debugging milestone A does not brick milestone B's verify (M90 verify decision A,
174
+ // 2026-06-22). Absent/legacy → empty map; an UNTAGGED signature is visible to ALL milestones
175
+ // (fail-safe: an old halt with no tag still blocks rather than silently vanishing).
176
+ if (!isPlainObject(parsed.signatureMilestone)) {
177
+ parsed.signatureMilestone = {};
178
+ }
179
+ return parsed;
180
+ } catch {
181
+ return null; // JSON parse failure = corrupt
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Atomically write state to disk (write to .tmp then rename).
187
+ * Throws on write failure so callers can surface the error.
188
+ */
189
+ function writeState(projectDir, state) {
190
+ const fp = statePath(projectDir);
191
+ const dir = path.dirname(fp);
192
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
193
+
194
+ const tmp = fp + '.tmp.' + process.pid;
195
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf8');
196
+ fs.renameSync(tmp, fp); // atomic on POSIX (same filesystem)
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // T2 + T3 — Append-cycle + Hard-halt (R-LOOP-1 / R-LOOP-2)
201
+ // T4 — Premise-re-examination directive + fail-closed state (R-LOOP-3 / R-FAIL-3)
202
+ // ---------------------------------------------------------------------------
203
+
204
+ /**
205
+ * Append a cycle for the given computed signature.
206
+ *
207
+ * Calling this once per debug-patch cycle — including variant-spawning fixes that
208
+ * close one signature but open another (R-LOOP-1) — is the caller's responsibility.
209
+ *
210
+ * On the 3rd same-signature cycle:
211
+ * halted: true is set in the returned envelope AND persisted to the state file.
212
+ * directive: a premise-re-examination directive routing to §2 (D-ARCH) is emitted.
213
+ * reExaminationPending: true is set (the R-FAIL-3 / §4 fail-closed surface).
214
+ *
215
+ * Returns:
216
+ * { ok:true, signature, cycles, halted, haltCode, directive, reExaminationPending }
217
+ * | { ok:false, error }
218
+ *
219
+ * @param {object} opts
220
+ * @param {string} opts.assertion — failing test / check description
221
+ * @param {string} opts.surface — file or module where failure occurs
222
+ * @param {string} opts.fileClass — broad class (unit|e2e|lint|type|build|…)
223
+ * @param {string} [opts.projectDir] — project root (defaults to cwd)
224
+ * @param {string} [opts.milestone] — milestone tag (so a halt is scoped to its milestone; a
225
+ * later unrelated milestone's verify won't see it)
226
+ */
227
+ function appendCycle({ assertion, surface, fileClass, projectDir, milestone } = {}) {
228
+ // 1. Compute signature
229
+ const sigResult = computeSignature({ assertion, surface, fileClass });
230
+ if (!sigResult.ok) return sigResult;
231
+ const { signature } = sigResult;
232
+
233
+ // 2. Read state (fail-closed on corrupt)
234
+ const state = readState(projectDir);
235
+ if (state === null) {
236
+ return { ok: false, error: 'Loop-ledger state file is corrupt or unreadable. Cannot safely count cycles (silent reset would mask a loop). Fix or remove .gsd-t/loop-ledger-state.json and retry.' };
237
+ }
238
+
239
+ // 3. Increment cycle count for this signature (R-LOOP-1: every variant counts)
240
+ state.cycles[signature] = (state.cycles[signature] || 0) + 1;
241
+ const cycles = state.cycles[signature];
242
+ // Tag the signature with its milestone (for per-milestone verify scoping). Only set if provided;
243
+ // an untagged signature stays visible to all milestones (fail-safe).
244
+ if (milestone && typeof milestone === 'string') {
245
+ state.signatureMilestone[signature] = milestone;
246
+ }
247
+
248
+ // 4. Evaluate halt (R-LOOP-2: 3rd cycle → HARD-HALT)
249
+ const halted = cycles >= HALT_THRESHOLD;
250
+ if (halted) {
251
+ state.halted[signature] = true;
252
+ state.reExaminationPending[signature] = true; // R-FAIL-3: PER-SIGNATURE fail-closed surface
253
+ }
254
+
255
+ // 5. Atomic write
256
+ try {
257
+ writeState(projectDir, state);
258
+ } catch (e) {
259
+ return { ok: false, error: `Failed to write loop-ledger state: ${e.message}` };
260
+ }
261
+
262
+ // 6. Build envelope
263
+ const envelope = {
264
+ ok: true,
265
+ signature,
266
+ cycles,
267
+ halted,
268
+ haltCode: halted ? 'LOOP_HALT_CYCLE_THRESHOLD' : null,
269
+ reExaminationPending: !!state.reExaminationPending[signature], // this signature's pending flag
270
+ directive: halted
271
+ ? {
272
+ action: 'PREMISE_RE_EXAMINATION',
273
+ route: 'architectural-hook',
274
+ module: 'bin/gsd-t-architectural-trigger.cjs',
275
+ contract: '.gsd-t/contracts/m90-doctrine-mechanisms-contract.md §2',
276
+ reason: `Same-symptom-signature loop detected after ${cycles} cycles. The fix strategy has not converged — the premise must be re-examined at the architectural level, not patched further.`,
277
+ }
278
+ : null,
279
+ };
280
+
281
+ return envelope;
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // T4 — read-exit-state (R-FAIL-3 surface for §4 fail-closed gate)
286
+ // ---------------------------------------------------------------------------
287
+
288
+ /**
289
+ * Return the current exit-state of the loop ledger.
290
+ *
291
+ * D-CONTRACT (§4 fail-closed gate) reads this to determine whether
292
+ * `halted-but-no-re-examination` should FAIL the verify workflow.
293
+ *
294
+ * Returns:
295
+ * { ok:true, haltedSignatures:string[], pendingSignatures:string[],
296
+ * reExaminationPending:boolean, haltedButNoReExamination:boolean }
297
+ * | { ok:false, error }
298
+ *
299
+ * @param {string} [projectDir]
300
+ * @param {object} [opts]
301
+ * @param {string} [opts.milestone] — when set, scope to halts tagged with THIS milestone OR
302
+ * untagged (fail-safe). A halt from another milestone is
303
+ * invisible here, so it won't brick an unrelated verify
304
+ * (M90 verify decision A, 2026-06-22).
305
+ */
306
+ function readExitState(projectDir, opts = {}) {
307
+ const milestone = opts && typeof opts === 'object' ? opts.milestone : undefined;
308
+ const state = readState(projectDir);
309
+ if (state === null) {
310
+ return { ok: false, error: 'Loop-ledger state file is corrupt or unreadable.' };
311
+ }
312
+
313
+ // Milestone scoping: a signature is in-scope when no milestone filter is given, OR the signature
314
+ // is untagged (legacy / fail-safe — still blocks), OR its tag matches the requested milestone.
315
+ const inScope = (sig) => {
316
+ if (!milestone) return true;
317
+ const tag = state.signatureMilestone[sig];
318
+ return !tag || tag === milestone;
319
+ };
320
+
321
+ const haltedSignatures = Object.keys(state.halted).filter((k) => state.halted[k] && inScope(k));
322
+ // PER-SIGNATURE: a halted signature is unresolved iff its own pending flag is still set.
323
+ // (A global boolean let one recordReExamination clear ALL — Red Team HIGH; now each halted
324
+ // signature must be cleared individually, so a second unresolved loop still FAILs R-FAIL-3.)
325
+ const pendingSignatures = haltedSignatures.filter((sig) => state.reExaminationPending[sig] === true);
326
+
327
+ return {
328
+ ok: true,
329
+ haltedSignatures,
330
+ pendingSignatures,
331
+ // Back-compat field: true iff ANY in-scope halted signature is still pending re-examination.
332
+ reExaminationPending: pendingSignatures.length > 0,
333
+ // The §4 fail-closed predicate: at least one in-scope halted signature has no recorded re-examination.
334
+ haltedButNoReExamination: pendingSignatures.length > 0,
335
+ };
336
+ }
337
+
338
+ // ---------------------------------------------------------------------------
339
+ // T4 — record-re-examination (clears the fail-closed surface — never silently)
340
+ // ---------------------------------------------------------------------------
341
+
342
+ /**
343
+ * Record that premise re-examination has been performed for a SPECIFIC halted signature.
344
+ * Clears only THAT signature's pending flag so the §4 fail-closed gate can pass for it —
345
+ * other halted-but-unresolved signatures stay pending (Red Team HIGH: a global clear let one
346
+ * call resolve every loop at once, silently passing a second unresolved non-converging loop).
347
+ *
348
+ * Never clears silently — only this explicit call clears it. Clearing a signature that is not
349
+ * halted/pending is a no-op (idempotent), not an error.
350
+ *
351
+ * @param {string|object} arg — the signature string, OR an opts object { signature, projectDir }
352
+ * @param {string} [maybeProjectDir] — projectDir when the first arg is a bare signature string
353
+ * Returns { ok:true, cleared:string[] } | { ok:false, error }
354
+ */
355
+ function recordReExamination(arg, maybeProjectDir) {
356
+ // Accept both recordReExamination(signature, projectDir) and recordReExamination({ signature, projectDir }).
357
+ let signature;
358
+ let projectDir;
359
+ if (arg && typeof arg === 'object') {
360
+ signature = arg.signature;
361
+ projectDir = arg.projectDir;
362
+ } else {
363
+ signature = arg;
364
+ projectDir = maybeProjectDir;
365
+ }
366
+
367
+ const state = readState(projectDir);
368
+ if (state === null) {
369
+ return { ok: false, error: 'Loop-ledger state file is corrupt or unreadable.' };
370
+ }
371
+
372
+ const cleared = [];
373
+ if (signature && typeof signature === 'string') {
374
+ // FULL per-signature RESET (M90 fix-cycle 5): re-examination means this signature starts
375
+ // fresh. Clearing ONLY reExaminationPending left cycles[sig] and halted[sig] intact, so the
376
+ // next appendCycle re-armed pending (cycles still >= threshold) — the gate re-bricked itself.
377
+ // A true self-heal resets all three together: the loop is being re-approached from scratch.
378
+ const wasTracked = state.reExaminationPending[signature] || state.halted[signature] ||
379
+ (state.cycles[signature] || 0) > 0;
380
+ delete state.reExaminationPending[signature];
381
+ delete state.halted[signature];
382
+ delete state.cycles[signature];
383
+ delete state.signatureMilestone[signature]; // drop the milestone tag too (full reset)
384
+ if (wasTracked) cleared.push(signature);
385
+ } else {
386
+ // No signature given: refuse a blanket clear (that was the bug). Surface the choices.
387
+ const pending = Object.keys(state.reExaminationPending).filter((s) => state.reExaminationPending[s]);
388
+ return {
389
+ ok: false,
390
+ error: `recordReExamination requires a signature — refusing a blanket clear (would silently pass other unresolved loops). Pending signatures: ${pending.join(', ') || '(none)'}`,
391
+ };
392
+ }
393
+
394
+ try {
395
+ writeState(projectDir, state);
396
+ } catch (e) {
397
+ return { ok: false, error: `Failed to write loop-ledger state: ${e.message}` };
398
+ }
399
+ return { ok: true, cleared };
400
+ }
401
+
402
+ /**
403
+ * Mark a signature as REQUIRING re-examination (halted + pending), regardless of cycle count.
404
+ * The debug workflow calls this when it detects RUN-LOCAL non-convergence (same signature in both
405
+ * of its 2 cycles) — a halt the global cycles>=HALT_THRESHOLD path would miss. This PERSISTS the
406
+ * unresolved-halt so the verify R-FAIL-3 gate can see it later. Detection sets the flag; ONLY
407
+ * recordReExamination (a genuine re-examination) clears it — NOT the act of detecting.
408
+ *
409
+ * @param {string|object} arg — signature string OR { signature, projectDir, milestone }
410
+ * @param {string} [maybeProjectDir]
411
+ * Returns { ok:true, signature, marked:boolean } | { ok:false, error }
412
+ */
413
+ function markReExaminationRequired(arg, maybeProjectDir) {
414
+ let signature, projectDir, milestone;
415
+ if (arg && typeof arg === 'object') { signature = arg.signature; projectDir = arg.projectDir; milestone = arg.milestone; }
416
+ else { signature = arg; projectDir = maybeProjectDir; }
417
+
418
+ if (!signature || typeof signature !== 'string') {
419
+ return { ok: false, error: 'markReExaminationRequired requires a signature string' };
420
+ }
421
+ const state = readState(projectDir);
422
+ if (state === null) {
423
+ return { ok: false, error: 'Loop-ledger state file is corrupt or unreadable.' };
424
+ }
425
+ state.halted[signature] = true;
426
+ state.reExaminationPending[signature] = true;
427
+ // Tag with the milestone so a later unrelated milestone's verify won't see this halt.
428
+ if (milestone && typeof milestone === 'string') state.signatureMilestone[signature] = milestone;
429
+ // Ensure a cycle count exists so the signature is visibly tracked.
430
+ if (!state.cycles[signature]) state.cycles[signature] = HALT_THRESHOLD;
431
+ try {
432
+ writeState(projectDir, state);
433
+ } catch (e) {
434
+ return { ok: false, error: `Failed to write loop-ledger state: ${e.message}` };
435
+ }
436
+ return { ok: true, signature, marked: true };
437
+ }
438
+
439
+ // ---------------------------------------------------------------------------
440
+ // T5 — module.exports (stable interface for D-CONTRACT; FREEZE after Wave 1)
441
+ // ---------------------------------------------------------------------------
442
+
443
+ module.exports = {
444
+ computeSignature,
445
+ appendCycle,
446
+ readExitState,
447
+ recordReExamination,
448
+ markReExaminationRequired,
449
+ };
450
+
451
+ // ---------------------------------------------------------------------------
452
+ // CLI entry point (T5 — used by runCli agent()-Bash helpers in workflows)
453
+ // ---------------------------------------------------------------------------
454
+
455
+ if (require.main === module) {
456
+ const args = process.argv.slice(2);
457
+ const subcommand = args[0];
458
+
459
+ function parseFlags(argv) {
460
+ const flags = {};
461
+ for (let i = 1; i < argv.length; i++) {
462
+ const a = argv[i];
463
+ if (a.startsWith('--') && i + 1 < argv.length) {
464
+ flags[a.slice(2)] = argv[++i];
465
+ }
466
+ }
467
+ return flags;
468
+ }
469
+
470
+ function exitOk(result) {
471
+ process.stdout.write(JSON.stringify(result) + '\n');
472
+ process.exit(0);
473
+ }
474
+
475
+ function exitErr(result) {
476
+ process.stdout.write(JSON.stringify(result) + '\n');
477
+ process.exit(1);
478
+ }
479
+
480
+ const flags = parseFlags(args);
481
+ const projectDir = flags.projectDir || process.cwd();
482
+
483
+ if (subcommand === 'append-cycle') {
484
+ const { assertion, surface, fileClass, milestone } = flags;
485
+ if (!assertion || !surface || !fileClass) {
486
+ exitErr({ ok: false, error: 'append-cycle requires --assertion, --surface, and --fileClass' });
487
+ }
488
+ const result = appendCycle({ assertion, surface, fileClass, projectDir, milestone });
489
+ if (!result.ok) exitErr(result);
490
+ else exitOk(result);
491
+
492
+ } else if (subcommand === 'read-exit-state') {
493
+ // --milestone scopes the read to that milestone's halts (+ untagged); omit = all.
494
+ const result = readExitState(projectDir, { milestone: flags.milestone });
495
+ if (!result.ok) exitErr(result);
496
+ else exitOk(result);
497
+
498
+ } else if (subcommand === 'record-re-examination') {
499
+ // Requires --signature (per-signature clear; a blanket clear is refused by the function).
500
+ const result = recordReExamination({ signature: flags.signature, projectDir });
501
+ if (!result.ok) exitErr(result);
502
+ else exitOk(result);
503
+
504
+ } else if (subcommand === 'mark-re-examination-required') {
505
+ // Persist an unresolved-halt for a signature (debug run-local non-convergence). Sets
506
+ // halted+pending; ONLY record-re-examination clears it (detection != resolution).
507
+ const result = markReExaminationRequired({ signature: flags.signature, projectDir, milestone: flags.milestone });
508
+ if (!result.ok) exitErr(result);
509
+ else exitOk(result);
510
+
511
+ } else {
512
+ exitErr({
513
+ ok: false,
514
+ error: subcommand
515
+ ? `Unknown subcommand: ${subcommand}. Valid: append-cycle, read-exit-state, record-re-examination, mark-re-examination-required`
516
+ : 'Subcommand required: append-cycle | read-exit-state | record-re-examination | mark-re-examination-required',
517
+ });
518
+ }
519
+ }