@deftai/directive-core 0.100.0 → 0.101.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.
Files changed (85) hide show
  1. package/dist/authz/classify.js +100 -23
  2. package/dist/authz/decompose-apply.d.ts +14 -0
  3. package/dist/authz/decompose-apply.js +93 -18
  4. package/dist/check/cached-orchestrator.d.ts +26 -5
  5. package/dist/check/cached-orchestrator.js +228 -6
  6. package/dist/check/gate-lists.d.ts +10 -4
  7. package/dist/check/gate-lists.js +19 -5
  8. package/dist/check/index.d.ts +3 -2
  9. package/dist/check/index.js +2 -1
  10. package/dist/check/named-cause.d.ts +45 -0
  11. package/dist/check/named-cause.js +121 -0
  12. package/dist/check/orchestrator.js +6 -1
  13. package/dist/doctor/help.d.ts +5 -0
  14. package/dist/doctor/help.js +31 -0
  15. package/dist/doctor/index.d.ts +2 -0
  16. package/dist/doctor/index.js +2 -0
  17. package/dist/doctor/main.js +58 -0
  18. package/dist/doctor/session-coda.d.ts +82 -0
  19. package/dist/doctor/session-coda.js +151 -0
  20. package/dist/doctor/types.d.ts +16 -0
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.js +2 -0
  23. package/dist/init-deposit/gitignore.js +2 -0
  24. package/dist/intake/issue-ingest.js +7 -0
  25. package/dist/policy/ac-pass-banking.d.ts +58 -0
  26. package/dist/policy/ac-pass-banking.js +150 -0
  27. package/dist/policy/ceremony-dial.d.ts +29 -0
  28. package/dist/policy/ceremony-dial.js +76 -0
  29. package/dist/policy/index.d.ts +1 -0
  30. package/dist/policy/index.js +15 -1
  31. package/dist/preflight-cache/evaluate.d.ts +3 -0
  32. package/dist/preflight-cache/evaluate.js +3 -0
  33. package/dist/product-first-done-gate/acceptance.d.ts +25 -0
  34. package/dist/product-first-done-gate/acceptance.js +247 -0
  35. package/dist/product-first-done-gate/check-mode.d.ts +45 -0
  36. package/dist/product-first-done-gate/check-mode.js +148 -0
  37. package/dist/product-first-done-gate/evaluate.d.ts +50 -0
  38. package/dist/product-first-done-gate/evaluate.js +255 -0
  39. package/dist/product-first-done-gate/index.d.ts +11 -0
  40. package/dist/product-first-done-gate/index.js +11 -0
  41. package/dist/product-first-done-gate/types.d.ts +64 -0
  42. package/dist/product-first-done-gate/types.js +61 -0
  43. package/dist/run-summary/emit.d.ts +56 -0
  44. package/dist/run-summary/emit.js +171 -0
  45. package/dist/run-summary/index.d.ts +4 -0
  46. package/dist/run-summary/index.js +4 -0
  47. package/dist/run-summary/path.d.ts +25 -0
  48. package/dist/run-summary/path.js +78 -0
  49. package/dist/run-summary/types.d.ts +76 -0
  50. package/dist/run-summary/types.js +21 -0
  51. package/dist/scope/acceptance-evidence.d.ts +34 -0
  52. package/dist/scope/acceptance-evidence.js +84 -10
  53. package/dist/scope/decompose.d.ts +5 -0
  54. package/dist/scope/decompose.js +11 -2
  55. package/dist/session/ac-pass-banking.d.ts +221 -0
  56. package/dist/session/ac-pass-banking.js +761 -0
  57. package/dist/session/deposit-sha.d.ts +49 -0
  58. package/dist/session/deposit-sha.js +121 -0
  59. package/dist/session/effort-budget.d.ts +6 -0
  60. package/dist/session/effort-budget.js +21 -3
  61. package/dist/session/index.d.ts +5 -0
  62. package/dist/session/index.js +5 -0
  63. package/dist/session/orientation-compression.d.ts +127 -0
  64. package/dist/session/orientation-compression.js +425 -0
  65. package/dist/session/orientation-state.d.ts +29 -0
  66. package/dist/session/orientation-state.js +85 -0
  67. package/dist/session/session-start.d.ts +25 -0
  68. package/dist/session/session-start.js +175 -0
  69. package/dist/session/toolchain-preflight.d.ts +64 -0
  70. package/dist/session/toolchain-preflight.js +151 -0
  71. package/dist/triage/bootstrap/gitignore.d.ts +1 -1
  72. package/dist/triage/bootstrap/gitignore.js +8 -2
  73. package/dist/triage/bootstrap/index.js +15 -3
  74. package/dist/triage/classify/index.d.ts +1 -0
  75. package/dist/triage/classify/index.js +2 -0
  76. package/dist/triage/classify/label-mirror.js +16 -0
  77. package/dist/triage/classify/mirror-discovery-tip.d.ts +95 -0
  78. package/dist/triage/classify/mirror-discovery-tip.js +234 -0
  79. package/dist/triage/reconcile/reconcile.js +28 -6
  80. package/dist/triage/summary/index.js +12 -4
  81. package/dist/triage/welcome/default-mode.js +16 -0
  82. package/dist/ts-check-lane/run-lane.js +21 -4
  83. package/dist/vbrief-validate/conformance.js +7 -0
  84. package/dist/vitest-runner/coverage-debt-teardown.js +16 -4
  85. package/package.json +11 -3
