@cspeach/cli 1.1.27 → 1.1.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/loop.js +33 -4
- package/dist/agent/provider-shape.js +9 -2
- package/dist/agent/providers/ai-hub-provider.js +2 -2
- package/dist/agent/providers/byok-provider.js +2 -2
- package/dist/agent/providers/local-provider.js +4 -4
- package/dist/agent/system-prompt.js +34 -7
- package/dist/agent/tool-dispatch.js +20 -1
- package/dist/approvals/grant-claims.js +70 -0
- package/dist/approvals/jwt.js +21 -1
- package/dist/approvals/plan-grant.js +297 -0
- package/dist/commands/config-set.js +12 -0
- package/dist/commands/deploy-card.js +10 -5
- package/dist/commands/export-audit.js +1 -1
- package/dist/commands/plan-audit-evidence.js +47 -1
- package/dist/commands/plan-audit.js +23 -8
- package/dist/commands/plan-batch-audit.js +349 -0
- package/dist/commands/plan-boundary.js +88 -0
- package/dist/commands/plan-chain.js +409 -566
- package/dist/commands/plan-continue.js +21 -7
- package/dist/commands/plan-gate.js +28 -49
- package/dist/commands/plan-reground.js +174 -0
- package/dist/commands/plan-resume.js +227 -103
- package/dist/commands/plan-sap-pause.js +105 -0
- package/dist/commands/plan-startup.js +218 -0
- package/dist/config/loader.js +9 -0
- package/dist/doctor/checks/forge-rules.js +67 -85
- package/dist/doctor/run.js +4 -4
- package/dist/lane/app-chain.js +88 -9
- package/dist/one-shot.js +1 -0
- package/dist/projects/extract-plan.js +34 -1
- package/dist/projects/handover-md.js +1 -1
- package/dist/projects/plan-objects.js +75 -0
- package/dist/projects/plan-run.js +74 -18
- package/dist/projects/plan-schema.js +6 -0
- package/dist/projects/plan-sources.js +199 -0
- package/dist/projects/promote-command.js +1 -1
- package/dist/projects/save-command.js +11 -13
- package/dist/projects/saved-lines.js +36 -0
- package/dist/renderer/checklist-format.js +5 -5
- package/dist/renderer/machine-blocks.js +141 -0
- package/dist/renderer/tool-labels.js +3 -0
- package/dist/repl/plan-hop.js +22 -0
- package/dist/repl/rule8-detector.js +16 -1
- package/dist/repl.js +320 -55
- package/dist/router/router-core.js +2 -2
- package/dist/sap/reachability-state.js +25 -0
- package/dist/session/audit-export.js +10 -16
- package/dist/session/recap.js +16 -26
- package/dist/skills/bundled-skills.js +1 -1
- package/dist/tools/_filesystem-shared.js +37 -0
- package/dist/tools/approval.js +48 -2
- package/dist/tools/dispatch-skill.js +3 -2
- package/dist/tools/filesystem/file-read.js +5 -3
- package/dist/tools/filesystem/glob.js +27 -4
- package/dist/tools/fiori/tools.js +6 -0
- package/dist/tools/lane/app-lane-resolve.js +1 -1
- package/dist/tools/sap-read.js +37 -3
- package/dist/tools/sap-write.js +65 -0
- package/dist/tools/text-elements/pool.js +250 -0
- package/dist/tools/text-elements/tools.js +433 -0
- package/dist/tools/tool-loading.js +3 -0
- package/dist/tools/transport.js +26 -5
- package/dist/tools/write-mode.js +4 -3
- package/package.json +120 -121
- package/dist/projects/email-template.js +0 -56
|
@@ -2,20 +2,23 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Task 6 (agentic-flow, 2026-07-03) — the shared post-phase sequence, called
|
|
4
4
|
// by BOTH repl.tsx plan-resume call sites (Ink + classic) so neither
|
|
5
|
-
// duplicates the block.
|
|
5
|
+
// duplicates the block.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
// → runPhaseAudit (fresh-context, tool-less judge turn)
|
|
9
|
-
// → applyAuditResult (verdict persisted as revision N+1)
|
|
10
|
-
// → verdict handling (failed ⇒ re-run/waive/block; infra ⇒ stop,
|
|
11
|
-
// 2nd consecutive infra ⇒ retry/waive/block)
|
|
12
|
-
// → decideNextPhaseGate (step/guarded transition decision)
|
|
13
|
-
// → offerNextPhaseAutoRun (prompt, or auto-queue in guarded mode)
|
|
7
|
+
// 2026-10-04 (continuous run, spec §3.1–§3.4) — one approval, then one run:
|
|
14
8
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
9
|
+
// finishPlanResume persisted the step WITH audit:'pending' (caller)
|
|
10
|
+
// → decideNextPhaseGate guarded: chain on (write steps included);
|
|
11
|
+
// step: ask before each step
|
|
12
|
+
// → deploy card always, before a deploy step
|
|
13
|
+
// → queue the next step (the harness-built resume command)
|
|
14
|
+
//
|
|
15
|
+
// …and when the run ends without queueing a next step (last step, the user
|
|
16
|
+
// said No, a stop), the independent audit runs ONCE over every step still
|
|
17
|
+
// 'pending' (runBatchAudit), then the completion lines.
|
|
18
|
+
//
|
|
19
|
+
// A crash before the end-of-run audit leaves 'pending' on disk; the next
|
|
20
|
+
// resume in a new session checks those steps first (preparePlanResume →
|
|
21
|
+
// runReauditPass), so the audit is never skipped.
|
|
19
22
|
//
|
|
20
23
|
// Everything I/O-heavy or interactive is injectable via PlanChainDeps so the
|
|
21
24
|
// chain is unit-testable without a provider, Ink, or inquirer.
|
|
@@ -23,69 +26,264 @@ import { basename } from 'node:path';
|
|
|
23
26
|
import chalk from 'chalk';
|
|
24
27
|
import { readProjectFile } from '../projects/status.js';
|
|
25
28
|
import { parsePlanContent } from '../projects/plan-schema.js';
|
|
26
|
-
import { statusesFromItems,
|
|
29
|
+
import { statusesFromItems, computeNextPhaseForRun, isPhaseSatisfiedForRun, planCompletionLines, phaseDisplayTitle, } from '../projects/plan-run.js';
|
|
30
|
+
import { savedLocation } from '../projects/saved-lines.js';
|
|
31
|
+
import { clearPlanGrant, getPlanGrant, getGrantTransport, installPlanGrant, noteDeltaOffered, offeredDeltaObjects, planGrantAllowed, } from '../approvals/plan-grant.js';
|
|
32
|
+
import { collectPlannedObjects, plannedObjectFacts } from '../projects/plan-objects.js';
|
|
27
33
|
import { isDeployPhase } from '../projects/plan-lane.js';
|
|
28
34
|
import { askDeployCard } from './deploy-card.js';
|
|
29
35
|
import { decideNextPhaseGate, getAndResetPlanPhaseSubagentDispatches, getAndResetPlanDeviation, } from './plan-gate.js';
|
|
30
|
-
import { runPhaseAudit } from './plan-audit.js';
|
|
31
36
|
import { applyAuditResult, applyPhaseResolution, buildResumeCommand, offerNextPhaseAutoRun, } from './plan-resume.js';
|
|
32
|
-
import {
|
|
33
|
-
import { getGlyphs } from '../renderer/glyphs.js';
|
|
34
|
-
import { formatFailure, formatAttention, formatInfo } from '../renderer/notice-shape.js';
|
|
35
|
-
import { paint } from '../renderer/theme.js';
|
|
37
|
+
import { formatFailure, formatInfo } from '../renderer/notice-shape.js';
|
|
36
38
|
import { clean } from '../renderer/tool-labels.js';
|
|
37
|
-
import {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
import { runBatchAudit, pendingAuditPhaseIds, selectPlanCardAction, } from './plan-batch-audit.js';
|
|
40
|
+
// The audit menus and the failure cap live in plan-batch-audit.ts; re-exported
|
|
41
|
+
// for existing callers.
|
|
42
|
+
export { MAX_CONSECUTIVE_AUDIT_FAILURES, defaultSelectFailureAction, defaultSelectWaiveCategory, } from './plan-batch-audit.js';
|
|
43
|
+
/** After this many "Fix it now" on one step in one run, the card offers only Skip / Stop. */
|
|
44
|
+
export const MAX_FAILED_CHECK_FIXES = 2;
|
|
45
|
+
/**
|
|
46
|
+
* runBatchAudit can throw (a revision failed validation, the save failed).
|
|
47
|
+
* The chain shows one plain line and stops; whatever was not recorded stays
|
|
48
|
+
* 'pending', so the next resume checks it again.
|
|
49
|
+
*/
|
|
50
|
+
async function batchAuditOrNull(args) {
|
|
51
|
+
try {
|
|
52
|
+
return await runBatchAudit(args);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
args.log(formatFailure({
|
|
56
|
+
what: 'The independent check could not finish',
|
|
57
|
+
detail: ['the steps it did not record stay unchecked — CSPeach checks them on the next resume'],
|
|
58
|
+
}).trimEnd());
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The checked steps whose check FAILED and got no answer: the audit says
|
|
64
|
+
* failed while the step still claims done. A step the user marked blocked
|
|
65
|
+
* (status blocked) or chose to re-run (status todo) is not one of them.
|
|
66
|
+
*/
|
|
67
|
+
function unansweredFailures(content, statuses, ids) {
|
|
68
|
+
const want = new Set(ids);
|
|
69
|
+
return content.phases.filter((p) => want.has(p.id) && p.audit?.state === 'failed'
|
|
70
|
+
&& (statuses[p.id] === 'validated' || statuses[p.id] === 'validated-with-waiver'));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* "Fix it now" count per step for this run (in memory). Keyed by the plan's
|
|
74
|
+
* stable id + the step id; cleared when the run ends without queueing a step.
|
|
75
|
+
*/
|
|
76
|
+
const failedCheckFixes = new Map();
|
|
77
|
+
/** Test seam — forget every fix count. */
|
|
78
|
+
export function resetFailedCheckFixAttempts() {
|
|
79
|
+
failedCheckFixes.clear();
|
|
80
|
+
}
|
|
81
|
+
/** One short display line from model text. */
|
|
82
|
+
function shortLine(s, max = 200) {
|
|
83
|
+
const c = clean(s);
|
|
84
|
+
return c.length > max ? `${c.slice(0, max - 1)}…` : c;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The card's facts for a step that ended blocked by its own check: what failed
|
|
88
|
+
* (the reason the step saved — an explicit blocked reason, else its notes,
|
|
89
|
+
* else its own check), the snapshot when one was recorded, and that nothing
|
|
90
|
+
* after it has run. Never field names.
|
|
91
|
+
*/
|
|
92
|
+
export function failedCheckFacts(p) {
|
|
93
|
+
const work = (p.work ?? {});
|
|
94
|
+
const reason = typeof work.blockedReason === 'string' && work.blockedReason.trim()
|
|
95
|
+
? work.blockedReason
|
|
96
|
+
: work.notes?.trim()
|
|
97
|
+
? work.notes
|
|
98
|
+
: null;
|
|
99
|
+
const facts = [reason
|
|
100
|
+
? `What failed: ${shortLine(reason)}`
|
|
101
|
+
: `What failed: its own check did not hold — ${shortLine(p.exitGate)}`];
|
|
102
|
+
if (work.snapshot?.trim())
|
|
103
|
+
facts.push(`Snapshot: ${shortLine(work.snapshot, 120)}`);
|
|
104
|
+
facts.push('Nothing after this step has run.');
|
|
105
|
+
return facts;
|
|
106
|
+
}
|
|
107
|
+
/** Production failed-check card (same surface as the audit menus). */
|
|
108
|
+
export async function defaultSelectFailedCheckAction(a) {
|
|
109
|
+
const choices = [
|
|
110
|
+
...(a.canFix ? [{ value: 'fix', label: 'Fix it now (CSPeach runs this step again)' }] : []),
|
|
111
|
+
{ value: 'skip', label: 'Skip this step (record a one-line reason)' },
|
|
112
|
+
{ value: 'stop', label: 'Stop' },
|
|
113
|
+
];
|
|
114
|
+
const picked = await selectPlanCardAction({
|
|
115
|
+
id: 'plan-failed-check',
|
|
116
|
+
question: `${a.title} could not finish`,
|
|
117
|
+
facts: a.facts,
|
|
118
|
+
choices,
|
|
119
|
+
footnote: 'Plan run',
|
|
120
|
+
});
|
|
121
|
+
return picked === 'none' ? 'stop' : picked;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* 2026-10-04 — a design step may name objects the approved list did not have.
|
|
125
|
+
* Ask ONCE to add them (instead of a card per write). Only objects not yet
|
|
126
|
+
* offered for this plan in this session are asked about: a No (or no answer)
|
|
127
|
+
* is remembered, so the same card never comes back at the next step; those
|
|
128
|
+
* writes ask one by one, as any off-list write does. Only extends a live
|
|
129
|
+
* grant of THIS plan and session, and never where a grant may not be minted
|
|
130
|
+
* (production, advisory-only, per-write approvals).
|
|
131
|
+
*/
|
|
132
|
+
export async function offerObjectListDelta(a) {
|
|
133
|
+
const g = getPlanGrant();
|
|
134
|
+
if (!g || g.planId !== a.planId || g.sessionId !== a.sessionId)
|
|
135
|
+
return 'none';
|
|
136
|
+
const listed = new Set(g.objects.map((o) => `${o.type}:${o.name}`));
|
|
137
|
+
const offered = offeredDeltaObjects(a.planId);
|
|
138
|
+
const added = collectPlannedObjects(a.content)
|
|
139
|
+
.filter((o) => !listed.has(`${o.type}:${o.name}`) && !offered.has(`${o.type}:${o.name}`));
|
|
140
|
+
if (added.length === 0)
|
|
141
|
+
return 'none';
|
|
142
|
+
if (!(await planGrantAllowed(a.sapAlias)))
|
|
143
|
+
return 'none';
|
|
144
|
+
noteDeltaOffered(a.planId, added);
|
|
145
|
+
const confirm = a.confirm ?? (async (req) => (await import('../ui/confirm-request.js')).askConfirm(req));
|
|
146
|
+
const one = added.length === 1;
|
|
147
|
+
const res = await confirm({
|
|
148
|
+
id: 'plan-object-delta',
|
|
149
|
+
tag: 'QUESTION',
|
|
150
|
+
title: `The design added ${added.length} object${one ? '' : 's'} to the plan. Add ${one ? 'it' : 'them'} to the approved list?`,
|
|
151
|
+
facts: plannedObjectFacts(added),
|
|
152
|
+
choices: [{ value: 'yes', label: 'Yes' }, { value: 'no', label: 'No — ask before each of these writes' }],
|
|
153
|
+
initial: 0,
|
|
154
|
+
aliases: { y: 'yes', n: 'no' },
|
|
155
|
+
});
|
|
156
|
+
if (res.value !== 'yes' || res.cancelled)
|
|
157
|
+
return 'declined';
|
|
158
|
+
const transport = getGrantTransport();
|
|
159
|
+
const ok = await installPlanGrant({
|
|
160
|
+
planId: g.planId, sessionId: g.sessionId, objects: [...g.objects, ...added],
|
|
161
|
+
...(transport ? { transport } : {}), ...(a.sapAlias ? { sapAlias: a.sapAlias } : {}),
|
|
162
|
+
}).catch(() => false);
|
|
163
|
+
if (!ok) {
|
|
164
|
+
a.log(`The added object${one ? '' : 's'} could not be approved. Each SAP write to ${one ? 'it' : 'them'} asks first.`);
|
|
165
|
+
return 'declined';
|
|
166
|
+
}
|
|
167
|
+
a.log(formatInfo(`${added.length} object${one ? '' : 's'} added to the approved list`).trimEnd());
|
|
168
|
+
return 'added';
|
|
169
|
+
}
|
|
44
170
|
/**
|
|
45
171
|
* Run the post-phase chain. Returns the LATEST saved envelope path (the
|
|
46
172
|
* caller's savedPath when nothing was written), or null when the chain could
|
|
47
173
|
* not even load the envelope.
|
|
48
174
|
*/
|
|
49
175
|
export async function runPostPhaseChain(args) {
|
|
176
|
+
// 2026-10-04 (continuous run, spec §3.2) — the plan object-list grant lives
|
|
177
|
+
// for one run: when the chain ends without queueing a next step (plan
|
|
178
|
+
// complete, a stop, the user said No, or the chain failed), it is cleared,
|
|
179
|
+
// so a later resume asks again.
|
|
180
|
+
let queuedNext = false;
|
|
181
|
+
const tracked = {
|
|
182
|
+
...args,
|
|
183
|
+
queueDispatch: (cmd) => { queuedNext = true; args.queueDispatch(cmd); },
|
|
184
|
+
};
|
|
185
|
+
try {
|
|
186
|
+
return await postPhaseChain(tracked);
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
if (!queuedNext && !args.suppressOffer)
|
|
190
|
+
clearPlanGrant();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
async function postPhaseChain(args) {
|
|
50
194
|
let path = args.savedPath;
|
|
51
195
|
let loaded = loadPlan(path, args.log);
|
|
52
196
|
if (!loaded)
|
|
53
197
|
return null;
|
|
54
|
-
|
|
198
|
+
const phase = loaded.content.phases.find((p) => p.id === args.executedPhaseId);
|
|
55
199
|
if (!phase) {
|
|
56
|
-
args.log(formatFailure({ what: 'Plan
|
|
200
|
+
args.log(formatFailure({ what: 'Plan step not found', detail: [`${basename(path)} · follow-up steps skipped`] }).trimEnd());
|
|
57
201
|
return path;
|
|
58
202
|
}
|
|
59
203
|
// Per-run flags survive the chain: an explicitly flagged run keeps its mode
|
|
60
|
-
// AND its audit setting across every harness-built resume command
|
|
61
|
-
// audit flag mirrors modeFlag; --audit maps to itself, 'off' to --no-audit).
|
|
204
|
+
// AND its audit setting across every harness-built resume command.
|
|
62
205
|
const flagSuffix = (args.modeFlag ? ` --${args.modeFlag}` : '')
|
|
63
206
|
+ (args.auditFlag === 'off' ? ' --no-audit' : args.auditFlag === 'on' ? ' --audit' : '');
|
|
64
207
|
const resumeCmd = (p) => buildResumeCommand(p) + flagSuffix;
|
|
65
|
-
let
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
208
|
+
let queued = false;
|
|
209
|
+
let stopShown = false;
|
|
210
|
+
const queue = (cmd) => { queued = true; args.queueDispatch(cmd); };
|
|
211
|
+
const titleOf = (p) => phaseDisplayTitle(p, loaded?.content.phases ?? []);
|
|
212
|
+
const batchArgs = (p, phaseIds, force) => ({
|
|
213
|
+
savedPath: p, phaseIds, sessionId: args.sessionId, cwd: args.cwd,
|
|
214
|
+
...(force ? { force: true } : {}),
|
|
215
|
+
prompt: args.prompt, log: args.log, ...(args.deps ? { deps: args.deps } : {}),
|
|
216
|
+
});
|
|
217
|
+
// The run ended without queueing a next step: audit every step still
|
|
218
|
+
// 'pending' once (spec §3.4), then the completion lines + where the file is.
|
|
219
|
+
const endOfRun = async (p) => {
|
|
220
|
+
if (queued)
|
|
221
|
+
return p;
|
|
222
|
+
let cur = p;
|
|
223
|
+
const l = loadPlan(cur, args.log);
|
|
224
|
+
if (!l)
|
|
225
|
+
return cur;
|
|
226
|
+
// Under skipEndAudit only the steps runReauditPass just checked are
|
|
227
|
+
// skipped — any other step still pending is checked here, so "Plan
|
|
228
|
+
// complete" never prints over an unchecked step.
|
|
229
|
+
const justChecked = new Set(args.skipEndAudit ? args.checkedPhaseIds ?? [] : []);
|
|
230
|
+
const ids = pendingAuditPhaseIds(l.content).filter((id) => !justChecked.has(id));
|
|
231
|
+
let autoBlocked = args.reauditFailed === true;
|
|
232
|
+
if (ids.length > 0) {
|
|
233
|
+
const r = await batchAuditOrNull(batchArgs(cur, ids));
|
|
234
|
+
if (!r)
|
|
235
|
+
return cur;
|
|
236
|
+
cur = r.path;
|
|
237
|
+
if (r.rerun) {
|
|
238
|
+
queue(resumeCmd(cur));
|
|
239
|
+
return cur;
|
|
240
|
+
}
|
|
241
|
+
autoBlocked = autoBlocked || r.blocked.length > 0;
|
|
242
|
+
}
|
|
243
|
+
const after = loadPlan(cur, args.log);
|
|
244
|
+
if (after) {
|
|
245
|
+
const st = statusesFromItems(after.envelope.interaction.items, after.content.phases);
|
|
246
|
+
const auditFailed = autoBlocked
|
|
247
|
+
|| unansweredFailures(after.content, st, [...ids, ...(args.checkedPhaseIds ?? [])]).length > 0;
|
|
248
|
+
// F25b — a step whose audit is still deferred ('pending': the check
|
|
249
|
+
// could not run) counts as built; the batch audit already said so.
|
|
250
|
+
const built = after.content.phases.every((q) => isPhaseSatisfiedForRun(st[q.id], q.audit));
|
|
251
|
+
const nextAfter = computeNextPhaseForRun(after.content.phases, st);
|
|
252
|
+
if (built && !nextAfter) {
|
|
253
|
+
args.log('', ...planCompletionLines(after.content.phases));
|
|
254
|
+
args.log(`Plan file: ${savedLocation(cur, args.cwd)}`);
|
|
255
|
+
}
|
|
256
|
+
else if (!built && auditFailed && !stopShown) {
|
|
257
|
+
// A failed check left unresolved (blocked, or no answer): say how to go on.
|
|
258
|
+
args.log(stoppedBanner('a step failed its independent check', resumeCmd(cur)));
|
|
259
|
+
}
|
|
260
|
+
else if (!built && !nextAfter && !stopShown) {
|
|
261
|
+
// Nothing left to run, but a step is blocked (marked blocked, or
|
|
262
|
+
// auto-blocked earlier) and the other steps finished: never end in
|
|
263
|
+
// silence. Name the blocked step(s) and how to go on.
|
|
264
|
+
const phases = after.content.phases;
|
|
265
|
+
const blocked = phases.filter((q) => st[q.id] === 'blocked');
|
|
266
|
+
const open = blocked.length > 0 ? blocked : phases.filter((q) => !isPhaseSatisfiedForRun(st[q.id], q.audit));
|
|
267
|
+
const names = open.map((q) => phaseDisplayTitle(q, phases)).join(', ');
|
|
268
|
+
args.log(stoppedBanner(`${names} ${open.length === 1 ? 'is' : 'are'} blocked; every other step it could run is done`, resumeCmd(cur)));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// The run is over: the next run starts with fresh fix counts.
|
|
272
|
+
if (!queued)
|
|
273
|
+
failedCheckFixes.clear();
|
|
274
|
+
return cur;
|
|
275
|
+
};
|
|
276
|
+
// Task 7 — phase-end helper report (all forced read-only inside plan steps).
|
|
74
277
|
const subagentDispatches = args.subagentDispatches ?? getAndResetPlanPhaseSubagentDispatches();
|
|
75
278
|
if (subagentDispatches > 0) {
|
|
76
|
-
args.log(chalk.dim(
|
|
279
|
+
args.log(chalk.dim(`${titleOf(phase)}: ${subagentDispatches} read-only helper${subagentDispatches === 1 ? '' : 's'} used`));
|
|
77
280
|
}
|
|
78
|
-
/* ── 0. deviation backstop (Task 10)
|
|
79
|
-
* approval.ts raised the flag mid-turn: this
|
|
80
|
-
*
|
|
81
|
-
* prompting; here the chain records why and stops.
|
|
82
|
-
*
|
|
83
|
-
* honestly
|
|
84
|
-
* finishPlanResume-injected 'pending' audit is dropped in the same write
|
|
85
|
-
* (clearAudit) or the next resume would re-audit the blocked phase. Same
|
|
86
|
-
* read+reset pattern as the subagent counter — the flag never leaks into
|
|
87
|
-
* the next chain leg. Guarded-only by construction: approval.ts only ever
|
|
88
|
-
* raises it while isGuardedRunActive(). */
|
|
281
|
+
/* ── 0. deviation backstop (Task 10, unchanged semantics) ────────────────
|
|
282
|
+
* approval.ts raised the flag mid-turn: this step declared writes:false but
|
|
283
|
+
* requested a write approval anyway — the write was DENIED without
|
|
284
|
+
* prompting; here the chain records why and stops. The injected 'pending'
|
|
285
|
+
* audit is dropped in the same write (clearAudit): the step did not finish
|
|
286
|
+
* honestly, so there is nothing to check. */
|
|
89
287
|
const deviationDetail = getAndResetPlanDeviation();
|
|
90
288
|
if (deviationDetail) {
|
|
91
289
|
path = await applyPhaseResolution(path, phase.id, {
|
|
@@ -95,295 +293,119 @@ export async function runPostPhaseChain(args) {
|
|
|
95
293
|
clearAudit: true,
|
|
96
294
|
log: args.log,
|
|
97
295
|
});
|
|
98
|
-
args.log(formatFailure({ what: '
|
|
296
|
+
args.log(formatFailure({ what: 'Step went off plan', object: titleOf(phase), detail: ['it asked to write to SAP, but the plan says this step only reads — the write was refused'] }).trimEnd(), stoppedBanner(`${titleOf(phase)} stopped — it asked for a write the plan did not declare`, resumeCmd(path)));
|
|
99
297
|
ringBell();
|
|
100
|
-
|
|
298
|
+
stopShown = true;
|
|
299
|
+
return endOfRun(path);
|
|
101
300
|
}
|
|
102
|
-
/* ── 1.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
// persisted 'pending' (crash between phase-save and verdict-save) or an
|
|
112
|
-
// explicit forceAudit (hand-edit re-verification) triggers an audit here.
|
|
113
|
-
const needsAudit = args.forceAudit
|
|
114
|
-
|| (!auditsOff && auditState === 'pending');
|
|
115
|
-
if (needsAudit) {
|
|
116
|
-
args.log('', chalk.dim(`[audit] independent audit of phase ${phase.id} — fresh context, judged against the tool-call evidence…`));
|
|
117
|
-
// Evidence lives with the session that EXECUTED the phase; on a re-audit
|
|
118
|
-
// in a fresh CLI session that id was recorded on the pending audit.
|
|
119
|
-
const evidenceSessionId = phase.audit?.sessionId ?? args.sessionId;
|
|
120
|
-
const auditFn = args.deps?.runPhaseAuditFn ?? runPhaseAudit;
|
|
121
|
-
let cost;
|
|
122
|
-
// D-B — window the evidence to THIS attempt only. Live post-phase audit:
|
|
123
|
-
// the repl threads the turn-start it captured (attemptStartedAt).
|
|
124
|
-
// RE-audit (review item 1): the OLD session's JSONL can hold MULTIPLE
|
|
125
|
-
// attempts (fail → rerun in the same process → die before verdict →
|
|
126
|
-
// resume re-audits), so an unwindowed re-audit re-creates the exact
|
|
127
|
-
// cross-attempt false-ordering on the standard recovery path — use the
|
|
128
|
-
// window finishPlanResume persisted on the audit record
|
|
129
|
-
// (audit.attemptStartedAt, carried through verdict writes). A legacy
|
|
130
|
-
// record without the field runs unwindowed (fail-open, no window claim
|
|
131
|
-
// is made to the auditor either — see buildAuditPrompt).
|
|
132
|
-
const sinceIso = args.attemptStartedAt ?? phase.audit?.attemptStartedAt;
|
|
133
|
-
// D-F item 4 — liveness. The audit is a single fresh-context model turn
|
|
134
|
-
// (10-40s) with no streaming into this chain, so the [audit] header sat
|
|
135
|
-
// silent. Reuse the house-style heartbeat (same primitive loop.ts uses),
|
|
136
|
-
// routed through args.log so it works under both Ink and classic.
|
|
137
|
-
// audit-timeout (2026-07-06): use the audit-specific schedule — the audit
|
|
138
|
-
// is a PROXY call (api.cspeach.dev), so the generic "check the VPN" 5m copy
|
|
139
|
-
// is the wrong diagnosis. runPhaseAudit now hard-caps each attempt at 120s,
|
|
140
|
-
// so this can never sit stuck forever. Attempt transitions are surfaced via
|
|
141
|
-
// onRetry below. Always stopped in finally.
|
|
142
|
-
// audit-lean FIX #2 — a LIVE token count threaded from the auditor's stream
|
|
143
|
-
// into the heartbeat suffix, so a stall (`· 0 tokens`) reads differently
|
|
144
|
-
// from slow-but-working progress. The suffix closure reads the latest value
|
|
145
|
-
// at each heartbeat tick; onProgress updates it as chunks arrive.
|
|
146
|
-
let auditTokens = 0;
|
|
147
|
-
const heartbeat = startThinkingHeartbeat({
|
|
148
|
-
thresholds: AUDIT_HEARTBEAT_THRESHOLDS,
|
|
149
|
-
emit: (line) => args.log(line),
|
|
150
|
-
suffix: () => ` · ${auditTokens.toLocaleString()} token${auditTokens === 1 ? '' : 's'}`,
|
|
151
|
-
});
|
|
152
|
-
let verdict;
|
|
153
|
-
try {
|
|
154
|
-
verdict = await auditFn({
|
|
155
|
-
envelope: loaded.envelope,
|
|
156
|
-
phaseId: phase.id,
|
|
157
|
-
sessionId: evidenceSessionId,
|
|
158
|
-
cwd: args.cwd,
|
|
159
|
-
...(sinceIso ? { sinceIso } : {}),
|
|
160
|
-
onCost: (c) => { cost = c; },
|
|
161
|
-
// Best-effort: the heartbeat reads auditTokens at each tick.
|
|
162
|
-
onProgress: (n) => { auditTokens = n; },
|
|
163
|
-
// audit-timeout — an honest attempt-transition line when a prior
|
|
164
|
-
// attempt hit its deadline (or otherwise failed) and we retry in a
|
|
165
|
-
// fresh session. Keeps the timeline truthful against the 120s×3 ceiling.
|
|
166
|
-
onRetry: (r) => {
|
|
167
|
-
const why = r.reason === 'timeout' ? 'previous attempt timed out'
|
|
168
|
-
: r.reason === 'unparseable' ? 'previous attempt returned no verdict'
|
|
169
|
-
: 'previous attempt errored';
|
|
170
|
-
args.log(chalk.dim(` [audit] attempt ${r.attempt} of ${r.totalAttempts} (${why})`));
|
|
171
|
-
},
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
finally {
|
|
175
|
-
heartbeat.stop();
|
|
301
|
+
/* ── 1. forced re-check (hand-edit re-verification) ──────────────────── */
|
|
302
|
+
if (args.forceAudit) {
|
|
303
|
+
const r = await batchAuditOrNull(batchArgs(path, [phase.id], true));
|
|
304
|
+
if (!r)
|
|
305
|
+
return path;
|
|
306
|
+
path = r.path;
|
|
307
|
+
if (r.rerun) {
|
|
308
|
+
queue(resumeCmd(path));
|
|
309
|
+
return path;
|
|
176
310
|
}
|
|
177
|
-
if (cost)
|
|
178
|
-
renderAuditCost(args, cost);
|
|
179
|
-
// Fix-wave (item 10a) — a persisted 'failed' reaching a re-audit means
|
|
180
|
-
// the session died AT the failure menu (verdict written, resolution
|
|
181
|
-
// never was). A failed verdict then RE-CONFIRMS the same failure: hold
|
|
182
|
-
// the counter instead of incrementing, and re-render the menu — the
|
|
183
|
-
// developer must still get the re-run/waive/block choice they lost.
|
|
184
|
-
// Delta-review edge: an interleaved infra outage launders the same case
|
|
185
|
-
// — failed(1) → menu death → re-audit hits infra (the counter is carried
|
|
186
|
-
// onto infra_failed) → the NEXT re-audit's failed verdict would count as
|
|
187
|
-
// consecutive and silently force-block. A persisted infra_failed that
|
|
188
|
-
// CARRIES a failure counter is therefore also a reconfirmation: the
|
|
189
|
-
// menu was owed and never shown.
|
|
190
|
-
const reconfirmingFailed = auditState === 'failed'
|
|
191
|
-
|| (auditState === 'infra_failed' && (phase.audit?.consecutiveFailures ?? 0) >= 1);
|
|
192
|
-
// Consecutive-failure decision happens BEFORE the write so the forced
|
|
193
|
-
// block lands in the SAME revision as the verdict.
|
|
194
|
-
const priorFailures = phase.audit?.consecutiveFailures ?? 0;
|
|
195
|
-
const countedFailures = reconfirmingFailed
|
|
196
|
-
? Math.max(priorFailures, 1)
|
|
197
|
-
: priorFailures + 1;
|
|
198
|
-
const forceBlock = verdict.state === 'failed'
|
|
199
|
-
&& countedFailures >= MAX_CONSECUTIVE_AUDIT_FAILURES;
|
|
200
|
-
const nowIso = new Date().toISOString();
|
|
201
|
-
// Only a real 'failed' verdict records a stop. 'infra_failed' no longer
|
|
202
|
-
// stops (audit-infra-continue) — like warn/pass it leaves no lastStop, so
|
|
203
|
-
// applyAuditResult clears any stale one (see its resolved-verdict branch).
|
|
204
|
-
const lastStop = verdict.state === 'failed'
|
|
205
|
-
? { phaseId: phase.id, reason: 'audit-failed', detail: clampInfra(verdict.findings[0] ?? ''), at: nowIso }
|
|
206
|
-
: undefined;
|
|
207
|
-
// Confidence-tiered redesign (2026-07-08) — the GATE. A `warn` verdict is
|
|
208
|
-
// NON-BLOCKING: it persists LITERALLY (state:'warn', findings carried),
|
|
209
|
-
// clears the failure counter (a warn is not a failure), and the chain
|
|
210
|
-
// continues exactly like a pass — no menu, no lastStop, forceBlock can
|
|
211
|
-
// never fire on it (forceBlock keys off verdict.state === 'failed' above).
|
|
212
|
-
// fail / infra_failed / pass persistence is byte-unchanged.
|
|
213
|
-
path = await applyAuditResult(path, phase.id, {
|
|
214
|
-
state: verdict.state,
|
|
215
|
-
findings: verdict.findings,
|
|
216
|
-
}, {
|
|
217
|
-
lastStop,
|
|
218
|
-
...(forceBlock ? { statusOverride: 'blocked' } : {}),
|
|
219
|
-
...(reconfirmingFailed ? { reconfirmedFailure: true } : {}),
|
|
220
|
-
log: args.log,
|
|
221
|
-
});
|
|
222
|
-
chainWrote = true;
|
|
223
311
|
loaded = loadPlan(path, args.log);
|
|
224
312
|
if (!loaded)
|
|
225
313
|
return path;
|
|
226
|
-
phase = loaded.content.phases.find((p) => p.id === args.executedPhaseId);
|
|
227
|
-
// D-E — the progress board renders HERE, AFTER the verdict lands, so the
|
|
228
|
-
// tally is audit-aware and never contradicts a later verdict. (finishPlanResume
|
|
229
|
-
// now prints only a neutral "audit pending…" line; the board it used to
|
|
230
|
-
// print counted the just-run phase as done because it erased the pending
|
|
231
|
-
// audit for display — a validated phase with a pending audit is NOT
|
|
232
|
-
// satisfied, so the pre-audit board read "2/3" for what was really "1/3".)
|
|
233
|
-
// Suppressed on mid-pass re-audit legs (runReauditPass renders once at the
|
|
234
|
-
// end via its final, unsuppressed leg).
|
|
235
|
-
if (!args.suppressOffer) {
|
|
236
|
-
const boardStatuses = statusesFromItems(loaded.envelope.interaction.items, loaded.content.phases);
|
|
237
|
-
args.log('', renderPlanTracker({
|
|
238
|
-
title: loaded.envelope.title,
|
|
239
|
-
version: loaded.envelope.version,
|
|
240
|
-
content: loaded.content,
|
|
241
|
-
statuses: boardStatuses,
|
|
242
|
-
currentId: null,
|
|
243
|
-
auditsOff,
|
|
244
|
-
}), '');
|
|
245
|
-
}
|
|
246
|
-
if (verdict.state === 'infra_failed') {
|
|
247
|
-
// audit-infra-continue (2026-07-09): the audit COULD NOT RUN — after the
|
|
248
|
-
// in-turn 3 attempts (runPhaseAudit's 120s×3 ceiling) the model call
|
|
249
|
-
// returned no output. That is an audit-INFRASTRUCTURE failure, not a
|
|
250
|
-
// finding against the phase, whose own exit gate (activation + ATC)
|
|
251
|
-
// already passed. So we do NOT hard-stop verified work: exactly like a
|
|
252
|
-
// warn/pass, render a LOUD, DISTINCT "could not run / NOT verified" note,
|
|
253
|
-
// keep the infra_failed state recorded (tracker keeps "! audit not run";
|
|
254
|
-
// the final summary lists it as unverified) and CONTINUE to the gate +
|
|
255
|
-
// offer below. isAuditResolved treats infra_failed as resolved, so
|
|
256
|
-
// dependants unlock and the failure menu is never shown.
|
|
257
|
-
renderAuditNotRun(args.log, phase.id, clampInfra(verdict.findings[0] ?? 'auditor infrastructure failure'), resumeCmd(path));
|
|
258
|
-
}
|
|
259
|
-
else if (verdict.state === 'failed') {
|
|
260
|
-
renderFindings(args.log, phase.id, verdict.findings);
|
|
261
|
-
if (forceBlock) {
|
|
262
|
-
args.log(formatFailure({ what: `Phase ${phase.id} failed its audit ${countedFailures} times in a row`, detail: ['marked blocked'] }).trimEnd(), stoppedBanner('two consecutive failed audits — phase blocked', resumeCmd(path)));
|
|
263
|
-
ringBell();
|
|
264
|
-
return path;
|
|
265
|
-
}
|
|
266
|
-
const select = args.deps?.selectFailureAction ?? defaultSelectFailureAction;
|
|
267
|
-
const action = await select({ phaseId: phase.id, findings: verdict.findings });
|
|
268
|
-
if (action === 'none') {
|
|
269
|
-
// D-C — fail CLOSED. 'none' is headless OR an interactive decline/cancel
|
|
270
|
-
// (Esc, no listener, or an unrecognised menu answer). The phase must NOT
|
|
271
|
-
// auto-re-run — a re-run is an explicit menu choice, never a default.
|
|
272
|
-
// Stop with the banner; resuming re-audits and re-offers the menu (the
|
|
273
|
-
// persisted 'failed' on a still-satisfied phase is re-caught by
|
|
274
|
-
// preparePlanResume's died-at-menu scan).
|
|
275
|
-
args.log(stoppedBanner(`audit failed for phase ${phase.id} — resolve interactively (re-run / waive / block)`, resumeCmd(path)));
|
|
276
|
-
ringBell();
|
|
277
|
-
return path;
|
|
278
|
-
}
|
|
279
|
-
if (action === 'blocked') {
|
|
280
|
-
path = await applyPhaseResolution(path, phase.id, {
|
|
281
|
-
status: 'blocked',
|
|
282
|
-
lastStop: { phaseId: phase.id, reason: 'audit-failed', detail: 'user marked the phase blocked after a failed audit', at: new Date().toISOString() },
|
|
283
|
-
summary: `audit ${phase.id}: failed — user marked phase blocked`,
|
|
284
|
-
log: args.log,
|
|
285
|
-
});
|
|
286
|
-
args.log(stoppedBanner(`phase ${phase.id} marked blocked after failed audit`, resumeCmd(path)));
|
|
287
|
-
ringBell();
|
|
288
|
-
return path;
|
|
289
|
-
}
|
|
290
|
-
if (action === 'rerun') {
|
|
291
|
-
// Status back to 'todo' — computeNextPhase re-picks it, and the flow
|
|
292
|
-
// ALWAYS audits an executed phase, so a re-run is re-audited by
|
|
293
|
-
// construction. The failed audit + counter stay on the phase until
|
|
294
|
-
// the re-run's finishPlanResume replaces them with 'pending'.
|
|
295
|
-
path = await applyPhaseResolution(path, phase.id, {
|
|
296
|
-
status: 'todo',
|
|
297
|
-
summary: `audit ${phase.id}: failed — user chose re-run (status → todo)`,
|
|
298
|
-
log: args.log,
|
|
299
|
-
});
|
|
300
|
-
args.log(formatInfo(`Re-running phase ${phase.id} · it will be re-audited after execution.`).trimEnd());
|
|
301
|
-
args.queueDispatch(resumeCmd(path));
|
|
302
|
-
return path;
|
|
303
|
-
}
|
|
304
|
-
// action === 'waive'
|
|
305
|
-
const reasonRaw = (await args.prompt(`Waive reason for ${phase.id} (one line): `)).trim();
|
|
306
|
-
const reason = reasonRaw.length > 0 ? reasonRaw : 'waived by user (no reason given)';
|
|
307
|
-
// §10 Q2 — a light 2-choice category alongside the reason: was the audit
|
|
308
|
-
// WRONG (false-positive) or is the finding real-but-accepted
|
|
309
|
-
// (accepted-risk)? Persisted so the audit's FP rate is measurable.
|
|
310
|
-
// Headless / cancel → accepted-risk (never a false-positive unasserted).
|
|
311
|
-
const category = await (args.deps?.selectWaiveCategory ?? defaultSelectWaiveCategory)({ phaseId: phase.id });
|
|
312
|
-
path = await applyAuditResult(path, phase.id, { state: 'waived', waivedReason: reason, waiveCategory: category }, { log: args.log });
|
|
313
|
-
loaded = loadPlan(path, args.log);
|
|
314
|
-
if (!loaded)
|
|
315
|
-
return path;
|
|
316
|
-
phase = loaded.content.phases.find((p) => p.id === args.executedPhaseId);
|
|
317
|
-
args.log(paint('dim', `${getGlyphs().info} audit waived (${category}) for ${phase.id} — reason recorded in the envelope.`));
|
|
318
|
-
}
|
|
319
|
-
else if (verdict.state === 'warn') {
|
|
320
|
-
// Non-blocking tier: ALWAYS shown, concise (Open-Q decision — warns are
|
|
321
|
-
// never swallowed). The chain continues to the gate + offer below just
|
|
322
|
-
// like a pass; the notes are already recorded on phase.audit.findings.
|
|
323
|
-
renderWarnings(args.log, phase.id, verdict.findings);
|
|
324
|
-
}
|
|
325
|
-
else {
|
|
326
|
-
args.log(paint('ok', `${getGlyphs().done} audit passed · ${phase.id} verified against the evidence.`));
|
|
327
|
-
}
|
|
328
314
|
}
|
|
329
|
-
/* ── 2. gate + offer ──────────────────────────────────────────────────── */
|
|
330
|
-
// Review I-2 — mid-pass re-audits stop after the verdict: the pass owner
|
|
331
|
-
// (runReauditPass) issues one offer at the end.
|
|
332
315
|
if (args.suppressOffer)
|
|
333
316
|
return path;
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
317
|
+
/* ── 1b. failed-check stop card (spec §3.3 #3, §5) ──────────────────────
|
|
318
|
+
* The step that just ran ended blocked: its own check failed after its own
|
|
319
|
+
* fix-and-recheck. Only on the normal post-phase call — a step blocked by
|
|
320
|
+
* the independent check (re-audit pass / forced re-check) has its own menu. */
|
|
321
|
+
if (!args.checkedPhaseIds && !args.forceAudit) {
|
|
322
|
+
const st0 = statusesFromItems(loaded.envelope.interaction.items, loaded.content.phases);
|
|
323
|
+
const ran = loaded.content.phases.find((p) => p.id === args.executedPhaseId);
|
|
324
|
+
if (ran && st0[ran.id] === 'blocked') {
|
|
325
|
+
const title = titleOf(ran);
|
|
326
|
+
const key = `${loaded.envelope.id}:${ran.id}`;
|
|
327
|
+
const fixes = failedCheckFixes.get(key) ?? 0;
|
|
328
|
+
const select = args.deps?.selectFailedCheckAction ?? defaultSelectFailedCheckAction;
|
|
329
|
+
const action = await select({ phaseId: ran.id, title, facts: failedCheckFacts(ran), canFix: fixes < MAX_FAILED_CHECK_FIXES });
|
|
330
|
+
if (action === 'fix' && fixes < MAX_FAILED_CHECK_FIXES) {
|
|
331
|
+
failedCheckFixes.set(key, fixes + 1);
|
|
332
|
+
path = await applyPhaseResolution(path, ran.id, {
|
|
333
|
+
status: 'todo', summary: `${ran.id}: blocked — user chose fix it now (status → todo)`, log: args.log,
|
|
334
|
+
});
|
|
335
|
+
args.log(formatInfo(`Running ${title} again`).trimEnd());
|
|
336
|
+
queue(resumeCmd(path));
|
|
337
|
+
return path;
|
|
338
|
+
}
|
|
339
|
+
let skipped = false;
|
|
340
|
+
if (action === 'skip') {
|
|
341
|
+
const reason = (await args.prompt(`Why skip ${title}? (one line): `)).trim();
|
|
342
|
+
if (reason.length > 0) {
|
|
343
|
+
// Recorded as a waiver, like the audit waiver: never shown as done.
|
|
344
|
+
path = await applyAuditResult(path, ran.id, { state: 'waived', waivedReason: reason, waiveCategory: 'accepted-risk' }, {
|
|
345
|
+
statusOverride: 'validated-with-waiver', log: args.log,
|
|
346
|
+
});
|
|
347
|
+
args.log(chalk.dim(`${title} skipped — reason recorded in the plan file.`));
|
|
348
|
+
skipped = true;
|
|
349
|
+
const re = loadPlan(path, args.log);
|
|
350
|
+
if (!re)
|
|
351
|
+
return path;
|
|
352
|
+
loaded = re;
|
|
353
|
+
}
|
|
342
354
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
355
|
+
if (!skipped) {
|
|
356
|
+
// Stop: check the steps done, then say how to go on.
|
|
357
|
+
stopShown = true;
|
|
358
|
+
const out = await endOfRun(path);
|
|
359
|
+
if (!queued) {
|
|
360
|
+
args.log(stoppedBanner(`${title} could not finish`, resumeCmd(out)));
|
|
361
|
+
ringBell();
|
|
362
|
+
}
|
|
363
|
+
return out;
|
|
350
364
|
}
|
|
351
365
|
}
|
|
352
|
-
return path;
|
|
353
366
|
}
|
|
367
|
+
/* ── 1c. the design added objects to the approved list (ask once) ───── */
|
|
368
|
+
await offerObjectListDelta({
|
|
369
|
+
content: loaded.content, planId: loaded.envelope.id, sessionId: args.sessionId, log: args.log,
|
|
370
|
+
...(args.sapAlias ? { sapAlias: args.sapAlias } : {}),
|
|
371
|
+
...(args.deps?.confirmCard ? { confirm: args.deps.confirmCard } : {}),
|
|
372
|
+
});
|
|
373
|
+
/* ── 2. gate ─────────────────────────────────────────────────────────── */
|
|
374
|
+
const statuses = statusesFromItems(loaded.envelope.interaction.items, loaded.content.phases);
|
|
375
|
+
const next = computeNextPhaseForRun(loaded.content.phases, statuses);
|
|
376
|
+
if (!next)
|
|
377
|
+
return endOfRun(path);
|
|
378
|
+
// After a batch check (re-audit pass / forced re-check) the gate looks at
|
|
379
|
+
// EVERY step that was checked: the first one left failed with no answer
|
|
380
|
+
// stops the run, whatever its place in the list. A user-chosen 'blocked'
|
|
381
|
+
// does not stop it. Otherwise the gate judges the step that just ran.
|
|
382
|
+
const checked = args.checkedPhaseIds ?? (args.forceAudit ? [phase.id] : null);
|
|
383
|
+
const stopStep = checked
|
|
384
|
+
? unansweredFailures(loaded.content, statuses, checked)[0]
|
|
385
|
+
: loaded.content.phases.find((p) => p.id === args.executedPhaseId);
|
|
354
386
|
const decision = decideNextPhaseGate({
|
|
355
387
|
mode: args.mode,
|
|
356
|
-
nextPhase: { id: next.id, writes: next.writes },
|
|
357
|
-
prevPhaseAudit:
|
|
388
|
+
nextPhase: { id: next.id, writes: next.writes, title: titleOf(next) },
|
|
389
|
+
prevPhaseAudit: stopStep?.audit,
|
|
358
390
|
});
|
|
359
391
|
if (decision.action === 'stop') {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
}
|
|
373
|
-
if (decision.action === 'prompt' && decision.stopReason === 'write-phase') {
|
|
374
|
-
// The prompt IS the stop (guarded semantics) — record it crash-safe
|
|
375
|
-
// BEFORE blocking on the user, then render the banner + bell.
|
|
376
|
-
path = await applyPhaseResolution(path, args.executedPhaseId, {
|
|
377
|
-
lastStop: { phaseId: next.id, reason: 'write-phase', at: new Date().toISOString() },
|
|
378
|
-
summary: `guarded chain stopped: write-phase consent for ${next.id}`,
|
|
379
|
-
log: args.log,
|
|
380
|
-
});
|
|
381
|
-
args.log(substitutePlanToken(decision.banner ?? '', path));
|
|
392
|
+
const stopId = stopStep?.id ?? args.executedPhaseId;
|
|
393
|
+
// The batch check already recorded this stop with the finding text — keep
|
|
394
|
+
// it (no extra revision, no lost detail).
|
|
395
|
+
const recorded = loaded.content.lastStop;
|
|
396
|
+
if (!(recorded?.phaseId === stopId && recorded.reason === 'audit-failed')) {
|
|
397
|
+
path = await applyPhaseResolution(path, stopId, {
|
|
398
|
+
lastStop: { phaseId: stopId, reason: 'audit-failed', at: new Date().toISOString() },
|
|
399
|
+
summary: 'chain stopped: an earlier step failed its independent check',
|
|
400
|
+
log: args.log,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
args.log(substitutePlanToken(decision.banner ?? stoppedBanner('stopped', resumeCmd(path)), path));
|
|
382
404
|
ringBell();
|
|
405
|
+
stopShown = true;
|
|
406
|
+
return endOfRun(path);
|
|
383
407
|
}
|
|
384
|
-
|
|
385
|
-
// chain stops on the CLI-owned deploy card (deploy-card.ts), in step AND
|
|
386
|
-
// guarded mode, instead of the plain "run the next phase?" question.
|
|
408
|
+
/* ── 3. deploy card (always — spec §3.2) ─────────────────────────────── */
|
|
387
409
|
if (isDeployPhase(next)) {
|
|
388
410
|
const card = await askDeployCard({
|
|
389
411
|
phases: loaded.content.phases,
|
|
@@ -400,91 +422,88 @@ export async function runPostPhaseChain(args) {
|
|
|
400
422
|
},
|
|
401
423
|
});
|
|
402
424
|
if (card.outcome === 'blocked') {
|
|
403
|
-
args.log(`${next
|
|
404
|
-
return path;
|
|
425
|
+
args.log(`${titleOf(next)} cannot run here: ${card.reason}. To continue later: open CSPeach in this folder and choose Continue.`);
|
|
426
|
+
return endOfRun(path);
|
|
405
427
|
}
|
|
406
428
|
if (card.outcome === 'no') {
|
|
407
|
-
args.log(
|
|
408
|
-
return path;
|
|
429
|
+
args.log('Not deployed. To continue later: open CSPeach in this folder and choose Continue.');
|
|
430
|
+
return endOfRun(path);
|
|
409
431
|
}
|
|
410
|
-
|
|
432
|
+
queue(resumeCmd(path));
|
|
411
433
|
return path;
|
|
412
434
|
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
435
|
+
/* ── 4. continue ─────────────────────────────────────────────────────── */
|
|
436
|
+
if (decision.action === 'auto') {
|
|
437
|
+
args.log(formatInfo(`Next: ${titleOf(next)}`).trimEnd());
|
|
438
|
+
queue(resumeCmd(path));
|
|
439
|
+
return path;
|
|
440
|
+
}
|
|
441
|
+
// step mode — one consent per step
|
|
442
|
+
const consented = await offerNextPhaseAutoRun({
|
|
443
|
+
outcome: { savedPath: path, resumeCommand: resumeCmd(path), nextPhaseId: next.id, nextDelegateTo: next.delegateTo, nextTitle: titleOf(next) },
|
|
420
444
|
prompt: args.prompt,
|
|
421
445
|
confirm: args.confirm,
|
|
422
|
-
queueDispatch:
|
|
446
|
+
queueDispatch: queue,
|
|
423
447
|
log: args.log,
|
|
424
448
|
gate: decision,
|
|
425
449
|
});
|
|
426
|
-
|
|
450
|
+
// The offer already said how to continue later: one continue line only.
|
|
451
|
+
if (!consented)
|
|
452
|
+
stopShown = true;
|
|
453
|
+
return consented ? path : endOfRun(path);
|
|
427
454
|
}
|
|
428
455
|
/**
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
* feeds forward so every verdict lands on the latest revision.
|
|
435
|
-
*
|
|
436
|
-
* Abort semantics: when a chain iteration queues a dispatch (the user picked
|
|
437
|
-
* re-run, or the final offer auto/consented), the pass stops — continuing to
|
|
438
|
-
* audit while a dispatch is pending would race the re-entry. Any phases left
|
|
439
|
-
* un-audited are named in a warning: hand-edit detection is history-author
|
|
440
|
-
* based and the pass's own writes wash it away, so they will NOT be
|
|
441
|
-
* re-detected automatically on the next resume. Stops (blocked/infra) do NOT
|
|
442
|
-
* abort the pass — infra persists as `infra_failed` (re-caught by the
|
|
443
|
-
* pending/infra scan next resume) and auditing the remaining phases is still
|
|
444
|
-
* useful information.
|
|
456
|
+
* The resume path's audit pass: steps left 'pending' by a run that died, a
|
|
457
|
+
* 'failed' check whose menu was never answered, or (force) every step a hand
|
|
458
|
+
* edit invalidated. All listed steps are checked in one batch (quiet, titles
|
|
459
|
+
* only), then the run continues from where the plan stands — without a
|
|
460
|
+
* second end-of-run audit over the same steps.
|
|
445
461
|
*/
|
|
446
462
|
export async function runReauditPass(args) {
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
463
|
+
// 2026-10-04 — a re-audit leg that queues nothing ends the run (nothing to
|
|
464
|
+
// check, the check could not run, or the chain stopped): the plan
|
|
465
|
+
// object-list grant ends with it.
|
|
466
|
+
let queuedNext = false;
|
|
467
|
+
const tracked = { ...args, queueDispatch: (cmd) => { queuedNext = true; args.queueDispatch(cmd); } };
|
|
468
|
+
try {
|
|
469
|
+
return await reauditPass(tracked);
|
|
470
|
+
}
|
|
471
|
+
finally {
|
|
472
|
+
if (!queuedNext)
|
|
473
|
+
clearPlanGrant();
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
async function reauditPass(args) {
|
|
477
|
+
// A re-audit dispatch runs NO model turn, so a raised deviation flag here
|
|
478
|
+
// can only be stale residue from a chain leg that crashed before consuming
|
|
479
|
+
// it. Discard it, or the chain below would wrongly block a checked step.
|
|
454
480
|
getAndResetPlanDeviation();
|
|
455
|
-
const { phaseIds, ...chainArgs } = args;
|
|
481
|
+
const { phaseIds, force, ...chainArgs } = args;
|
|
456
482
|
if (phaseIds.length === 0)
|
|
457
483
|
return args.savedPath;
|
|
458
|
-
|
|
459
|
-
args.
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
suppressOffer: i < phaseIds.length - 1,
|
|
471
|
-
queueDispatch,
|
|
472
|
-
});
|
|
473
|
-
if (result)
|
|
474
|
-
path = result;
|
|
475
|
-
if (queued) {
|
|
476
|
-
const remaining = phaseIds.slice(i + 1);
|
|
477
|
-
if (remaining.length > 0) {
|
|
478
|
-
args.log(formatAttention({
|
|
479
|
-
text: `${remaining.length} hand-edited phase(s) not re-audited yet`,
|
|
480
|
-
fact: remaining.join(', '),
|
|
481
|
-
next: 'they are not auto-detected after this write; verify them (or re-run their phases) once the queued dispatch completes',
|
|
482
|
-
}).trimEnd());
|
|
483
|
-
}
|
|
484
|
-
break;
|
|
485
|
-
}
|
|
484
|
+
const r = await batchAuditOrNull({
|
|
485
|
+
savedPath: args.savedPath, phaseIds, sessionId: args.sessionId, cwd: args.cwd,
|
|
486
|
+
...(force ? { force: true } : {}), prompt: args.prompt, log: args.log,
|
|
487
|
+
...(args.deps ? { deps: args.deps } : {}),
|
|
488
|
+
});
|
|
489
|
+
if (!r)
|
|
490
|
+
return args.savedPath;
|
|
491
|
+
if (r.rerun) {
|
|
492
|
+
const suffix = (args.modeFlag ? ` --${args.modeFlag}` : '')
|
|
493
|
+
+ (args.auditFlag === 'off' ? ' --no-audit' : args.auditFlag === 'on' ? ' --audit' : '');
|
|
494
|
+
args.queueDispatch(buildResumeCommand(r.path) + suffix);
|
|
495
|
+
return r.path;
|
|
486
496
|
}
|
|
487
|
-
|
|
497
|
+
const out = await runPostPhaseChain({
|
|
498
|
+
...chainArgs,
|
|
499
|
+
savedPath: r.path,
|
|
500
|
+
executedPhaseId: phaseIds[phaseIds.length - 1],
|
|
501
|
+
subagentDispatches: 0,
|
|
502
|
+
skipEndAudit: true,
|
|
503
|
+
checkedPhaseIds: phaseIds,
|
|
504
|
+
...(r.blocked.length > 0 ? { reauditFailed: true } : {}),
|
|
505
|
+
});
|
|
506
|
+
return out ?? r.path;
|
|
488
507
|
}
|
|
489
508
|
/* ── helpers ───────────────────────────────────────────────────────────── */
|
|
490
509
|
function loadPlan(path, log) {
|
|
@@ -516,188 +535,12 @@ function ringBell() {
|
|
|
516
535
|
}
|
|
517
536
|
/** STOPPED banner in the plan-gate style, with the REAL resume command. */
|
|
518
537
|
function stoppedBanner(reason, resumeCommand) {
|
|
519
|
-
return `\n■ STOPPED — ${reason}\n
|
|
538
|
+
return `\n■ STOPPED — ${reason}\n To continue: open CSPeach in this folder and choose Continue, or type ${resumeCommand}\n`;
|
|
520
539
|
}
|
|
521
540
|
/**
|
|
522
|
-
*
|
|
523
|
-
*
|
|
541
|
+
* The gate banner leaves its `@<plan>` placeholder literal (the pure module
|
|
542
|
+
* doesn't know the saved path) — substitute the real basename here.
|
|
524
543
|
*/
|
|
525
544
|
export function substitutePlanToken(banner, savedPath) {
|
|
526
545
|
return banner.replace(/@<plan>/g, `@${basename(savedPath)}`);
|
|
527
546
|
}
|
|
528
|
-
function clampInfra(msg) {
|
|
529
|
-
const flat = msg.replace(/\s+/g, ' ').trim();
|
|
530
|
-
return flat.length > INFRA_RENDER_CHARS ? flat.slice(0, INFRA_RENDER_CHARS - 1).trimEnd() + '…' : flat;
|
|
531
|
-
}
|
|
532
|
-
function renderFindings(log, phaseId, findings) {
|
|
533
|
-
log('', paint('bad', `${getGlyphs().failed} audit FAILED for phase ${phaseId}:`));
|
|
534
|
-
const shown = findings.length > 0 ? findings : ['(auditor returned no findings text)'];
|
|
535
|
-
shown.forEach((f, i) => log(` ${i + 1}. ${f}`));
|
|
536
|
-
}
|
|
537
|
-
/**
|
|
538
|
-
* Confidence-tiered redesign (2026-07-08) — the concise, always-shown render
|
|
539
|
-
* for a NON-BLOCKING warn verdict. Mirrors renderFindings' shape but frames the
|
|
540
|
-
* phase as passed-with-notes (the chain continues). Each finding is already
|
|
541
|
-
* formatted "[W1] detail" by the auditor (plan-audit.renderFinding).
|
|
542
|
-
*/
|
|
543
|
-
function renderWarnings(log, phaseId, findings) {
|
|
544
|
-
const n = findings.length;
|
|
545
|
-
log('', paint('warn', `${getGlyphs().attention} audit passed with ${n} note${n === 1 ? '' : 's'} for phase ${phaseId}:`));
|
|
546
|
-
const shown = n > 0 ? findings : ['(auditor returned a warn verdict with no note detail)'];
|
|
547
|
-
shown.forEach((f, i) => log(` ${i + 1}. ${f}`));
|
|
548
|
-
}
|
|
549
|
-
/**
|
|
550
|
-
* audit-infra-continue (2026-07-09) — the LOUD, always-shown note for an
|
|
551
|
-
* audit that COULD NOT RUN. Distinct from warn's "passed with N notes": this
|
|
552
|
-
* frames the phase as unverified-but-proceeding, is explicit that the audit
|
|
553
|
-
* INFRASTRUCTURE failed (not the work), reassures that the phase's own exit
|
|
554
|
-
* gate passed, and points at the off-switch for a persistent outage — a
|
|
555
|
-
* pointer, not a forced prompt. Never swallowed (the chain continues, so
|
|
556
|
-
* nothing else surfaces this).
|
|
557
|
-
*/
|
|
558
|
-
function renderAuditNotRun(log, phaseId, reason, resumeCommand) {
|
|
559
|
-
log('', paint('warn', `${getGlyphs().attention} audit could not run for phase ${phaseId} (audit infrastructure failed after 3 attempts — the model call returned no output).`), paint('warn', ' This phase is NOT audit-verified; its own exit gate (activation + ATC) passed. Proceeding.'), paint('dim', ` reason: ${reason}`), paint('dim', ` If the auditor keeps failing, run with --no-audit (or set audit off in config) to stop retrying; resume as usual: ${resumeCommand}`));
|
|
560
|
-
}
|
|
561
|
-
/**
|
|
562
|
-
* Shared menu surface for the audit-resolution selects. Mirrors
|
|
563
|
-
* safety-confirm.ts: headless → no decision (fail-closed, nothing written);
|
|
564
|
-
* Ink → confirm card via askConfirm (piece 2 §5); classic → inquirer select.
|
|
565
|
-
*/
|
|
566
|
-
async function selectAuditAction(args) {
|
|
567
|
-
const { isHeadless, shouldUseInk } = await import('../renderer/tty.js');
|
|
568
|
-
if (isHeadless())
|
|
569
|
-
return 'none';
|
|
570
|
-
if (shouldUseInk()) {
|
|
571
|
-
// D-C — this card opens in the POST-PHASE chain. A leaked keystroke used
|
|
572
|
-
// to auto-select choice 1 ("Re-run phase") and silently re-run the
|
|
573
|
-
// phase. The card slot claims input focus (spinner stopped, status row
|
|
574
|
-
// paused, restored after), digits only move the cursor, and typed-ahead
|
|
575
|
-
// keys are dropped — so no stray key can pick a choice.
|
|
576
|
-
const { askConfirm } = await import('../ui/confirm-request.js');
|
|
577
|
-
const res = await askConfirm({
|
|
578
|
-
id: args.id,
|
|
579
|
-
tag: 'SAFETY CHECK',
|
|
580
|
-
title: args.question,
|
|
581
|
-
facts: args.facts,
|
|
582
|
-
choices: args.choices,
|
|
583
|
-
initial: 0,
|
|
584
|
-
footnote: 'Plan audit',
|
|
585
|
-
});
|
|
586
|
-
if (res.cancelled || res.value == null)
|
|
587
|
-
return 'none';
|
|
588
|
-
return coerceAction(res.value, args.choices);
|
|
589
|
-
}
|
|
590
|
-
const { select } = await import('@inquirer/prompts');
|
|
591
|
-
const { withInquirer } = await import('../repl/inquirer-guard.js');
|
|
592
|
-
const { inquirerTheme } = await import('../repl/inquirer-theme.js');
|
|
593
|
-
const picked = await withInquirer(() => select({
|
|
594
|
-
message: args.question,
|
|
595
|
-
theme: inquirerTheme,
|
|
596
|
-
choices: args.choices.map((c) => ({ value: c.value, name: c.label })),
|
|
597
|
-
}));
|
|
598
|
-
return coerceAction(picked, args.choices);
|
|
599
|
-
}
|
|
600
|
-
/**
|
|
601
|
-
* D-C — fail CLOSED on any answer that is not one of this menu's own choice
|
|
602
|
-
* values. `'' ?? 'none'` did NOT catch an empty-string answer (nullish
|
|
603
|
-
* coalescing only guards null/undefined), so a stray/blank answer used to fall
|
|
604
|
-
* through the runPostPhaseChain switch to a silent default. Anything the menu
|
|
605
|
-
* did not explicitly offer maps to 'none' (stop, no auto-action).
|
|
606
|
-
*/
|
|
607
|
-
function coerceAction(answer, choices) {
|
|
608
|
-
return choices.some((c) => c.value === answer) ? answer : 'none';
|
|
609
|
-
}
|
|
610
|
-
/** Production failed-verdict menu (see selectAuditAction for the surface).
|
|
611
|
-
* The findings are already printed above by renderFindings; the card
|
|
612
|
-
* repeats only the count and the first three. */
|
|
613
|
-
export async function defaultSelectFailureAction(a) {
|
|
614
|
-
const n = a.findings.length;
|
|
615
|
-
const facts = [
|
|
616
|
-
`${n} ${n === 1 ? 'finding' : 'findings'}, listed above`,
|
|
617
|
-
// Findings are model text: display-clean them at the source (ANSI and
|
|
618
|
-
// control characters out, one line each), like the safety-card rows.
|
|
619
|
-
...a.findings.slice(0, 3).map((f, i) => `${i + 1}. ${clean(f)}`),
|
|
620
|
-
];
|
|
621
|
-
if (n > 3)
|
|
622
|
-
facts.push(`${getGlyphs().more} ${n - 3} more`);
|
|
623
|
-
return selectAuditAction({
|
|
624
|
-
id: 'plan-audit-failed',
|
|
625
|
-
question: `Audit failed for phase ${a.phaseId} — what now?`,
|
|
626
|
-
facts,
|
|
627
|
-
choices: [
|
|
628
|
-
{ value: 'rerun', label: 'Re-run phase (re-audited after execution)' },
|
|
629
|
-
{ value: 'waive', label: 'Waive audit (record a one-line reason)' },
|
|
630
|
-
{ value: 'blocked', label: 'Mark phase blocked' },
|
|
631
|
-
],
|
|
632
|
-
});
|
|
633
|
-
}
|
|
634
|
-
/**
|
|
635
|
-
* §10 Q2 (audit-confidence redesign) — production waive-category menu. A LIGHT
|
|
636
|
-
* 2-choice shown right after the waive-reason prompt so the audit's real
|
|
637
|
-
* false-positive rate is measurable. Same surface as selectAuditAction, but
|
|
638
|
-
* headless / cancel / stray answer → 'accepted-risk' (the CONSERVATIVE default:
|
|
639
|
-
* a waive no human explicitly labelled a false-positive is recorded as
|
|
640
|
-
* accepted-risk, so the measured FP rate never over-counts). Never hangs.
|
|
641
|
-
*/
|
|
642
|
-
/** Exported for the piece-2 behaviour pins (plan-chain-focus-pin.test.ts). */
|
|
643
|
-
export async function defaultSelectWaiveCategory(a) {
|
|
644
|
-
const { isHeadless, shouldUseInk } = await import('../renderer/tty.js');
|
|
645
|
-
if (isHeadless())
|
|
646
|
-
return 'accepted-risk';
|
|
647
|
-
const question = `Waiving ${a.phaseId} — why? (measures the audit's false-positive rate)`;
|
|
648
|
-
const choices = [
|
|
649
|
-
{ value: 'accepted-risk', label: 'Accepted risk — the finding is real, I accept it and proceed' },
|
|
650
|
-
{ value: 'false-positive', label: 'False positive — the audit was wrong, the work is actually fine' },
|
|
651
|
-
];
|
|
652
|
-
const coerce = (answer) => choices.some((c) => c.value === answer) ? answer : 'accepted-risk';
|
|
653
|
-
if (shouldUseInk()) {
|
|
654
|
-
// Same input-focus discipline as selectAuditAction (askConfirm).
|
|
655
|
-
const { askConfirm } = await import('../ui/confirm-request.js');
|
|
656
|
-
const res = await askConfirm({
|
|
657
|
-
id: 'plan-waive-category',
|
|
658
|
-
tag: 'QUESTION',
|
|
659
|
-
title: question,
|
|
660
|
-
choices,
|
|
661
|
-
initial: 0,
|
|
662
|
-
footnote: 'Plan audit',
|
|
663
|
-
});
|
|
664
|
-
if (res.cancelled || res.value == null)
|
|
665
|
-
return 'accepted-risk';
|
|
666
|
-
return coerce(res.value);
|
|
667
|
-
}
|
|
668
|
-
const { select } = await import('@inquirer/prompts');
|
|
669
|
-
const { withInquirer } = await import('../repl/inquirer-guard.js');
|
|
670
|
-
const { inquirerTheme } = await import('../repl/inquirer-theme.js');
|
|
671
|
-
const picked = await withInquirer(() => select({
|
|
672
|
-
message: question,
|
|
673
|
-
theme: inquirerTheme,
|
|
674
|
-
choices: choices.map((c) => ({ value: c.value, name: c.label })),
|
|
675
|
-
}));
|
|
676
|
-
return coerce(picked);
|
|
677
|
-
}
|
|
678
|
-
/**
|
|
679
|
-
* Audit spend surfaces twice: a dim line next to the turn footer (the
|
|
680
|
-
* `audit` entry) and a labelled turn-0 line in the session cost JSONL —
|
|
681
|
-
* the audit runs in its own isolated session, so without this its tokens
|
|
682
|
-
* would be invisible to the user's cost trail. Best-effort, never throws.
|
|
683
|
-
*/
|
|
684
|
-
function renderAuditCost(args, cost) {
|
|
685
|
-
try {
|
|
686
|
-
const tok = cost.tokens.input + cost.tokens.output;
|
|
687
|
-
const usd = computeCost(cost.model, cost.tokens);
|
|
688
|
-
// Credits mode: the audit turn's USD is our COGS. There is no per-audit
|
|
689
|
-
// credit figure to substitute (the ledger charges the turn, not this
|
|
690
|
-
// sub-step), so the dim line drops the cost fragment and keeps tokens —
|
|
691
|
-
// never a dollar on a managed customer's screen.
|
|
692
|
-
args.log(chalk.dim(creditsActive()
|
|
693
|
-
? `[audit] ${tok.toLocaleString()} tok (${cost.model})`
|
|
694
|
-
: `[audit] ${tok.toLocaleString()} tok · ${formatCost(usd)} (${cost.model})`));
|
|
695
|
-
void appendCostLine(args.sessionId, {
|
|
696
|
-
...buildEntry({ turn: 0, model: cost.model, tokens: cost.tokens, duration_ms: cost.durationMs }),
|
|
697
|
-
label: 'audit',
|
|
698
|
-
});
|
|
699
|
-
}
|
|
700
|
-
catch {
|
|
701
|
-
// observability only
|
|
702
|
-
}
|
|
703
|
-
}
|