@tekyzinc/gsd-t 4.6.11 → 4.7.10
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/CHANGELOG.md +18 -0
- package/README.md +1 -1
- package/bin/gsd-t-architectural-trigger.cjs +523 -0
- package/bin/gsd-t-loop-ledger.cjs +519 -0
- package/bin/gsd-t-research-gate.cjs +439 -0
- package/bin/gsd-t.js +63 -1
- package/commands/gsd-t-help.md +25 -0
- package/docs/requirements.md +17 -0
- package/package.json +1 -1
- package/templates/CLAUDE-global.md +35 -16
- package/templates/prompts/blind-adversary-subagent.md +101 -0
- package/templates/prompts/research-subagent.md +127 -0
- package/templates/prompts/stated-claims-snippet.md +89 -0
- package/templates/test-helpers/launch-extension.ts +81 -0
- package/templates/workflows/gsd-t-debug.workflow.js +312 -0
- package/templates/workflows/gsd-t-execute.workflow.js +366 -1
- package/templates/workflows/gsd-t-phase.workflow.js +482 -5
- package/templates/workflows/gsd-t-quick.workflow.js +272 -2
- package/templates/workflows/gsd-t-verify.workflow.js +261 -1
|
@@ -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
|
+
}
|