@@ -0,0 +1,761 @@
1
+ /**
2
+ * AC-pass banking checkpoint (#3285).
3
+ *
4
+ * When stated acceptance criteria first pass, FINALIZE: record a durable bank
5
+ * checkpoint, optionally append a bank-event to the run summary, and refuse
6
+ * self-imposed deepening unless remaining budget meets the surplus threshold
7
+ * (default 20% of max turns/cost). Post-bank out-of-scope findings are
8
+ * reported, not chased, when surplus is insufficient.
9
+ *
10
+ * Sharpens #3266 bank-the-pass. Composes product-first done-gate (#3284) and
11
+ * optional DEFT_RUN_SUMMARY_PATH telemetry (#3282 hook, fail-open).
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync } from "node:fs";
15
+ import { dirname, join, resolve } from "node:path";
16
+ import { ContainedWriteError, containedWrite } from "../fs/contained-write.js";
17
+ import { DEFAULT_SURPLUS_THRESHOLD, resolveAcPassBanking, } from "../policy/ac-pass-banking.js";
18
+ import { detectHardEffortBudget, formatDeepeningSkippedNote, recommendVerificationDepth, } from "./effort-budget.js";
19
+ /** Durable bank ledger directory (gitignored under `.deft/`). */
20
+ export const AC_PASS_BANK_DIR = ".deft/ac-pass-banks";
21
+ export const AC_PASS_BANK_SCHEMA_VERSION = 1;
22
+ /** Env path for optional JSONL run summary (#3282). Unset = silent. */
23
+ export const ENV_RUN_SUMMARY_PATH = "DEFT_RUN_SUMMARY_PATH";
24
+ function utcIso(now) {
25
+ if (now)
26
+ return now;
27
+ return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
28
+ }
29
+ function fractionOk(remaining, max, threshold) {
30
+ if (max <= 0)
31
+ return remaining > 0 && threshold <= 0;
32
+ return remaining / max >= threshold;
33
+ }
34
+ /**
35
+ * Evaluate whether remaining budget meets the surplus threshold (#3285).
36
+ *
37
+ * When max is known: remaining/max >= threshold on every known axis.
38
+ * When only remaining is known (no max): surplus is false (fail-safe) —
39
+ * absolute #3266 reserve still applies separately via recommendVerificationDepth.
40
+ */
41
+ export function evaluateSurplus(input) {
42
+ const threshold = input.surplusThreshold ?? DEFAULT_SURPLUS_THRESHOLD;
43
+ const { budget } = input;
44
+ if (!budget.detected) {
45
+ return {
46
+ hasSurplus: true,
47
+ remainingFraction: null,
48
+ surplusThreshold: threshold,
49
+ axis: "unknown",
50
+ reason: "unbounded budget — surplus gate inactive",
51
+ };
52
+ }
53
+ const turnsKnown = budget.remainingTurns !== null && budget.maxTurns !== null && budget.maxTurns > 0;
54
+ const costKnown = budget.remainingBudget !== null && budget.maxBudget !== null && budget.maxBudget > 0;
55
+ if (!turnsKnown && !costKnown) {
56
+ return {
57
+ hasSurplus: false,
58
+ remainingFraction: null,
59
+ surplusThreshold: threshold,
60
+ axis: "unknown",
61
+ reason: "hard budget without max+remaining pair — refuse deepen (fail-safe surplus unknown)",
62
+ };
63
+ }
64
+ let turnsOk = true;
65
+ let costOk = true;
66
+ let remFrac = null;
67
+ let axis = "unknown";
68
+ if (turnsKnown) {
69
+ const rem = budget.remainingTurns;
70
+ const max = budget.maxTurns;
71
+ turnsOk = fractionOk(rem, max, threshold);
72
+ remFrac = rem / max;
73
+ axis = "turns";
74
+ }
75
+ if (costKnown) {
76
+ const rem = budget.remainingBudget;
77
+ const max = budget.maxBudget;
78
+ costOk = fractionOk(rem, max, threshold);
79
+ const costFrac = rem / max;
80
+ remFrac = remFrac === null ? costFrac : Math.min(remFrac, costFrac);
81
+ axis = turnsKnown ? "both" : "cost";
82
+ }
83
+ const hasSurplus = turnsOk && costOk;
84
+ const pct = Math.round(threshold * 100);
85
+ const remPct = remFrac === null ? "?" : String(Math.round(remFrac * 100));
86
+ return {
87
+ hasSurplus,
88
+ remainingFraction: remFrac,
89
+ surplusThreshold: threshold,
90
+ axis,
91
+ reason: hasSurplus
92
+ ? `surplus ok: remaining≈${remPct}% of max >= ${pct}% threshold (axis=${axis})`
93
+ : `surplus insufficient: remaining≈${remPct}% of max < ${pct}% threshold (axis=${axis})`,
94
+ };
95
+ }
96
+ /**
97
+ * Full AC-pass banking decision: still open / finalize+ship / finalize+deepen.
98
+ */
99
+ export function evaluateAcPassBanking(input) {
100
+ const enabled = input.config?.enabled ?? true;
101
+ const surplusThreshold = input.config?.surplusThreshold ?? DEFAULT_SURPLUS_THRESHOLD;
102
+ const surplus = evaluateSurplus({
103
+ budget: input.budget,
104
+ surplusThreshold,
105
+ });
106
+ if (!input.statedAcceptanceMet) {
107
+ const depthPolicy = recommendVerificationDepth({
108
+ budget: input.budget,
109
+ statedAcceptanceMet: false,
110
+ deepenReserveTurns: input.deepenReserveTurns,
111
+ deepenReserveBudget: input.deepenReserveBudget,
112
+ surplusThreshold,
113
+ });
114
+ return {
115
+ nextAction: "still_open",
116
+ surplus,
117
+ depthPolicy,
118
+ deepeningAllowed: false,
119
+ notes: ["stated acceptance not yet met — bank the pass first (#3285/#3266)"],
120
+ };
121
+ }
122
+ // AC met → FINALIZE is mandatory; deepen only with surplus + absolute reserve.
123
+ const baseDepth = recommendVerificationDepth({
124
+ budget: input.budget,
125
+ statedAcceptanceMet: true,
126
+ deepenReserveTurns: input.deepenReserveTurns,
127
+ deepenReserveBudget: input.deepenReserveBudget,
128
+ surplusThreshold: enabled ? surplusThreshold : null,
129
+ });
130
+ const notes = ["AC-pass bank checkpoint required (finalize-on-green) (#3285)"];
131
+ if (!enabled || !input.budget.detected) {
132
+ const deepen = baseDepth === "stated-then-deepen" || baseDepth === "unconstrained-deepen";
133
+ return {
134
+ nextAction: deepen ? "finalize_and_deepen" : "finalize_and_ship",
135
+ surplus,
136
+ depthPolicy: baseDepth,
137
+ deepeningAllowed: deepen,
138
+ notes: [
139
+ ...notes,
140
+ !enabled
141
+ ? "acPassBanking.enabled=false — surplus gate inactive"
142
+ : "unbounded budget — dual-stop still applies; bank is optional discipline",
143
+ ],
144
+ };
145
+ }
146
+ // Hard-capped + AC met: require surplus AND absolute reserve (baseDepth).
147
+ const absoluteOk = baseDepth === "stated-then-deepen";
148
+ const deepeningAllowed = surplus.hasSurplus && absoluteOk;
149
+ const depthPolicy = deepeningAllowed
150
+ ? "stated-then-deepen"
151
+ : "stated-only";
152
+ if (!deepeningAllowed) {
153
+ notes.push(surplus.reason);
154
+ notes.push(formatDeepeningSkippedNote(input.budget, surplus.reason));
155
+ }
156
+ else {
157
+ notes.push(surplus.reason);
158
+ }
159
+ return {
160
+ nextAction: deepeningAllowed ? "finalize_and_deepen" : "finalize_and_ship",
161
+ surplus,
162
+ depthPolicy,
163
+ deepeningAllowed,
164
+ notes,
165
+ };
166
+ }
167
+ /**
168
+ * Post-bank finding disposition: report (default) vs chase (only with surplus)
169
+ * vs fix-regression (always when stated AC regressed).
170
+ */
171
+ export function decidePostBankFinding(input) {
172
+ const recordedAt = utcIso(input.now);
173
+ if (input.regressesStatedAc) {
174
+ return {
175
+ summary: input.findingSummary,
176
+ action: "fix-regression",
177
+ recordedAt,
178
+ regressesStatedAc: true,
179
+ };
180
+ }
181
+ if (input.hasSurplus) {
182
+ return {
183
+ summary: input.findingSummary,
184
+ action: "chase",
185
+ recordedAt,
186
+ regressesStatedAc: false,
187
+ };
188
+ }
189
+ return {
190
+ summary: input.findingSummary,
191
+ action: "report",
192
+ recordedAt,
193
+ regressesStatedAc: false,
194
+ };
195
+ }
196
+ export function acPassBanksDir(projectRoot) {
197
+ return join(resolve(projectRoot), ...AC_PASS_BANK_DIR.split("/"));
198
+ }
199
+ /**
200
+ * Sanitize scopeId for a ledger filename without unbounded quantifiers (CodeQL).
201
+ * Walks characters once — O(n), no ReDoS risk on pathological "-" runs.
202
+ */
203
+ export function sanitizeScopeIdForFilename(scopeId) {
204
+ const out = [];
205
+ let prevDash = false;
206
+ for (let i = 0; i < scopeId.length && out.length < 48; i += 1) {
207
+ const ch = scopeId[i];
208
+ const code = ch.charCodeAt(0);
209
+ const alnum = (code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122);
210
+ const ok = alnum || ch === "." || ch === "_" || ch === "-";
211
+ if (ok) {
212
+ if (ch === "-") {
213
+ if (prevDash || out.length === 0)
214
+ continue;
215
+ prevDash = true;
216
+ out.push("-");
217
+ }
218
+ else {
219
+ prevDash = false;
220
+ out.push(ch);
221
+ }
222
+ }
223
+ else if (!prevDash && out.length > 0) {
224
+ prevDash = true;
225
+ out.push("-");
226
+ }
227
+ }
228
+ while (out.length > 0 && out[out.length - 1] === "-") {
229
+ out.pop();
230
+ }
231
+ return out.join("") || "scope";
232
+ }
233
+ /** Stable filename for a scope bank record. */
234
+ export function acPassBankFilename(scopeId) {
235
+ const digest = createHash("sha256").update(scopeId, "utf8").digest("hex").slice(0, 16);
236
+ const safe = sanitizeScopeIdForFilename(scopeId);
237
+ return `${safe}-${digest}.json`;
238
+ }
239
+ export function acPassBankPath(projectRoot, scopeId) {
240
+ return join(acPassBanksDir(projectRoot), acPassBankFilename(scopeId));
241
+ }
242
+ /**
243
+ * Persist a bank checkpoint that survives session death (#3285).
244
+ * Also appends a bank-event to DEFT_RUN_SUMMARY_PATH when set (fail-open).
245
+ */
246
+ /** Append-only findings journal (never truncated by re-bank). */
247
+ export function acPassFindingsJournalPath(projectRoot, scopeId) {
248
+ return `${acPassBankPath(projectRoot, scopeId)}.findings.jsonl`;
249
+ }
250
+ function readFindingsJournal(projectRoot, scopeId) {
251
+ const jpath = acPassFindingsJournalPath(projectRoot, scopeId);
252
+ if (!existsSync(jpath))
253
+ return [];
254
+ try {
255
+ const text = readFileSync(jpath, { encoding: "utf8" });
256
+ const out = [];
257
+ for (const line of text.split("\n")) {
258
+ const t = line.trim();
259
+ if (!t)
260
+ continue;
261
+ try {
262
+ const parsed = JSON.parse(t);
263
+ if (isPostBankFinding(parsed))
264
+ out.push(parsed);
265
+ }
266
+ catch {
267
+ // skip bad lines
268
+ }
269
+ }
270
+ return out;
271
+ }
272
+ catch {
273
+ return [];
274
+ }
275
+ }
276
+ function appendFindingsJournal(projectRoot, scopeId, findings) {
277
+ if (findings.length === 0)
278
+ return;
279
+ const root = resolve(projectRoot);
280
+ const jpath = acPassFindingsJournalPath(root, scopeId);
281
+ const dir = acPassBanksDir(root);
282
+ if (!existsSync(dir))
283
+ mkdirSync(dir, { recursive: true });
284
+ const lines = findings.map((f) => JSON.stringify(f)).join("\n") + "\n";
285
+ containedWrite({ root, target: jpath, data: lines, mode: "append" });
286
+ }
287
+ export function bankAcPass(input) {
288
+ const bankedAt = utcIso(input.now);
289
+ const root = resolve(input.projectRoot);
290
+ const path = acPassBankPath(root, input.scopeId);
291
+ const existed = existsSync(path);
292
+ // Preserve prior post-bank findings on re-bank so ledger history survives (#3285 Greptile).
293
+ const prior = readAcPassBank(input.projectRoot, input.scopeId);
294
+ const journal = readFindingsJournal(input.projectRoot, input.scopeId);
295
+ // Unrecoverable existing ledger (no findings recoverable): refuse overwrite.
296
+ if (existed && prior === null) {
297
+ throw new Error(`ac-pass-banking: unrecoverable existing ledger at ${path}; ` +
298
+ `refusing overwrite and banked=true without write (#3285)`);
299
+ }
300
+ // Partial recovery stub: do not treat stub as authoritative. Drop damaged
301
+ // primary/tmp so the next write is a fresh bank seeded from the journal only.
302
+ if (prior !== null && prior.bankedAt === "1970-01-01T00:00:00Z") {
303
+ try {
304
+ if (existsSync(path))
305
+ unlinkSync(path);
306
+ }
307
+ catch {
308
+ // ignore
309
+ }
310
+ try {
311
+ if (existsSync(`${path}.tmp`))
312
+ unlinkSync(`${path}.tmp`);
313
+ }
314
+ catch {
315
+ // ignore
316
+ }
317
+ }
318
+ const record = {
319
+ schemaVersion: AC_PASS_BANK_SCHEMA_VERSION,
320
+ scopeId: input.scopeId,
321
+ bankedAt,
322
+ headSha: input.headSha ?? null,
323
+ remainingTurns: input.budget.remainingTurns,
324
+ remainingBudget: input.budget.remainingBudget,
325
+ maxTurns: input.budget.maxTurns,
326
+ maxBudget: input.budget.maxBudget,
327
+ surplusThreshold: input.surplus.surplusThreshold,
328
+ hadSurplus: input.surplus.hasSurplus,
329
+ nextAction: input.nextAction,
330
+ // Journal is append-only SoT for findings across corrupt rewrites (#3285).
331
+ postBankFindings: mergeFindings(prior?.postBankFindings ?? [], journal),
332
+ };
333
+ const dir = acPassBanksDir(root);
334
+ if (!existsSync(dir)) {
335
+ mkdirSync(dir, { recursive: true });
336
+ }
337
+ // Crash-atomic replace: write tmp then rename. Never truncate live ledger.
338
+ // If rename fails, keep .tmp as the durable checkpoint (read prefers newer).
339
+ const tmpPath = `${path}.tmp`;
340
+ const payload = `${JSON.stringify(record, null, 2)}\n`;
341
+ containedWrite({
342
+ root,
343
+ target: tmpPath,
344
+ data: payload,
345
+ mode: "replace",
346
+ });
347
+ let durablePath = path;
348
+ try {
349
+ renameSync(tmpPath, path);
350
+ }
351
+ catch {
352
+ if (!existed) {
353
+ // First bank: no prior ledger to protect — direct write is safe.
354
+ containedWrite({ root, target: path, data: payload, mode: "replace" });
355
+ }
356
+ else {
357
+ // Prior ledger preserved; .tmp holds the refreshed checkpoint.
358
+ durablePath = tmpPath;
359
+ }
360
+ }
361
+ appendBankEventToRunSummary({
362
+ environ: input.environ ?? process.env,
363
+ event: {
364
+ type: "ac_pass_bank",
365
+ schemaVersion: AC_PASS_BANK_SCHEMA_VERSION,
366
+ scopeId: record.scopeId,
367
+ bankedAt: record.bankedAt,
368
+ nextAction: record.nextAction,
369
+ hadSurplus: record.hadSurplus,
370
+ surplusThreshold: record.surplusThreshold,
371
+ remainingFraction: input.surplus.remainingFraction,
372
+ remainingTurns: record.remainingTurns,
373
+ remainingBudget: record.remainingBudget,
374
+ maxTurns: record.maxTurns,
375
+ maxBudget: record.maxBudget,
376
+ headSha: record.headSha,
377
+ path: durablePath,
378
+ },
379
+ });
380
+ return record;
381
+ }
382
+ /**
383
+ * Best-effort recovery of postBankFindings from a truncated/corrupt ledger
384
+ * so re-bank cannot silently wipe history (#3285 Greptile residual).
385
+ *
386
+ * Handles complete arrays and truncated arrays (missing closing bracket)
387
+ * by salvaging fully-closed finding objects.
388
+ */
389
+ function isPostBankFinding(item) {
390
+ return (typeof item === "object" &&
391
+ item !== null &&
392
+ typeof item.summary === "string" &&
393
+ typeof item.action === "string");
394
+ }
395
+ /** Recover complete JSON objects from a (possibly truncated) array body. */
396
+ export function recoverCompleteJsonObjects(arrayBody) {
397
+ const out = [];
398
+ let i = 0;
399
+ while (i < arrayBody.length) {
400
+ while (i < arrayBody.length && arrayBody[i] !== "{")
401
+ i += 1;
402
+ if (i >= arrayBody.length)
403
+ break;
404
+ const startObj = i;
405
+ let depth = 0;
406
+ let inString = false;
407
+ let escapeNext = false;
408
+ let closed = false;
409
+ for (; i < arrayBody.length; i += 1) {
410
+ const ch = arrayBody[i];
411
+ if (inString) {
412
+ if (escapeNext)
413
+ escapeNext = false;
414
+ else if (ch === "\\")
415
+ escapeNext = true;
416
+ else if (ch === '"')
417
+ inString = false;
418
+ continue;
419
+ }
420
+ if (ch === '"') {
421
+ inString = true;
422
+ continue;
423
+ }
424
+ if (ch === "{")
425
+ depth += 1;
426
+ else if (ch === "}") {
427
+ depth -= 1;
428
+ if (depth === 0) {
429
+ try {
430
+ out.push(JSON.parse(arrayBody.slice(startObj, i + 1)));
431
+ }
432
+ catch {
433
+ // skip incomplete object
434
+ }
435
+ i += 1;
436
+ closed = true;
437
+ break;
438
+ }
439
+ }
440
+ }
441
+ if (!closed)
442
+ break;
443
+ }
444
+ return out;
445
+ }
446
+ export function recoverFindingsFromLedgerText(raw) {
447
+ const key = '"postBankFindings"';
448
+ const idx = raw.indexOf(key);
449
+ if (idx < 0)
450
+ return [];
451
+ let i = idx + key.length;
452
+ while (i < raw.length &&
453
+ (raw[i] === " " || raw[i] === "\t" || raw[i] === "\n" || raw[i] === "\r" || raw[i] === ":")) {
454
+ i += 1;
455
+ }
456
+ if (raw[i] !== "[")
457
+ return [];
458
+ const arrayStart = i + 1;
459
+ let depth = 0;
460
+ for (let j = i; j < raw.length; j += 1) {
461
+ const ch = raw[j];
462
+ if (ch === "[")
463
+ depth += 1;
464
+ else if (ch === "]") {
465
+ depth -= 1;
466
+ if (depth === 0) {
467
+ try {
468
+ const parsed = JSON.parse(raw.slice(i, j + 1));
469
+ if (Array.isArray(parsed)) {
470
+ return parsed.filter(isPostBankFinding);
471
+ }
472
+ }
473
+ catch {
474
+ // fall through to partial recovery
475
+ }
476
+ break;
477
+ }
478
+ }
479
+ }
480
+ return recoverCompleteJsonObjects(raw.slice(arrayStart)).filter(isPostBankFinding);
481
+ }
482
+ function recoveredStubRecord(scopeId, findings) {
483
+ return {
484
+ schemaVersion: AC_PASS_BANK_SCHEMA_VERSION,
485
+ scopeId,
486
+ // Epoch so recovery stubs never win fresher-metadata selection over a
487
+ // valid primary ledger (#3285 Greptile residual).
488
+ bankedAt: "1970-01-01T00:00:00Z",
489
+ headSha: null,
490
+ remainingTurns: null,
491
+ remainingBudget: null,
492
+ maxTurns: null,
493
+ maxBudget: null,
494
+ surplusThreshold: DEFAULT_SURPLUS_THRESHOLD,
495
+ hadSurplus: false,
496
+ nextAction: "finalize_and_ship",
497
+ postBankFindings: findings,
498
+ };
499
+ }
500
+ /**
501
+ * Load a bank record when present. Corrupt files recover findings when possible
502
+ * so re-bank preserves post-bank history (#3285).
503
+ */
504
+ function parseBankText(scopeId, text) {
505
+ try {
506
+ const raw = JSON.parse(text);
507
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
508
+ const findings = recoverFindingsFromLedgerText(text);
509
+ return findings.length > 0 ? recoveredStubRecord(scopeId, findings) : null;
510
+ }
511
+ const rec = raw;
512
+ if (!Array.isArray(rec.postBankFindings)) {
513
+ return {
514
+ ...rec,
515
+ postBankFindings: recoverFindingsFromLedgerText(text),
516
+ };
517
+ }
518
+ return rec;
519
+ }
520
+ catch {
521
+ const findings = recoverFindingsFromLedgerText(text);
522
+ return findings.length > 0 ? recoveredStubRecord(scopeId, findings) : null;
523
+ }
524
+ }
525
+ function mergeFindings(a, b) {
526
+ const out = [];
527
+ const seen = new Set();
528
+ for (const f of [...a, ...b]) {
529
+ const key = `${f.action}|${f.summary}|${f.recordedAt}`;
530
+ if (seen.has(key))
531
+ continue;
532
+ seen.add(key);
533
+ out.push(f);
534
+ }
535
+ return out;
536
+ }
537
+ /**
538
+ * Load bank record. When both primary and .tmp exist, prefer a fully-valid
539
+ * JSON primary over a partial tmp, and union recovered findings so partial
540
+ * recovery cannot erase durable history (#3285 Greptile residual).
541
+ */
542
+ export function readAcPassBank(projectRoot, scopeId) {
543
+ const path = acPassBankPath(projectRoot, scopeId);
544
+ const tmpPath = `${path}.tmp`;
545
+ let primaryText = null;
546
+ let tmpText = null;
547
+ if (existsSync(path)) {
548
+ try {
549
+ primaryText = readFileSync(path, { encoding: "utf8" });
550
+ }
551
+ catch {
552
+ primaryText = null;
553
+ }
554
+ }
555
+ if (existsSync(tmpPath)) {
556
+ try {
557
+ tmpText = readFileSync(tmpPath, { encoding: "utf8" });
558
+ }
559
+ catch {
560
+ tmpText = null;
561
+ }
562
+ }
563
+ if (primaryText === null && tmpText === null)
564
+ return null;
565
+ const primary = primaryText !== null ? parseBankText(scopeId, primaryText) : null;
566
+ const tmp = tmpText !== null ? parseBankText(scopeId, tmpText) : null;
567
+ const journal = readFindingsJournal(projectRoot, scopeId);
568
+ // Prefer fresher fully-parsed record for metadata; always union findings + journal.
569
+ if (primary !== null && tmp !== null) {
570
+ const base = tmp.bankedAt >= primary.bankedAt ? tmp : primary;
571
+ const other = base === tmp ? primary : tmp;
572
+ return {
573
+ ...base,
574
+ postBankFindings: mergeFindings(mergeFindings(base.postBankFindings, other.postBankFindings), journal),
575
+ };
576
+ }
577
+ if (primary !== null) {
578
+ return {
579
+ ...primary,
580
+ postBankFindings: mergeFindings(primary.postBankFindings, journal),
581
+ };
582
+ }
583
+ if (tmp !== null) {
584
+ return {
585
+ ...tmp,
586
+ postBankFindings: mergeFindings(tmp.postBankFindings, journal),
587
+ };
588
+ }
589
+ // Both unparsable as records: union raw recoveries + journal.
590
+ const findings = mergeFindings(mergeFindings(primaryText !== null ? recoverFindingsFromLedgerText(primaryText) : [], tmpText !== null ? recoverFindingsFromLedgerText(tmpText) : []), journal);
591
+ return findings.length > 0 ? recoveredStubRecord(scopeId, findings) : null;
592
+ }
593
+ /**
594
+ * Append a post-bank finding to an existing bank record (report/chase/fix).
595
+ */
596
+ export function recordPostBankFinding(projectRoot, scopeId, finding) {
597
+ const existing = readAcPassBank(projectRoot, scopeId);
598
+ if (existing === null)
599
+ return null;
600
+ // Durable append-only journal first so re-bank cannot lose this finding.
601
+ appendFindingsJournal(projectRoot, scopeId, [finding]);
602
+ const updated = {
603
+ ...existing,
604
+ postBankFindings: mergeFindings(existing.postBankFindings, [finding]),
605
+ };
606
+ const root = resolve(projectRoot);
607
+ const path = acPassBankPath(root, scopeId);
608
+ const tmpPath = `${path}.tmp`;
609
+ const payload = `${JSON.stringify(updated, null, 2)}\n`;
610
+ containedWrite({ root, target: tmpPath, data: payload, mode: "replace" });
611
+ try {
612
+ renameSync(tmpPath, path);
613
+ }
614
+ catch {
615
+ // leave .tmp; journal already durable
616
+ }
617
+ return updated;
618
+ }
619
+ /**
620
+ * Format one JSONL bank-event line for run-summary telemetry (#3285 / #3282).
621
+ */
622
+ export function formatBankEventLine(event) {
623
+ return JSON.stringify({
624
+ ...event,
625
+ ts: typeof event.bankedAt === "string" ? event.bankedAt : utcIso(),
626
+ source: "ac-pass-banking",
627
+ issue: 3285,
628
+ });
629
+ }
630
+ /**
631
+ * Append bank event to DEFT_RUN_SUMMARY_PATH when set.
632
+ * Unset = silent. `-` = stdout. Failures warn once to stderr (fail-open).
633
+ */
634
+ export function appendBankEventToRunSummary(input) {
635
+ const environ = input.environ ?? process.env;
636
+ const raw = (environ[ENV_RUN_SUMMARY_PATH] ?? "").trim();
637
+ if (!raw) {
638
+ return { written: false, path: null };
639
+ }
640
+ const line = `${formatBankEventLine(input.event)}\n`;
641
+ if (raw === "-") {
642
+ process.stdout.write(line);
643
+ return { written: true, path: "-" };
644
+ }
645
+ try {
646
+ if (input.writeLine) {
647
+ input.writeLine(raw, line);
648
+ }
649
+ else {
650
+ const abs = resolve(raw);
651
+ const parent = dirname(abs);
652
+ if (!existsSync(parent)) {
653
+ mkdirSync(parent, { recursive: true });
654
+ }
655
+ // Contain under the parent directory of the summary file.
656
+ containedWrite({
657
+ root: parent,
658
+ target: abs,
659
+ data: line,
660
+ mode: "append",
661
+ });
662
+ }
663
+ return { written: true, path: raw };
664
+ }
665
+ catch (err) {
666
+ const msg = err instanceof ContainedWriteError
667
+ ? err.message
668
+ : err instanceof Error
669
+ ? err.message
670
+ : String(err);
671
+ const warn = input.warn ?? ((m) => process.stderr.write(`${m}\n`));
672
+ warn(`[deft ac-pass-banking] run-summary write failed path=${raw}: ${msg} (#3285)`);
673
+ return { written: false, path: raw };
674
+ }
675
+ }
676
+ /**
677
+ * Simulated run used by the surplus-insufficient acceptance test (#3285):
678
+ * AC passes → bank → extra checks would exceed budget → ship + report finding.
679
+ */
680
+ export function simulateSurplusInsufficientRun(input) {
681
+ const config = {
682
+ enabled: true,
683
+ surplusThreshold: input.surplusThreshold ?? DEFAULT_SURPLUS_THRESHOLD,
684
+ };
685
+ const decision = evaluateAcPassBanking({
686
+ budget: input.budget,
687
+ statedAcceptanceMet: true,
688
+ config,
689
+ });
690
+ const nextAction = decision.nextAction === "still_open" ? "finalize_and_ship" : decision.nextAction;
691
+ const bank = bankAcPass({
692
+ projectRoot: input.projectRoot,
693
+ scopeId: input.scopeId,
694
+ budget: input.budget,
695
+ surplus: decision.surplus,
696
+ nextAction,
697
+ headSha: input.headSha ?? null,
698
+ now: input.now,
699
+ environ: input.environ,
700
+ });
701
+ const finding = decidePostBankFinding({
702
+ findingSummary: input.findingSummary,
703
+ regressesStatedAc: false,
704
+ hasSurplus: decision.surplus.hasSurplus,
705
+ now: input.now,
706
+ });
707
+ recordPostBankFinding(input.projectRoot, input.scopeId, finding);
708
+ const shipped = !decision.deepeningAllowed || finding.action === "report";
709
+ return {
710
+ decision,
711
+ bank,
712
+ finding,
713
+ shipped: shipped || nextAction === "finalize_and_ship",
714
+ findingAction: finding.action,
715
+ };
716
+ }
717
+ /**
718
+ * Resolve policy config for a project root (typed → env → default).
719
+ */
720
+ export function resolveBankingConfigForRoot(projectRoot, environ) {
721
+ const resolved = resolveAcPassBanking(projectRoot, environ ?? process.env);
722
+ return {
723
+ enabled: resolved.enabled,
724
+ surplusThreshold: resolved.surplusThreshold,
725
+ };
726
+ }
727
+ /**
728
+ * Production bridge (#3285 Greptile P1): after verify:ac reports executable
729
+ * success, FINALIZE the bank checkpoint + surplus decision.
730
+ * Throws on I/O failure so verify:ac can fail closed (checkpoint mandatory).
731
+ */
732
+ export function maybeBankOnAcPass(input) {
733
+ if (input.executableRuns <= 0) {
734
+ return { banked: false, decision: null, bank: null, notes: [] };
735
+ }
736
+ const budget = input.budget ?? detectHardEffortBudget({ environ: input.environ ?? process.env });
737
+ const config = input.config ?? resolveBankingConfigForRoot(input.projectRoot, input.environ ?? process.env);
738
+ const decision = evaluateAcPassBanking({
739
+ budget,
740
+ statedAcceptanceMet: true,
741
+ config,
742
+ });
743
+ const nextAction = decision.nextAction === "still_open" ? "finalize_and_ship" : decision.nextAction;
744
+ const bank = bankAcPass({
745
+ projectRoot: input.projectRoot,
746
+ scopeId: input.scopeId,
747
+ budget,
748
+ surplus: decision.surplus,
749
+ nextAction,
750
+ headSha: input.headSha ?? null,
751
+ now: input.now,
752
+ environ: input.environ,
753
+ });
754
+ const notes = [
755
+ ...decision.notes,
756
+ `[deft ac-pass-banking] banked scope=${input.scopeId} next=${nextAction} ` +
757
+ `had_surplus=${decision.surplus.hasSurplus} (#3285)`,
758
+ ];
759
+ return { banked: true, decision, bank, notes };
760
+ }
761
+ //# sourceMappingURL=ac-pass-banking.js.map