@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.
Files changed (100) hide show
  1. package/dist/agent/loop.js +22 -9
  2. package/dist/approvals/op-labels.js +124 -0
  3. package/dist/approvals/render.js +42 -36
  4. package/dist/cli.js +15 -0
  5. package/dist/commands/compact.js +28 -2
  6. package/dist/commands/config-set.js +189 -0
  7. package/dist/commands/config-show.js +20 -0
  8. package/dist/commands/export-audit.js +43 -0
  9. package/dist/commands/help.js +5 -0
  10. package/dist/commands/plan-audit-evidence.js +266 -0
  11. package/dist/commands/plan-audit.js +692 -0
  12. package/dist/commands/plan-chain.js +671 -0
  13. package/dist/commands/plan-continue.js +179 -0
  14. package/dist/commands/plan-gate.js +154 -0
  15. package/dist/commands/plan-resume.js +588 -33
  16. package/dist/config/loader.js +128 -4
  17. package/dist/config/model-defaults.js +14 -0
  18. package/dist/cost/pricing.js +27 -1
  19. package/dist/doctor/checks/system-roles.js +41 -0
  20. package/dist/doctor/run.js +2 -0
  21. package/dist/models/resolve.js +61 -0
  22. package/dist/models/server-config.js +155 -0
  23. package/dist/one-shot.js +25 -3
  24. package/dist/projects/extract-cca.js +3 -1
  25. package/dist/projects/extract-modernize.js +3 -1
  26. package/dist/projects/extract-plan.js +60 -6
  27. package/dist/projects/extract-test-coverage.js +3 -1
  28. package/dist/projects/extract-upgrade.js +3 -1
  29. package/dist/projects/handover-md.js +195 -0
  30. package/dist/projects/index.js +1 -1
  31. package/dist/projects/plan-run.js +137 -13
  32. package/dist/projects/plan-schema.js +73 -0
  33. package/dist/projects/run-lease.js +157 -0
  34. package/dist/projects/save-command.js +26 -15
  35. package/dist/renderer/status-footer.js +22 -12
  36. package/dist/renderer/thinking-heartbeat.js +64 -8
  37. package/dist/renderer/todo-block.js +51 -0
  38. package/dist/renderer/tool-widget.js +37 -0
  39. package/dist/repl/bracketed-paste.js +28 -19
  40. package/dist/repl/builtin-commands.js +5 -0
  41. package/dist/repl/current-transport.js +10 -0
  42. package/dist/repl/history.js +86 -0
  43. package/dist/repl/ink-stdin-guard.js +64 -0
  44. package/dist/repl/mode-ceiling.js +16 -0
  45. package/dist/repl/mode-cycle.js +104 -0
  46. package/dist/repl/post-turn-status.js +24 -4
  47. package/dist/repl/slash-completer.js +5 -0
  48. package/dist/repl.js +954 -83
  49. package/dist/rewind/candidates.js +194 -0
  50. package/dist/rewind/cli.js +137 -0
  51. package/dist/rewind/format.js +27 -0
  52. package/dist/rewind/restore.js +245 -0
  53. package/dist/session/audit-export.js +459 -0
  54. package/dist/session/context-report.js +163 -0
  55. package/dist/session/recap.js +160 -0
  56. package/dist/skill-catalog.js +9 -3
  57. package/dist/skills/bundled-skills.js +71 -78
  58. package/dist/tools/approval.js +115 -7
  59. package/dist/tools/ask-question.js +304 -3
  60. package/dist/tools/extend-model/anchored-insert.js +604 -0
  61. package/dist/tools/extend-model/tool.js +162 -10
  62. package/dist/tools/fiori/fe-extend.js +76 -0
  63. package/dist/tools/fiori/fe-scaffold.js +29 -3
  64. package/dist/tools/fiori/floorplan-map.js +19 -0
  65. package/dist/tools/fiori/samples/data/index.json +13602 -0
  66. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  67. package/dist/tools/fiori/samples/loader.js +248 -0
  68. package/dist/tools/fiori/samples/search.js +63 -0
  69. package/dist/tools/fiori/samples/types.js +2 -0
  70. package/dist/tools/fiori/smoke/assertions.js +74 -0
  71. package/dist/tools/fiori/smoke/browser.js +52 -0
  72. package/dist/tools/fiori/smoke/driver.js +89 -0
  73. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  74. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  75. package/dist/tools/fiori/tools.js +328 -3
  76. package/dist/tools/local-build.js +11 -1
  77. package/dist/tools/sap-read.js +79 -11
  78. package/dist/tools/sap-write.js +24 -4
  79. package/dist/tools/snapshot.js +27 -1
  80. package/dist/tools/subagent/agent_run.js +27 -3
  81. package/dist/tools/todo.js +144 -0
  82. package/dist/ui/app.js +372 -19
  83. package/dist/ui/approval-modal.js +49 -16
  84. package/dist/ui/ask-question-emitter.js +14 -0
  85. package/dist/ui/context-grid.js +108 -0
  86. package/dist/ui/footer.js +109 -30
  87. package/dist/ui/header.js +7 -0
  88. package/dist/ui/line-resolution.js +18 -2
  89. package/dist/ui/rewind-emitter.js +10 -0
  90. package/dist/ui/rewind-panel.js +81 -0
  91. package/dist/ui/sap-state-store.js +1 -0
  92. package/dist/ui/status-line.js +43 -0
  93. package/dist/ui/text-input.js +72 -8
  94. package/dist/ui/todo-emitter.js +25 -0
  95. package/dist/ui/todo-panel.js +64 -0
  96. package/dist/ui/turn-status-emitter.js +50 -4
  97. package/dist/ui/turn-status.js +18 -3
  98. package/dist/ui/widgets/ask-form.js +242 -0
  99. package/dist/ui/widgets/ask-question-modal.js +17 -7
  100. 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
- const MANIFEST_RE = /<!--\s*csforge:plan-manifest\s*\n([\s\S]*?)\n\s*-->/g;
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
- validated: phases.filter((p) => isPhaseSatisfied(statuses[p.id])).length,
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; ignored for the full shape (seed
90
- * sessions pass nothing).
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
- for (const p of rawPhases)
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
- const MANIFEST_RE = /<!--\s*csforge:test-coverage-manifest\s*\n([\s\S]*?)\n\s*-->/;
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
- const MANIFEST_RE = /<!--\s*csforge:upgrade-manifest\s*\n([\s\S]*?)\n\s*-->/;
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
+ }
@@ -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
- const validated = a.content.phases.filter((p) => isPhaseSatisfied(a.statuses[p.id])).length;
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 = diffs.length > 0 ? diffs.join('; ') : 'resume run — no phase status change';
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).