@cspeach/cli 1.0.0 → 1.1.1
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 +22 -9
- package/dist/approvals/op-labels.js +124 -0
- package/dist/approvals/render.js +42 -36
- package/dist/cli.js +15 -0
- package/dist/commands/compact.js +28 -2
- package/dist/commands/config-set.js +189 -0
- package/dist/commands/config-show.js +20 -0
- package/dist/commands/export-audit.js +43 -0
- package/dist/commands/help.js +5 -0
- package/dist/commands/plan-audit-evidence.js +266 -0
- package/dist/commands/plan-audit.js +692 -0
- package/dist/commands/plan-chain.js +671 -0
- package/dist/commands/plan-continue.js +179 -0
- package/dist/commands/plan-gate.js +154 -0
- package/dist/commands/plan-resume.js +588 -33
- package/dist/config/loader.js +128 -4
- package/dist/config/model-defaults.js +14 -0
- package/dist/cost/pricing.js +27 -1
- package/dist/doctor/checks/system-roles.js +41 -0
- package/dist/doctor/run.js +2 -0
- package/dist/models/resolve.js +61 -0
- package/dist/models/server-config.js +155 -0
- package/dist/one-shot.js +25 -3
- package/dist/projects/extract-cca.js +3 -1
- package/dist/projects/extract-modernize.js +3 -1
- package/dist/projects/extract-plan.js +60 -6
- package/dist/projects/extract-test-coverage.js +3 -1
- package/dist/projects/extract-upgrade.js +3 -1
- package/dist/projects/handover-md.js +195 -0
- package/dist/projects/index.js +1 -1
- package/dist/projects/plan-run.js +137 -13
- package/dist/projects/plan-schema.js +73 -0
- package/dist/projects/run-lease.js +157 -0
- package/dist/projects/save-command.js +26 -15
- package/dist/renderer/status-footer.js +22 -12
- package/dist/renderer/thinking-heartbeat.js +64 -8
- package/dist/renderer/todo-block.js +51 -0
- package/dist/renderer/tool-widget.js +37 -0
- package/dist/repl/bracketed-paste.js +28 -19
- package/dist/repl/builtin-commands.js +5 -0
- package/dist/repl/current-transport.js +10 -0
- package/dist/repl/history.js +86 -0
- package/dist/repl/ink-stdin-guard.js +64 -0
- package/dist/repl/mode-ceiling.js +16 -0
- package/dist/repl/mode-cycle.js +104 -0
- package/dist/repl/post-turn-status.js +24 -4
- package/dist/repl/slash-completer.js +5 -0
- package/dist/repl.js +954 -83
- package/dist/rewind/candidates.js +194 -0
- package/dist/rewind/cli.js +137 -0
- package/dist/rewind/format.js +27 -0
- package/dist/rewind/restore.js +245 -0
- package/dist/session/audit-export.js +459 -0
- package/dist/session/context-report.js +163 -0
- package/dist/session/recap.js +160 -0
- package/dist/skill-catalog.js +9 -3
- package/dist/skills/bundled-skills.js +71 -78
- package/dist/tools/approval.js +115 -7
- package/dist/tools/ask-question.js +304 -3
- package/dist/tools/extend-model/anchored-insert.js +604 -0
- package/dist/tools/extend-model/tool.js +162 -10
- package/dist/tools/fiori/fe-extend.js +76 -0
- package/dist/tools/fiori/fe-scaffold.js +29 -3
- package/dist/tools/fiori/floorplan-map.js +19 -0
- package/dist/tools/fiori/samples/data/index.json +13602 -0
- package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
- package/dist/tools/fiori/samples/loader.js +248 -0
- package/dist/tools/fiori/samples/search.js +63 -0
- package/dist/tools/fiori/samples/types.js +2 -0
- package/dist/tools/fiori/smoke/assertions.js +74 -0
- package/dist/tools/fiori/smoke/browser.js +52 -0
- package/dist/tools/fiori/smoke/driver.js +89 -0
- package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
- package/dist/tools/fiori/smoke/run-smoke.js +149 -0
- package/dist/tools/fiori/tools.js +328 -3
- package/dist/tools/local-build.js +11 -1
- package/dist/tools/sap-read.js +79 -11
- package/dist/tools/sap-write.js +24 -4
- package/dist/tools/snapshot.js +27 -1
- package/dist/tools/subagent/agent_run.js +27 -3
- package/dist/tools/todo.js +144 -0
- package/dist/ui/app.js +372 -19
- package/dist/ui/approval-modal.js +49 -16
- package/dist/ui/ask-question-emitter.js +14 -0
- package/dist/ui/context-grid.js +108 -0
- package/dist/ui/footer.js +109 -30
- package/dist/ui/header.js +7 -0
- package/dist/ui/line-resolution.js +18 -2
- package/dist/ui/rewind-emitter.js +10 -0
- package/dist/ui/rewind-panel.js +81 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/status-line.js +43 -0
- package/dist/ui/text-input.js +72 -8
- package/dist/ui/todo-emitter.js +25 -0
- package/dist/ui/todo-panel.js +64 -0
- package/dist/ui/turn-status-emitter.js +50 -4
- package/dist/ui/turn-status.js +18 -3
- package/dist/ui/widgets/ask-form.js +242 -0
- package/dist/ui/widgets/ask-question-modal.js +17 -7
- package/package.json +4 -1
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { parsePlanContent, PLAN_PHASE_STATUSES } from './plan-schema.js';
|
|
2
2
|
import { computeNextPhase, isPhaseSatisfied } from './plan-run.js';
|
|
3
|
-
|
|
3
|
+
// E2 dual-read: accept BOTH the legacy `csforge:` prefix (old artifacts on
|
|
4
|
+
// customer disks carry it forever) and the current `cspeach:` prefix. Legacy
|
|
5
|
+
// acceptance is permanent.
|
|
6
|
+
const MANIFEST_RE = /<!--\s*(?:csforge|cspeach):plan-manifest\s*\n([\s\S]*?)\n\s*-->/g;
|
|
4
7
|
// 2026-06-06 live-smoke lesson #4 (Postel): normalise model-emitted
|
|
5
8
|
// `work` blocks instead of rejecting them. Models naturally write
|
|
6
9
|
// `"transport": null` for "none" (schema fields are optional, NOT
|
|
@@ -56,6 +59,20 @@ function expandCompactContent(changedRaw, prior, rawStatuses) {
|
|
|
56
59
|
if (overlay.work == null) {
|
|
57
60
|
delete overlay.work;
|
|
58
61
|
}
|
|
62
|
+
// Task 6 review I-1 (audit forgery seam): audit verdicts are HARNESS-
|
|
63
|
+
// authored only (plan-resume.ts / plan-chain.ts). A model-emitted overlay
|
|
64
|
+
// carrying an `audit` key could wash a persisted 'failed' verdict to
|
|
65
|
+
// 'passed' — strip it so the prior phase's audit always survives the
|
|
66
|
+
// spread below.
|
|
67
|
+
delete overlay.audit;
|
|
68
|
+
// Track 1 (phase-loss seam): `ui` (flavor/floorplan/appId) is plan-create-
|
|
69
|
+
// only — set once at plan creation, NEVER edited on resume (abap-plan
|
|
70
|
+
// SKILL.md hard rule). A resume overlay must never edit or brick it: a
|
|
71
|
+
// malformed model-written `ui` would fail the STRICT planPhaseUiSchema and
|
|
72
|
+
// reject the whole merged candidate (losing the phase), and a valid one
|
|
73
|
+
// would silently rewrite the flavor mid-plan. Strip it so the prior
|
|
74
|
+
// phase's `ui` always survives the spread below.
|
|
75
|
+
delete overlay.ui;
|
|
59
76
|
normalizeWorkFields(overlay);
|
|
60
77
|
}
|
|
61
78
|
// 1:1 map over the PRIOR phase list — changed entries overlay their phase,
|
|
@@ -76,8 +93,11 @@ function expandCompactContent(changedRaw, prior, rawStatuses) {
|
|
|
76
93
|
const summary = {
|
|
77
94
|
total: phases.length,
|
|
78
95
|
// Waived phases (C1) count as validated here — summary.validated means
|
|
79
|
-
// "done for DAG purposes", same as the tracker header tally.
|
|
80
|
-
|
|
96
|
+
// "done for DAG purposes", same as the tracker header tally. Audit-aware
|
|
97
|
+
// (fix-wave): a validated-but-audit-failed phase is NOT done — same
|
|
98
|
+
// predicate call as `next` above and savePlanPatch's recompute, so the
|
|
99
|
+
// two summary fields can never disagree about the same phase.
|
|
100
|
+
validated: phases.filter((p) => isPhaseSatisfied(statuses[p.id], p.audit)).length,
|
|
81
101
|
blocked: phases.filter((p) => statuses[p.id] === 'blocked').length,
|
|
82
102
|
next: next?.id ?? null,
|
|
83
103
|
};
|
|
@@ -86,8 +106,9 @@ function expandCompactContent(changedRaw, prior, rawStatuses) {
|
|
|
86
106
|
/**
|
|
87
107
|
* Parse the LAST plan-manifest block out of the skill's markdown output.
|
|
88
108
|
* `priorContent` is the validated content of the envelope being resumed —
|
|
89
|
-
* required to expand the compact shape;
|
|
90
|
-
*
|
|
109
|
+
* required to expand the compact shape; on the full shape it is the source
|
|
110
|
+
* of truth for per-phase `audit` fields (Task 6 review I-1 — audits are
|
|
111
|
+
* harness-authored, never model-authored). Seed sessions pass nothing.
|
|
91
112
|
*/
|
|
92
113
|
export function extractPlan(markdown, priorContent) {
|
|
93
114
|
// Last block wins — defensive against a turn emitting more than one block.
|
|
@@ -141,8 +162,41 @@ export function extractPlan(markdown, priorContent) {
|
|
|
141
162
|
}
|
|
142
163
|
const rawPhases = rawContent?.phases;
|
|
143
164
|
if (Array.isArray(rawPhases)) {
|
|
144
|
-
|
|
165
|
+
// Task 6 review I-1 (audit forgery seam, full-shape variant): the full
|
|
166
|
+
// shape replaces content wholesale, so a model omitting (or inventing)
|
|
167
|
+
// `audit` fields would erase or forge persisted verdicts — an absent
|
|
168
|
+
// audit is fail-OPEN (isAuditResolved(undefined) === true). Re-impose
|
|
169
|
+
// the prior envelope's audit onto every phase by id and strip any
|
|
170
|
+
// model-authored audit for phases the prior never audited (seed
|
|
171
|
+
// sessions have no prior → all audits stripped; audits are harness-
|
|
172
|
+
// authored only). The just-executed phase needs no exception here:
|
|
173
|
+
// finishPlanResume overwrites its audit with 'pending' on success, and
|
|
174
|
+
// carrying the prior verdict on non-success matches the compact merge.
|
|
175
|
+
const priorAudits = new Map((priorContent?.phases ?? []).map((p) => [p.id, p.audit]));
|
|
176
|
+
for (const p of rawPhases) {
|
|
145
177
|
normalizeWorkFields(p);
|
|
178
|
+
if (p !== null && typeof p === 'object' && !Array.isArray(p)) {
|
|
179
|
+
const rec = p;
|
|
180
|
+
const prior = typeof rec.id === 'string' ? priorAudits.get(rec.id) : undefined;
|
|
181
|
+
if (prior !== undefined)
|
|
182
|
+
rec.audit = prior;
|
|
183
|
+
else
|
|
184
|
+
delete rec.audit;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Fix-wave — content.lastStop is HARNESS-authored (chain-stop records),
|
|
189
|
+
// same rule as the per-phase audits above: the full shape replaces
|
|
190
|
+
// content wholesale, so a model re-emission would silently drop the
|
|
191
|
+
// prior stop record (or forge one). Re-impose the prior envelope's
|
|
192
|
+
// lastStop; strip any model-authored one when the prior never had it
|
|
193
|
+
// (seed sessions have no prior → always stripped).
|
|
194
|
+
if (rawContent && typeof rawContent === 'object') {
|
|
195
|
+
const rc = rawContent;
|
|
196
|
+
if (priorContent?.lastStop !== undefined)
|
|
197
|
+
rc.lastStop = priorContent.lastStop;
|
|
198
|
+
else
|
|
199
|
+
delete rc.lastStop;
|
|
146
200
|
}
|
|
147
201
|
contentInput = obj.content;
|
|
148
202
|
}
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
* item-002 | ZPAYMENTS_OLD | PROG | ZPAYMENTS_OLD_TEST | 4 | generated
|
|
26
26
|
* -->
|
|
27
27
|
*/
|
|
28
|
-
|
|
28
|
+
// E2 dual-read: accept both the legacy `csforge:` and current `cspeach:`
|
|
29
|
+
// prefixes (legacy acceptance is permanent — old saved artifacts carry it).
|
|
30
|
+
const MANIFEST_RE = /<!--\s*(?:csforge|cspeach):test-coverage-manifest\s*\n([\s\S]*?)\n\s*-->/;
|
|
29
31
|
function parseManifestBlock(markdown) {
|
|
30
32
|
const m = MANIFEST_RE.exec(markdown);
|
|
31
33
|
if (!m) {
|
|
@@ -36,7 +36,9 @@
|
|
|
36
36
|
* Parsing is permissive — missing optional fields produce defaults; a
|
|
37
37
|
* missing manifest block throws so callers know to surface the error.
|
|
38
38
|
*/
|
|
39
|
-
|
|
39
|
+
// E2 dual-read: accept both the legacy `csforge:` and current `cspeach:`
|
|
40
|
+
// prefixes (legacy acceptance is permanent — old saved artifacts carry it).
|
|
41
|
+
const MANIFEST_RE = /<!--\s*(?:csforge|cspeach):upgrade-manifest\s*\n([\s\S]*?)\n\s*-->/;
|
|
40
42
|
function parseManifestBlock(markdown) {
|
|
41
43
|
const m = MANIFEST_RE.exec(markdown);
|
|
42
44
|
if (!m) {
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// cspeach-cli/src/projects/handover-md.ts
|
|
2
|
+
//
|
|
3
|
+
// Task 8 (agentic-flow, 2026-07-03) — markdown handover projection.
|
|
4
|
+
//
|
|
5
|
+
// Design rule (docs/strategy/2026-07-02-agentic-flow-design.md, Decision 5):
|
|
6
|
+
// the .cspeach.json envelope is the SINGLE machine source of truth; this
|
|
7
|
+
// markdown file is a WRITE-ONLY, deterministically regenerable projection.
|
|
8
|
+
// Nothing ever reads it back as state — enforced by design: no function in
|
|
9
|
+
// this module accepts markdown. One `<planBase>-handover.md` per plan family
|
|
10
|
+
// (the version suffix is stripped), overwritten on every envelope save.
|
|
11
|
+
//
|
|
12
|
+
// renderPlanHandover is PURE and DETERMINISTIC: no Date.now(), no randomness —
|
|
13
|
+
// every value derives from envelope fields, so the same envelope always
|
|
14
|
+
// renders byte-identical output (regenerability is the whole contract).
|
|
15
|
+
import { writeFileSync } from 'node:fs';
|
|
16
|
+
import { basename, dirname, join } from 'node:path';
|
|
17
|
+
import chalk from 'chalk';
|
|
18
|
+
import { statusesFromItems, computeNextPhase, isPhaseSatisfied, planCompletionLines, } from './plan-run.js';
|
|
19
|
+
import { projectFilename } from './filename.js';
|
|
20
|
+
import { readProjectFile } from './status.js';
|
|
21
|
+
/**
|
|
22
|
+
* Plain-text mirror of the tracker's auditGlyph wording (plan-run.ts) — same
|
|
23
|
+
* words, no chalk. Empty-audit phases render NO segment, matching the
|
|
24
|
+
* tracker's legacy-envelope behaviour. The `default` arm is deliberate
|
|
25
|
+
* forward-compat: an unknown future PlanAuditState reads "audit not run"
|
|
26
|
+
* instead of crashing the projection.
|
|
27
|
+
*/
|
|
28
|
+
function auditWords(audit) {
|
|
29
|
+
if (!audit)
|
|
30
|
+
return null;
|
|
31
|
+
switch (audit.state) {
|
|
32
|
+
case 'passed': return '✔ audited';
|
|
33
|
+
case 'warn': {
|
|
34
|
+
// Non-blocking tier — passed WITH notes. Plain-text mirror of the
|
|
35
|
+
// tracker's warn glyph; count comes from findings.
|
|
36
|
+
const n = audit.findings?.length ?? 0;
|
|
37
|
+
return `⚠ passed with ${n} note${n === 1 ? '' : 's'}`;
|
|
38
|
+
}
|
|
39
|
+
case 'waived': return `✎ waived${audit.waiveCategory ? ` (${audit.waiveCategory})` : ''}`;
|
|
40
|
+
case 'failed': return '✖ audit failed';
|
|
41
|
+
case 'not_audited_hand_edited': return '✋ hand-edited';
|
|
42
|
+
case 'pending':
|
|
43
|
+
case 'infra_failed':
|
|
44
|
+
default:
|
|
45
|
+
return '⚠ audit not run';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Minimal YAML scalar emitter (no library — the frontmatter fields are all
|
|
50
|
+
* scalars / string arrays). Simple token values stay bare; anything else is
|
|
51
|
+
* emitted as a JSON string, which is valid YAML 1.2 double-quoted syntax.
|
|
52
|
+
*/
|
|
53
|
+
function yamlScalar(v) {
|
|
54
|
+
// Bare (plain) scalar only for the safe token alphabet: starts with a
|
|
55
|
+
// non-indicator char (`@` is a YAML reserved indicator and `-` opens a
|
|
56
|
+
// block-sequence entry — both are quoted when leading, but stay fine
|
|
57
|
+
// mid-string), no `: ` (mapping ambiguity), no ` #` (comment), no
|
|
58
|
+
// trailing space. Everything else goes JSON-quoted.
|
|
59
|
+
const plainSafe = /^[A-Za-z0-9/_.][A-Za-z0-9@/_.\- ]*$/.test(v)
|
|
60
|
+
&& !v.includes(': ') && !v.includes(' #') && !v.endsWith(' ');
|
|
61
|
+
return plainSafe ? v : JSON.stringify(v);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Audit off-switch (2026-07-06, owner) — derive `audits: on | off | mixed` for
|
|
65
|
+
* the frontmatter PURELY from phase audit-field presence, no new state:
|
|
66
|
+
* among the SATISFIED phases (the ones that actually ran to completion), an ON
|
|
67
|
+
* run leaves every one carrying an audit field (passed/waived), an OFF run
|
|
68
|
+
* leaves none. Mixed = both present (a plan run partly on, partly off).
|
|
69
|
+
* Nothing satisfied yet ⇒ 'on' (the config default; nothing has run off).
|
|
70
|
+
* Caveat: a pre-auditor legacy plan (satisfied phases, no audit fields) derives
|
|
71
|
+
* 'off' — technically "audits never existed" rather than "turned off", but the
|
|
72
|
+
* field-presence signal is honest and needs no schema addition.
|
|
73
|
+
*/
|
|
74
|
+
function deriveAudits(phases, statuses) {
|
|
75
|
+
const satisfied = phases.filter((p) => isPhaseSatisfied(statuses[p.id], p.audit));
|
|
76
|
+
if (satisfied.length === 0)
|
|
77
|
+
return 'on';
|
|
78
|
+
const withAudit = satisfied.filter((p) => p.audit !== undefined).length;
|
|
79
|
+
if (withAudit === 0)
|
|
80
|
+
return 'off';
|
|
81
|
+
if (withAudit === satisfied.length)
|
|
82
|
+
return 'on';
|
|
83
|
+
return 'mixed';
|
|
84
|
+
}
|
|
85
|
+
function progressLine(p, status) {
|
|
86
|
+
const segments = [
|
|
87
|
+
p.id,
|
|
88
|
+
p.title?.trim() || null,
|
|
89
|
+
status,
|
|
90
|
+
auditWords(p.audit),
|
|
91
|
+
p.work?.transport ? `transport ${p.work.transport}` : null,
|
|
92
|
+
].filter((s) => s !== null && s.length > 0);
|
|
93
|
+
return `- ${segments.join(' — ')}`;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Render the handover markdown for a plan envelope. Pure + deterministic —
|
|
97
|
+
* see the module header. Throws on non-plan envelopes (structural misuse);
|
|
98
|
+
* the write helper below catches everything.
|
|
99
|
+
*/
|
|
100
|
+
export function renderPlanHandover(envelope) {
|
|
101
|
+
if (envelope.artefactType !== 'plan') {
|
|
102
|
+
throw new Error(`renderPlanHandover expects a plan envelope, got ${envelope.artefactType}`);
|
|
103
|
+
}
|
|
104
|
+
const content = envelope.content;
|
|
105
|
+
const statuses = statusesFromItems(envelope.interaction.items, content.phases);
|
|
106
|
+
// Derived status — same predicates as the DAG (never reimplemented here):
|
|
107
|
+
// all satisfied → complete; any blocked → blocked; else in-progress.
|
|
108
|
+
const allSatisfied = content.phases.every((p) => isPhaseSatisfied(statuses[p.id], p.audit));
|
|
109
|
+
const anyBlocked = content.phases.some((p) => statuses[p.id] === 'blocked');
|
|
110
|
+
const status = allSatisfied ? 'complete' : anyBlocked ? 'blocked' : 'in-progress';
|
|
111
|
+
const next = computeNextPhase(content.phases, statuses);
|
|
112
|
+
// 'done' only when genuinely complete; a deadlocked plan (no eligible phase,
|
|
113
|
+
// not all satisfied) says 'none' — 'done' would misreport a blocked plan.
|
|
114
|
+
const phaseField = next ? next.id : allSatisfied ? 'done' : 'none';
|
|
115
|
+
// The resume command uses the canonical filename reconstructed from envelope
|
|
116
|
+
// fields (deterministic). A rare on-disk `-N` collision sibling still
|
|
117
|
+
// resolves — version-less/newest-version resolution in plan-resume.ts
|
|
118
|
+
// matches the whole family.
|
|
119
|
+
const nextAction = allSatisfied
|
|
120
|
+
? planCompletionLines(content.phases).join(' ')
|
|
121
|
+
: `/abap-plan --resume @${projectFilename({
|
|
122
|
+
title: envelope.title, artefactType: 'plan', id: envelope.id, version: envelope.version,
|
|
123
|
+
})}`;
|
|
124
|
+
const artifacts = content.phases.flatMap((p) => p.work?.generated ?? []);
|
|
125
|
+
const audits = deriveAudits(content.phases, statuses);
|
|
126
|
+
const lines = [
|
|
127
|
+
'---',
|
|
128
|
+
`status: ${status}`,
|
|
129
|
+
`audits: ${audits}`,
|
|
130
|
+
`phase: ${yamlScalar(phaseField)}`,
|
|
131
|
+
`next-action: ${yamlScalar(nextAction)}`,
|
|
132
|
+
`artifacts: [${artifacts.map(yamlScalar).join(', ')}]`,
|
|
133
|
+
`envelope-version: ${envelope.version}`,
|
|
134
|
+
'---',
|
|
135
|
+
`# ${envelope.title} — plan handover`,
|
|
136
|
+
'',
|
|
137
|
+
'## Progress',
|
|
138
|
+
'',
|
|
139
|
+
...content.phases.map((p) => progressLine(p, statuses[p.id] ?? 'todo')),
|
|
140
|
+
'',
|
|
141
|
+
'## Last stop',
|
|
142
|
+
'',
|
|
143
|
+
];
|
|
144
|
+
const stop = content.lastStop;
|
|
145
|
+
lines.push(stop
|
|
146
|
+
? `${stop.phaseId} — ${stop.reason}${stop.detail ? ` — ${stop.detail}` : ''} (${stop.at})`
|
|
147
|
+
: '(none)');
|
|
148
|
+
lines.push('', '## Decisions & notes', '');
|
|
149
|
+
const noted = content.phases.filter((p) => (p.work?.notes ?? '').trim().length > 0);
|
|
150
|
+
if (noted.length === 0) {
|
|
151
|
+
lines.push('(none)');
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
for (const p of noted) {
|
|
155
|
+
lines.push(`### ${p.id}`, '', p.work.notes.trim(), '');
|
|
156
|
+
}
|
|
157
|
+
lines.pop(); // drop the trailing blank inside the section
|
|
158
|
+
}
|
|
159
|
+
return lines.join('\n') + '\n';
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The projection's path for an envelope path: version suffix (`-vN` and any
|
|
163
|
+
* `-vN-M` collision sub-suffix from saveProject) stripped, so ONE handover
|
|
164
|
+
* file per plan family is overwritten across versions —
|
|
165
|
+
* `hr-extract-plan-ab12-v3.cspeach.json` → `hr-extract-plan-ab12-handover.md`.
|
|
166
|
+
*/
|
|
167
|
+
export function handoverPathFor(envelopePath) {
|
|
168
|
+
const name = basename(envelopePath);
|
|
169
|
+
const base = name.replace(/(?:-v\d+(?:-\d+)?)?\.cspeach\.json$/, '');
|
|
170
|
+
return join(dirname(envelopePath), `${base}-handover.md`);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Regenerate `<planBase>-handover.md` beside a just-saved plan envelope.
|
|
174
|
+
* Reads the ENVELOPE back from `savedPath` (reading the envelope is fine —
|
|
175
|
+
* it is the source of truth; the .md is never read). Best-effort by design:
|
|
176
|
+
* any failure logs one dim line and returns null — projection loss must
|
|
177
|
+
* never break the run. Returns the written path on success; null for
|
|
178
|
+
* non-plan envelopes and on any failure.
|
|
179
|
+
*/
|
|
180
|
+
export function writePlanHandover(savedPath, log) {
|
|
181
|
+
const logFn = log ?? ((...lines) => { for (const l of lines)
|
|
182
|
+
console.log(l); });
|
|
183
|
+
try {
|
|
184
|
+
const envelope = readProjectFile(savedPath);
|
|
185
|
+
if (envelope.artefactType !== 'plan')
|
|
186
|
+
return null;
|
|
187
|
+
const target = handoverPathFor(savedPath);
|
|
188
|
+
writeFileSync(target, renderPlanHandover(envelope), 'utf8');
|
|
189
|
+
return target;
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
logFn(chalk.dim(`[plan] handover markdown not regenerated (${e instanceof Error ? e.message : String(e)}) — the envelope remains the source of truth.`));
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
package/dist/projects/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { planContentSchema, parsePlanContent, PLAN_LAYERS, PLAN_DELEGATE_SKILLS, PLAN_PHASE_STATUSES } from './plan-schema.js';
|
|
2
2
|
export { extractPlan } from './extract-plan.js';
|
|
3
|
-
export { statusesFromItems, computeNextPhase, renderPlanTracker, buildPlanRevision, isPhaseSatisfied } from './plan-run.js';
|
|
3
|
+
export { statusesFromItems, computeNextPhase, renderPlanTracker, buildPlanRevision, isPhaseSatisfied, isAuditResolved } from './plan-run.js';
|
|
4
4
|
export { validateEnvelope } from './validate.js';
|
|
5
5
|
export { canonicalSha256 } from './canonicalize.js';
|
|
6
6
|
export { titleSlug, shortId, projectFilename } from './filename.js';
|
|
@@ -8,14 +8,45 @@
|
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
/** Statuses a dead session may have left behind — resumable, picked before fresh `todo`s. */
|
|
10
10
|
const IN_PROGRESS = ['designing', 'building', 'verifying'];
|
|
11
|
+
/**
|
|
12
|
+
* Auditor verdict resolution (2026-07-03 agentic-flow design). A phase's
|
|
13
|
+
* audit is RESOLVED when the independent auditor passed it or the user
|
|
14
|
+
* explicitly waived the findings. An ABSENT audit is resolved too — legacy
|
|
15
|
+
* envelopes (phases validated before the auditor existed) must remain
|
|
16
|
+
* satisfied, or every pre-audit plan would deadlock on resume.
|
|
17
|
+
*/
|
|
18
|
+
export function isAuditResolved(audit) {
|
|
19
|
+
if (!audit)
|
|
20
|
+
return true; // legacy envelope — pre-auditor phases stay satisfied
|
|
21
|
+
// 'warn' (confidence-tiered redesign, 2026-07-08) is the NON-BLOCKING tier:
|
|
22
|
+
// the phase passed with notes, so — exactly like a pass or an explicit waiver
|
|
23
|
+
// — it RESOLVES the audit and unlocks dependants. The notes ride along on
|
|
24
|
+
// audit.findings and are surfaced by the tracker glyph + the chain render.
|
|
25
|
+
//
|
|
26
|
+
// 'infra_failed' (audit-infra-continue, 2026-07-09) is RESOLVED too, for a
|
|
27
|
+
// different reason: the audit could not RUN (the model call returned no
|
|
28
|
+
// output after its in-turn retries). An audit that can't run is not a finding
|
|
29
|
+
// against the work — and the phase already passed its own exit gate
|
|
30
|
+
// (activation + ATC) — so walling verified work on an infra outage is wrong.
|
|
31
|
+
// It resolves like a warn: dependants unlock, the chain continues, and the
|
|
32
|
+
// "not verified" fact is surfaced (loud chain note + the ⚠ audit-not-run
|
|
33
|
+
// glyph) and recorded (state stays infra_failed; NOT laundered to waived).
|
|
34
|
+
return audit.state === 'passed' || audit.state === 'waived'
|
|
35
|
+
|| audit.state === 'warn' || audit.state === 'infra_failed';
|
|
36
|
+
}
|
|
11
37
|
/**
|
|
12
38
|
* C1 (2026-06-11, D30 waiver) — a phase counts as DONE for DAG purposes when
|
|
13
39
|
* it is 'validated' OR 'validated-with-waiver' (exit gate not met, but the
|
|
14
40
|
* user explicitly waived it). Single predicate so eligibility, summary
|
|
15
41
|
* counting, and the "plan complete" checks can never drift apart.
|
|
42
|
+
*
|
|
43
|
+
* 2026-07-03 (agentic-flow): additionally, when the phase carries an auditor
|
|
44
|
+
* verdict, it must be resolved (passed/waived) — a validated-but-audit-failed
|
|
45
|
+
* phase does NOT unlock dependants. The param is optional so pre-audit call
|
|
46
|
+
* sites keep their exact behaviour.
|
|
16
47
|
*/
|
|
17
|
-
export function isPhaseSatisfied(s) {
|
|
18
|
-
return s === 'validated' || s === 'validated-with-waiver';
|
|
48
|
+
export function isPhaseSatisfied(s, audit) {
|
|
49
|
+
return (s === 'validated' || s === 'validated-with-waiver') && isAuditResolved(audit);
|
|
19
50
|
}
|
|
20
51
|
/**
|
|
21
52
|
* Phase status lives in interaction.items (store convention). Build the
|
|
@@ -38,13 +69,16 @@ export function statusesFromItems(items, phases) {
|
|
|
38
69
|
* re-entered from the top, gates and grounding included.
|
|
39
70
|
*/
|
|
40
71
|
export function computeNextPhase(phases, statuses) {
|
|
72
|
+
// Audit verdicts live on the phase objects, not the status map — look each
|
|
73
|
+
// dependency up so a validated-but-audit-failed phase gates its dependants.
|
|
74
|
+
const byId = new Map(phases.map((p) => [p.id, p]));
|
|
41
75
|
for (const p of phases) {
|
|
42
76
|
const s = statuses[p.id] ?? 'todo';
|
|
43
|
-
if (isPhaseSatisfied(s) || s === 'blocked')
|
|
77
|
+
if (isPhaseSatisfied(s, p.audit) || s === 'blocked')
|
|
44
78
|
continue;
|
|
45
79
|
if (s !== 'todo' && !IN_PROGRESS.includes(s))
|
|
46
80
|
continue;
|
|
47
|
-
if (p.entryCriteria.every((dep) => isPhaseSatisfied(statuses[dep])))
|
|
81
|
+
if (p.entryCriteria.every((dep) => isPhaseSatisfied(statuses[dep], byId.get(dep)?.audit)))
|
|
48
82
|
return p;
|
|
49
83
|
}
|
|
50
84
|
return null;
|
|
@@ -105,6 +139,33 @@ function phaseDetail(p, s) {
|
|
|
105
139
|
}
|
|
106
140
|
return shortGate(p.exitGate);
|
|
107
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* Auditor-verdict glyph rendered after the phase status (2026-07-03,
|
|
144
|
+
* agentic-flow). Empty string when the phase carries no audit — legacy
|
|
145
|
+
* envelopes must render byte-identical to the pre-audit tracker.
|
|
146
|
+
*/
|
|
147
|
+
function auditGlyph(audit) {
|
|
148
|
+
if (!audit)
|
|
149
|
+
return '';
|
|
150
|
+
switch (audit.state) {
|
|
151
|
+
case 'passed': return ` ${chalk.green('✔ audited')}`;
|
|
152
|
+
case 'warn': {
|
|
153
|
+
// Non-blocking tier: validated-WITH-NOTES. Distinct from a clean pass and
|
|
154
|
+
// from the "not run" states — it ran, passed, and carries N notes.
|
|
155
|
+
const n = audit.findings?.length ?? 0;
|
|
156
|
+
return ` ${chalk.yellow(`⚠ ${n} note${n === 1 ? '' : 's'}`)}`;
|
|
157
|
+
}
|
|
158
|
+
case 'waived': return ` ${chalk.yellow(`✎ waived${audit.waiveCategory ? ` (${audit.waiveCategory})` : ''}`)}`;
|
|
159
|
+
case 'failed': return ` ${chalk.red('✖ audit failed')}`;
|
|
160
|
+
case 'not_audited_hand_edited': return ` ${chalk.yellow('✋ hand-edited')}`;
|
|
161
|
+
case 'pending':
|
|
162
|
+
case 'infra_failed':
|
|
163
|
+
default:
|
|
164
|
+
// `default` is deliberate forward-compat, not a missed case: an unknown
|
|
165
|
+
// future PlanAuditState renders "⚠ audit not run" instead of crashing.
|
|
166
|
+
return ` ${chalk.yellow('⚠ audit not run')}`;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
108
169
|
/**
|
|
109
170
|
* Plan-progress board — a Claude-Code-style checkbox task list. Each phase is
|
|
110
171
|
* named for the work it does (not its mechanical id), so the reader can follow
|
|
@@ -118,7 +179,10 @@ export function renderPlanTracker(a) {
|
|
|
118
179
|
const total = a.content.phases.length;
|
|
119
180
|
// Waived phases count as done in the header tally — the DAG treats them as
|
|
120
181
|
// satisfied; the per-line "✓* (waived)" marker carries the distinction.
|
|
121
|
-
|
|
182
|
+
// Audit-aware (2026-07-03): a validated-but-audit-failed phase must NOT
|
|
183
|
+
// count as done, or the header would say "N/N done" while the completion
|
|
184
|
+
// check refuses to complete — the tally uses the same predicate as the DAG.
|
|
185
|
+
const validated = a.content.phases.filter((p) => isPhaseSatisfied(a.statuses[p.id], p.audit)).length;
|
|
122
186
|
if (a.includeHeader !== false) {
|
|
123
187
|
lines.push(chalk.dim(`─ ${a.title} (v${a.version}) · ${validated}/${total} done ─`));
|
|
124
188
|
lines.push('');
|
|
@@ -129,28 +193,37 @@ export function renderPlanTracker(a) {
|
|
|
129
193
|
const title = phaseTitle(p);
|
|
130
194
|
const detail = phaseDetail(p, s);
|
|
131
195
|
const dash = detail ? ` — ${detail}` : '';
|
|
196
|
+
// Auditor verdict after the status — '' on legacy phases (no audit field),
|
|
197
|
+
// so pre-audit envelopes render byte-identical.
|
|
198
|
+
const audit = auditGlyph(p.audit);
|
|
132
199
|
if (s === 'validated') {
|
|
133
|
-
lines.push(chalk.dim(` ☒ ${title}${dash}`));
|
|
200
|
+
lines.push(chalk.dim(` ☒ ${title}${dash}`) + audit);
|
|
134
201
|
}
|
|
135
202
|
else if (s === 'validated-with-waiver') {
|
|
136
203
|
// C1 (D30 waiver) — done-but-waived: the exit gate was never met; the
|
|
137
204
|
// user accepted it anyway. Distinct marker so a tracker reader can tell
|
|
138
205
|
// a verified phase from a waived one at a glance.
|
|
139
|
-
lines.push(chalk.dim(` ☒ ${title}${dash}`) + ` ${chalk.yellow('✓* (waived)')}`);
|
|
206
|
+
lines.push(chalk.dim(` ☒ ${title}${dash}`) + ` ${chalk.yellow('✓* (waived)')}` + audit);
|
|
140
207
|
}
|
|
141
208
|
else if (s === 'blocked') {
|
|
142
|
-
lines.push(` ${chalk.red('☐')} ${chalk.red(title)} ${chalk.red('— blocked')}${detail ? chalk.dim(` · ${detail}`) : ''}`);
|
|
209
|
+
lines.push(` ${chalk.red('☐')} ${chalk.red(title)} ${chalk.red('— blocked')}${detail ? chalk.dim(` · ${detail}`) : ''}${audit}`);
|
|
143
210
|
}
|
|
144
211
|
else if (isCurrent) {
|
|
145
|
-
lines.push(` ${chalk.hex(PEACH)('☐')} ${chalk.bold(title)}${dash} ${chalk.hex(PEACH).bold('◀ now')}`);
|
|
212
|
+
lines.push(` ${chalk.hex(PEACH)('☐')} ${chalk.bold(title)}${dash}${audit} ${chalk.hex(PEACH).bold('◀ now')}`);
|
|
146
213
|
}
|
|
147
214
|
else if (IN_PROGRESS.includes(s)) {
|
|
148
|
-
lines.push(` ${chalk.hex(PEACH)('☐')} ${chalk.bold(title)}${dash} ${chalk.dim(`(${s})`)}`);
|
|
215
|
+
lines.push(` ${chalk.hex(PEACH)('☐')} ${chalk.bold(title)}${dash}${audit} ${chalk.dim(`(${s})`)}`);
|
|
149
216
|
}
|
|
150
217
|
else {
|
|
151
|
-
lines.push(` ☐ ${title}${chalk.dim(dash)}`);
|
|
218
|
+
lines.push(` ☐ ${title}${chalk.dim(dash)}${audit}`);
|
|
152
219
|
}
|
|
153
220
|
}
|
|
221
|
+
// Off-switch footer — honesty: state plainly that this run's phases are not
|
|
222
|
+
// being audited. Rendered only when the caller threads auditsOff (in-memory).
|
|
223
|
+
if (a.auditsOff) {
|
|
224
|
+
lines.push('');
|
|
225
|
+
lines.push(chalk.dim(' audits: off'));
|
|
226
|
+
}
|
|
154
227
|
return lines.join('\n');
|
|
155
228
|
}
|
|
156
229
|
/* ── plan-complete chaining (C2, 2026-06-11, D24) ───────────────────────── */
|
|
@@ -201,6 +274,42 @@ export function findServiceBinding(phases) {
|
|
|
201
274
|
*/
|
|
202
275
|
export function planCompletionLines(phases) {
|
|
203
276
|
const lines = ['Plan complete — every phase validated.'];
|
|
277
|
+
// Confidence-tiered redesign (2026-07-08): a completed plan that carried
|
|
278
|
+
// non-blocking warn verdicts surfaces them in the FINAL summary — a warned
|
|
279
|
+
// phase shows its notes, so an "all validated" plan never hides that a phase
|
|
280
|
+
// passed WITH reservations. (Empty when no phase warned ⇒ byte-identical to
|
|
281
|
+
// the pre-warn completion message.)
|
|
282
|
+
for (const p of phases) {
|
|
283
|
+
if (p.audit?.state !== 'warn')
|
|
284
|
+
continue;
|
|
285
|
+
const notes = p.audit.findings ?? [];
|
|
286
|
+
lines.push(notes.length > 0
|
|
287
|
+
? `⚠ ${p.id} passed with ${notes.length} note${notes.length === 1 ? '' : 's'}: ${notes.join('; ')}.`
|
|
288
|
+
: `⚠ ${p.id} passed with notes.`);
|
|
289
|
+
}
|
|
290
|
+
// §10 Q2 (audit-confidence redesign): a waived phase surfaces its light
|
|
291
|
+
// category + reason in the FINAL summary — an "all validated" plan never
|
|
292
|
+
// hides that a gate was a human override, and the category (false-positive
|
|
293
|
+
// vs accepted-risk) is right there for the reader. Legacy waivers without a
|
|
294
|
+
// category degrade to a bare `waived:` line (back-compat).
|
|
295
|
+
for (const p of phases) {
|
|
296
|
+
if (p.audit?.state !== 'waived')
|
|
297
|
+
continue;
|
|
298
|
+
const cat = p.audit.waiveCategory ? ` (${p.audit.waiveCategory})` : '';
|
|
299
|
+
const reason = p.audit.waivedReason ? `: ${p.audit.waivedReason}` : '';
|
|
300
|
+
lines.push(`✎ ${p.id} waived${cat}${reason}.`);
|
|
301
|
+
}
|
|
302
|
+
// audit-infra-continue (2026-07-09): a phase whose audit could NOT RUN
|
|
303
|
+
// surfaces in the FINAL summary as a DISTINCT "unverified" line — an "all
|
|
304
|
+
// validated" plan must never hide that a phase shipped without an independent
|
|
305
|
+
// audit. It is framed as an infrastructure failure (not a code finding) and
|
|
306
|
+
// NOT as a waiver (the user never asserted acceptance). Empty when no phase
|
|
307
|
+
// hit infra ⇒ byte-identical to the pre-fix completion message.
|
|
308
|
+
for (const p of phases) {
|
|
309
|
+
if (p.audit?.state !== 'infra_failed')
|
|
310
|
+
continue;
|
|
311
|
+
lines.push(`⚠ ${p.id} was NOT audit-verified (audit infrastructure failed — its own exit gate, activation + ATC, passed).`);
|
|
312
|
+
}
|
|
204
313
|
const hasUi = phases.some((p) => p.layer === 'ui' || p.delegateTo === 'abap-fiori-build');
|
|
205
314
|
const hasService = phases.some((p) => p.layer === 'service');
|
|
206
315
|
if (!hasUi && hasService) {
|
|
@@ -209,6 +318,13 @@ export function planCompletionLines(phases) {
|
|
|
209
318
|
? `Backend complete. Next: /abap-fiori-build — point it at ${binding}.`
|
|
210
319
|
: 'Backend complete. Next: /abap-fiori-build — point it at the published service binding.');
|
|
211
320
|
}
|
|
321
|
+
// Last-written-wins: the LAST ui phase carrying a deployedUrl, matching
|
|
322
|
+
// buildUiPhaseContext's reverse-scan resolution (both consume ui work that
|
|
323
|
+
// ui.deploy records — direction must agree between the two sites).
|
|
324
|
+
const deployed = [...phases].reverse().find((p) => p.layer === 'ui' && p.work?.deployedUrl);
|
|
325
|
+
if (deployed?.work?.deployedUrl) {
|
|
326
|
+
lines.push(`App deployed: ${deployed.work.deployedUrl}`);
|
|
327
|
+
}
|
|
212
328
|
lines.push('Consider /abap-preflight on the produced transport(s) before release.');
|
|
213
329
|
return lines;
|
|
214
330
|
}
|
|
@@ -224,7 +340,14 @@ export function planCompletionLines(phases) {
|
|
|
224
340
|
* - item comments / answers added via the editor survive the revision —
|
|
225
341
|
* only the status comes from the new extract
|
|
226
342
|
*/
|
|
227
|
-
export function buildPlanRevision(original, extract, author, nowIso
|
|
343
|
+
export function buildPlanRevision(original, extract, author, nowIso,
|
|
344
|
+
/**
|
|
345
|
+
* Task 6 (2026-07-03): optional history-entry summary. Audit-verdict
|
|
346
|
+
* revisions carry no phase-status diff, so the default "resume run — no
|
|
347
|
+
* phase status change" would hide WHY the version exists; callers like
|
|
348
|
+
* applyAuditResult pass e.g. "audit c1.design: failed (2 findings)".
|
|
349
|
+
*/
|
|
350
|
+
summaryOverride) {
|
|
228
351
|
if (original.artefactType !== 'plan') {
|
|
229
352
|
throw new Error(`buildPlanRevision expects a plan envelope, got ${original.artefactType}`);
|
|
230
353
|
}
|
|
@@ -241,7 +364,8 @@ export function buildPlanRevision(original, extract, author, nowIso) {
|
|
|
241
364
|
if (before !== after)
|
|
242
365
|
diffs.push(`${p.id}: ${before} → ${after}`);
|
|
243
366
|
}
|
|
244
|
-
const summary =
|
|
367
|
+
const summary = summaryOverride
|
|
368
|
+
?? (diffs.length > 0 ? diffs.join('; ') : 'resume run — no phase status change');
|
|
245
369
|
return {
|
|
246
370
|
...original,
|
|
247
371
|
lastEditedAt: nowIso,
|
|
@@ -56,7 +56,71 @@ const planPhaseWorkSchema = z.object({
|
|
|
56
56
|
notes: z.string().optional(),
|
|
57
57
|
// C2 (D24): exact SRVB name for the plan-complete /abap-fiori-build chain.
|
|
58
58
|
binding: z.string().optional(),
|
|
59
|
+
// Track 1 ui-phase fields — tolerant on purpose (written mid-resume by the
|
|
60
|
+
// model; a malformed value must degrade to undefined, never fail the whole
|
|
61
|
+
// envelope parse). Keep in sync with PlanPhaseWork in types.ts.
|
|
62
|
+
app: z.object({ dir: z.string().min(1) }).optional().catch(undefined),
|
|
63
|
+
service: z.object({
|
|
64
|
+
url: z.string().min(1).optional(),
|
|
65
|
+
path: z.string().min(1).optional(),
|
|
66
|
+
version: z.enum(['2.0', '4.0']).optional(),
|
|
67
|
+
}).optional().catch(undefined),
|
|
68
|
+
deployedUrl: z.string().min(1).optional().catch(undefined),
|
|
59
69
|
}).passthrough();
|
|
70
|
+
export const PLAN_UI_FLAVORS = ['fiori-elements', 'freestyle'];
|
|
71
|
+
export const PLAN_UI_FLOORPLANS = ['listReport', 'worklist', 'ovp', 'alp'];
|
|
72
|
+
// Strict on purpose: written once at plan-create under save validation —
|
|
73
|
+
// a bad flavor must fail the save, not silently degrade.
|
|
74
|
+
const planPhaseUiSchema = z.object({
|
|
75
|
+
flavor: z.enum(PLAN_UI_FLAVORS),
|
|
76
|
+
floorplan: z.enum(PLAN_UI_FLOORPLANS).optional(),
|
|
77
|
+
appId: z.string().min(1).optional(),
|
|
78
|
+
appTitle: z.string().min(1).optional(),
|
|
79
|
+
});
|
|
80
|
+
/** Keep in sync with PlanAuditState in types.ts. 'warn' is the non-blocking
|
|
81
|
+
* tier (confidence-tiered redesign, 2026-07-08) — persisted literally so a
|
|
82
|
+
* warned phase records its notes and renders distinctly from a clean pass. */
|
|
83
|
+
export const PLAN_AUDIT_STATES = [
|
|
84
|
+
'pending', 'passed', 'warn', 'failed', 'infra_failed', 'waived', 'not_audited_hand_edited',
|
|
85
|
+
];
|
|
86
|
+
/**
|
|
87
|
+
* Reasons a chain stop can record on content.lastStop. Keep in sync with
|
|
88
|
+
* PlanContent['lastStop']['reason'] in types.ts (same single-source pattern
|
|
89
|
+
* as PLAN_AUDIT_STATES/PlanAuditState — the zod enum below derives from this).
|
|
90
|
+
*/
|
|
91
|
+
export const PLAN_STOP_REASONS = [
|
|
92
|
+
'write-phase', 'audit-failed', 'audit-infra', 'deviation',
|
|
93
|
+
];
|
|
94
|
+
/**
|
|
95
|
+
* §10 Q2 (audit-confidence tiered redesign, 2026-07-08): the light category a
|
|
96
|
+
* human waive records so the audit's false-positive rate is measurable. Keep in
|
|
97
|
+
* sync with PlanWaiveCategory in types.ts (the zod enum below derives from it).
|
|
98
|
+
*/
|
|
99
|
+
export const PLAN_WAIVE_CATEGORIES = ['false-positive', 'accepted-risk'];
|
|
100
|
+
const planPhaseAuditSchema = z.object({
|
|
101
|
+
state: z.enum(PLAN_AUDIT_STATES),
|
|
102
|
+
findings: z.array(z.string()).optional(),
|
|
103
|
+
waivedReason: z.string().optional(),
|
|
104
|
+
// §10 Q2 (2026-07-08): light waive category (see PlanPhaseAudit in types.ts).
|
|
105
|
+
// Must be listed here or z.object strips it on the next parse — losing the
|
|
106
|
+
// datum the audit FP-rate query depends on. Optional for back-compat: legacy
|
|
107
|
+
// waivers carry no category and must still parse.
|
|
108
|
+
waiveCategory: z.enum(PLAN_WAIVE_CATEGORIES).optional(),
|
|
109
|
+
at: z.string().optional(),
|
|
110
|
+
// Task 6 (2026-07-03): evidence-session pointer + consecutive-failure
|
|
111
|
+
// counter (see PlanPhaseAudit in types.ts). Both MUST be in the schema —
|
|
112
|
+
// z.object strips unknown keys, so an unlisted field would be silently
|
|
113
|
+
// dropped on the next parse and the counter/evidence pointer lost.
|
|
114
|
+
sessionId: z.string().optional(),
|
|
115
|
+
// D-B review item 1 (2026-07-05): attempt-window cutoff for re-audits (see
|
|
116
|
+
// PlanPhaseAudit in types.ts). Must be in the schema or z.object strips it.
|
|
117
|
+
attemptStartedAt: z.string().optional(),
|
|
118
|
+
consecutiveFailures: z.number().int().nonnegative().optional(),
|
|
119
|
+
});
|
|
120
|
+
/** Fail-safe write-ness: a phase that doesn't declare is treated as writing. */
|
|
121
|
+
export function phaseWrites(phase) {
|
|
122
|
+
return phase.writes !== false;
|
|
123
|
+
}
|
|
60
124
|
const planPhaseSchema = z.object({
|
|
61
125
|
id: z.string().min(1),
|
|
62
126
|
// C2 (D29): optional short human name for the tracker board; old envelopes
|
|
@@ -69,6 +133,9 @@ const planPhaseSchema = z.object({
|
|
|
69
133
|
manifest: planPhaseManifestSchema,
|
|
70
134
|
exitGate: z.string().min(1),
|
|
71
135
|
approval: z.boolean().optional(),
|
|
136
|
+
writes: z.boolean().optional(),
|
|
137
|
+
ui: planPhaseUiSchema.optional(),
|
|
138
|
+
audit: planPhaseAuditSchema.optional(),
|
|
72
139
|
work: planPhaseWorkSchema.optional(),
|
|
73
140
|
});
|
|
74
141
|
/**
|
|
@@ -140,6 +207,12 @@ export const planContentSchema = z
|
|
|
140
207
|
blocked: z.number().int().nonnegative(),
|
|
141
208
|
next: z.string().nullable(),
|
|
142
209
|
}),
|
|
210
|
+
lastStop: z.object({
|
|
211
|
+
phaseId: z.string().min(1),
|
|
212
|
+
reason: z.enum(PLAN_STOP_REASONS),
|
|
213
|
+
detail: z.string().optional(),
|
|
214
|
+
at: z.string(),
|
|
215
|
+
}).optional(),
|
|
143
216
|
})
|
|
144
217
|
.superRefine((content, ctx) => {
|
|
145
218
|
// mode:'revision' requires a revision target map (the discovered live stack).
|