@cspeach/cli 0.9.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +228 -26
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/tool-dispatch.js +15 -0
  7. package/dist/approvals/canonical.js +91 -0
  8. package/dist/approvals/jwt.js +39 -2
  9. package/dist/approvals/op-labels.js +124 -0
  10. package/dist/approvals/render.js +42 -36
  11. package/dist/auth/org-anthropic-key.js +25 -0
  12. package/dist/classifier/client.js +18 -3
  13. package/dist/cli.js +15 -0
  14. package/dist/commands/compact.js +28 -2
  15. package/dist/commands/config-set.js +284 -0
  16. package/dist/commands/config-show.js +20 -0
  17. package/dist/commands/export-audit.js +43 -0
  18. package/dist/commands/help.js +5 -0
  19. package/dist/commands/login.js +31 -14
  20. package/dist/commands/plan-audit-evidence.js +266 -0
  21. package/dist/commands/plan-audit.js +692 -0
  22. package/dist/commands/plan-chain.js +671 -0
  23. package/dist/commands/plan-continue.js +179 -0
  24. package/dist/commands/plan-gate.js +154 -0
  25. package/dist/commands/plan-model-tier.js +83 -0
  26. package/dist/commands/plan-resume.js +728 -46
  27. package/dist/config/loader.js +223 -5
  28. package/dist/config/model-defaults.js +14 -0
  29. package/dist/cost/pricing.js +27 -1
  30. package/dist/doctor/checks/_http-probe.js +1 -0
  31. package/dist/doctor/checks/cert.js +14 -3
  32. package/dist/doctor/checks/sap.js +30 -8
  33. package/dist/doctor/checks/system-roles.js +41 -0
  34. package/dist/doctor/checks/zcspeach.js +19 -4
  35. package/dist/doctor/run.js +2 -0
  36. package/dist/models/resolve.js +61 -0
  37. package/dist/models/server-config.js +155 -0
  38. package/dist/one-shot.js +76 -6
  39. package/dist/projects/answer-blockers.js +137 -0
  40. package/dist/projects/extract-cca.js +111 -17
  41. package/dist/projects/extract-modernize.js +4 -2
  42. package/dist/projects/extract-plan.js +184 -37
  43. package/dist/projects/extract-spec-gap.js +34 -7
  44. package/dist/projects/extract-test-coverage.js +4 -2
  45. package/dist/projects/extract-upgrade.js +116 -23
  46. package/dist/projects/handover-md.js +195 -0
  47. package/dist/projects/index.js +5 -2
  48. package/dist/projects/merge-cca.js +292 -0
  49. package/dist/projects/merge-upgrade.js +173 -0
  50. package/dist/projects/migration.js +103 -1
  51. package/dist/projects/output-paths.js +27 -0
  52. package/dist/projects/plan-run.js +285 -27
  53. package/dist/projects/plan-schema.js +136 -3
  54. package/dist/projects/promote-command.js +25 -2
  55. package/dist/projects/promote.js +128 -0
  56. package/dist/projects/run-lease.js +157 -0
  57. package/dist/projects/save-command.js +259 -21
  58. package/dist/projects/status.js +3 -1
  59. package/dist/projects/validate.js +1 -1
  60. package/dist/projects/workspace.js +164 -20
  61. package/dist/renderer/notices.js +64 -0
  62. package/dist/renderer/progress-chatter.js +8 -0
  63. package/dist/renderer/status-footer.js +22 -12
  64. package/dist/renderer/thinking-heartbeat.js +64 -8
  65. package/dist/renderer/todo-block.js +51 -0
  66. package/dist/renderer/tool-widget.js +55 -4
  67. package/dist/renderer/tty.js +43 -4
  68. package/dist/renderer/verify-chain.js +77 -0
  69. package/dist/repl/at-picker.js +60 -7
  70. package/dist/repl/bracketed-paste.js +28 -19
  71. package/dist/repl/builtin-commands.js +42 -0
  72. package/dist/repl/current-transport.js +10 -0
  73. package/dist/repl/early-line-buffer.js +68 -0
  74. package/dist/repl/history.js +86 -0
  75. package/dist/repl/ink-stdin-guard.js +64 -0
  76. package/dist/repl/inquirer-guard.js +70 -5
  77. package/dist/repl/mode-ceiling.js +16 -0
  78. package/dist/repl/mode-cycle.js +104 -0
  79. package/dist/repl/numbered-menu.js +131 -0
  80. package/dist/repl/post-turn-status.js +26 -6
  81. package/dist/repl/rule8-detector.js +17 -2
  82. package/dist/repl/safety-confirm.js +111 -2
  83. package/dist/repl/safety-mode-state.js +19 -3
  84. package/dist/repl/slash-completer.js +5 -0
  85. package/dist/repl/slash-picker.js +10 -15
  86. package/dist/repl.js +1232 -95
  87. package/dist/rewind/candidates.js +194 -0
  88. package/dist/rewind/cli.js +137 -0
  89. package/dist/rewind/format.js +27 -0
  90. package/dist/rewind/restore.js +245 -0
  91. package/dist/router/classifier.js +150 -6
  92. package/dist/sap/capability-matrix.js +20 -0
  93. package/dist/sap/capability-matrix.json +11236 -0
  94. package/dist/sap/capability.js +146 -0
  95. package/dist/sap/connection-manager.js +19 -1
  96. package/dist/sap/onboarding.js +42 -4
  97. package/dist/session/audit-export.js +459 -0
  98. package/dist/session/context-report.js +163 -0
  99. package/dist/session/pending.js +27 -0
  100. package/dist/session/recap.js +160 -0
  101. package/dist/skill-catalog.js +51 -40
  102. package/dist/skills/bundled-skills.js +272 -1
  103. package/dist/skills/promotion-dispatch.js +23 -0
  104. package/dist/tools/_command-shared.js +36 -12
  105. package/dist/tools/_filesystem-shared.js +139 -4
  106. package/dist/tools/_flag.js +25 -0
  107. package/dist/tools/approval.js +177 -26
  108. package/dist/tools/ask-question.js +400 -7
  109. package/dist/tools/capability/tool.js +74 -0
  110. package/dist/tools/dispatch-skill.js +22 -1
  111. package/dist/tools/extend-model/anchored-insert.js +1414 -0
  112. package/dist/tools/extend-model/tool.js +340 -0
  113. package/dist/tools/filesystem/extract-document.js +57 -0
  114. package/dist/tools/filesystem/file-edit.js +12 -2
  115. package/dist/tools/filesystem/file-read.js +2 -2
  116. package/dist/tools/filesystem/file-write.js +11 -2
  117. package/dist/tools/filesystem/glob.js +11 -0
  118. package/dist/tools/filesystem/grep.js +10 -0
  119. package/dist/tools/filesystem/read-document.js +107 -0
  120. package/dist/tools/fiori/apply.js +50 -0
  121. package/dist/tools/fiori/bin.js +3 -0
  122. package/dist/tools/fiori/catalog/index.js +27 -0
  123. package/dist/tools/fiori/catalog/value-help.js +230 -0
  124. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  125. package/dist/tools/fiori/cli.js +71 -0
  126. package/dist/tools/fiori/deploy-config.js +73 -0
  127. package/dist/tools/fiori/fe-extend.js +76 -0
  128. package/dist/tools/fiori/fe-scaffold.js +71 -0
  129. package/dist/tools/fiori/floorplan-map.js +19 -0
  130. package/dist/tools/fiori/i18n.js +39 -0
  131. package/dist/tools/fiori/manifest.js +70 -0
  132. package/dist/tools/fiori/render.js +77 -0
  133. package/dist/tools/fiori/samples/data/index.json +13602 -0
  134. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  135. package/dist/tools/fiori/samples/loader.js +248 -0
  136. package/dist/tools/fiori/samples/search.js +63 -0
  137. package/dist/tools/fiori/samples/types.js +2 -0
  138. package/dist/tools/fiori/scaffold.js +39 -0
  139. package/dist/tools/fiori/smoke/assertions.js +74 -0
  140. package/dist/tools/fiori/smoke/browser.js +52 -0
  141. package/dist/tools/fiori/smoke/driver.js +89 -0
  142. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  143. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  144. package/dist/tools/fiori/tools.js +681 -0
  145. package/dist/tools/fiori/types.js +1 -0
  146. package/dist/tools/local-build.js +86 -0
  147. package/dist/tools/local-files.js +31 -0
  148. package/dist/tools/project/_merge-shared.js +68 -0
  149. package/dist/tools/project/cca_merge.js +164 -0
  150. package/dist/tools/project/playbook_get.js +1 -1
  151. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  152. package/dist/tools/sap-read.js +132 -20
  153. package/dist/tools/sap-write.js +550 -21
  154. package/dist/tools/shell/shell_exec.js +41 -6
  155. package/dist/tools/snapshot.js +63 -14
  156. package/dist/tools/subagent/agent_run.js +27 -3
  157. package/dist/tools/subagent/background_run.js +17 -1
  158. package/dist/tools/todo.js +144 -0
  159. package/dist/tools/transport-resolution.js +86 -0
  160. package/dist/tools/transport.js +224 -5
  161. package/dist/tools/write-mode.js +4 -0
  162. package/dist/ui/app.js +378 -21
  163. package/dist/ui/approval-modal.js +49 -16
  164. package/dist/ui/ask-question-emitter.js +14 -0
  165. package/dist/ui/body.js +13 -0
  166. package/dist/ui/context-grid.js +108 -0
  167. package/dist/ui/footer.js +120 -27
  168. package/dist/ui/header.js +7 -0
  169. package/dist/ui/line-resolution.js +35 -8
  170. package/dist/ui/rewind-emitter.js +10 -0
  171. package/dist/ui/rewind-panel.js +81 -0
  172. package/dist/ui/sap-state-store.js +1 -0
  173. package/dist/ui/session-timeline.js +1 -0
  174. package/dist/ui/status-line.js +43 -0
  175. package/dist/ui/text-input.js +214 -0
  176. package/dist/ui/todo-emitter.js +25 -0
  177. package/dist/ui/todo-panel.js +64 -0
  178. package/dist/ui/turn-status-emitter.js +50 -4
  179. package/dist/ui/turn-status.js +18 -3
  180. package/dist/ui/widgets/ask-form.js +242 -0
  181. package/dist/ui/widgets/ask-question-modal.js +21 -8
  182. package/package.json +22 -3
  183. package/bench/README.md +0 -78
  184. package/bench/prompts/abap-document-cds.md +0 -44
  185. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  186. package/bench/prompts/abap-test-method.md +0 -42
  187. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  188. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  189. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  190. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  191. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  192. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  193. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  194. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  195. package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
@@ -24,11 +24,12 @@
24
24
  * The prompt is interactive (via the supplied async prompt callback);
25
25
  * one-shot mode skips it entirely (caller passes a no-op).
26
26
  */
27
- import { existsSync, mkdirSync, readdirSync, renameSync } from 'node:fs';
27
+ import { existsSync, mkdirSync, readdirSync, renameSync, rmdirSync, statSync } from 'node:fs';
28
28
  import { join } from 'node:path';
29
29
  import os from 'node:os';
30
30
  import { loadConfig, saveConfig } from '../config/loader.js';
31
31
  import { defaultWorkspaceFor } from './workspace.js';
32
+ import { OUTPUT_ROOT, LEGACY_ROOT } from './output-paths.js';
32
33
  /**
33
34
  * Run the migration prompt if and only if it hasn't run before AND
34
35
  * there's actually something to migrate. No-op otherwise. Always sets
@@ -109,3 +110,104 @@ export async function maybePromptWorkspaceMigration(deps) {
109
110
  }
110
111
  await saveConfig({ ...cfg, migration_prompted: true });
111
112
  }
113
+ /**
114
+ * Recursively move every entry under `srcDir` into `dstDir`, MERGING into
115
+ * existing directories rather than clobbering them. Files that already exist
116
+ * at the destination are left untouched (the legacy copy is dropped — the
117
+ * dual-read already prefers the new root, so an existing new file wins).
118
+ * After the walk, the source directory is removed if empty.
119
+ */
120
+ function mergeMoveTree(srcDir, dstDir, log) {
121
+ mkdirSync(dstDir, { recursive: true });
122
+ for (const name of readdirSync(srcDir)) {
123
+ const src = join(srcDir, name);
124
+ const dst = join(dstDir, name);
125
+ const isDir = statSync(src).isDirectory();
126
+ if (isDir) {
127
+ mergeMoveTree(src, dst, log);
128
+ }
129
+ else if (existsSync(dst)) {
130
+ log(`(keeping existing ${name} — already migrated)`);
131
+ }
132
+ else {
133
+ renameSync(src, dst);
134
+ }
135
+ }
136
+ // Remove the source dir if the walk emptied it. A merge that kept some
137
+ // existing-destination files may leave it non-empty — rmdir throws then,
138
+ // which we swallow (the leftover is harmless and visible to the user).
139
+ try {
140
+ rmdirSync(srcDir);
141
+ }
142
+ catch {
143
+ /* non-empty after a partial merge — leave it for the user to inspect */
144
+ }
145
+ }
146
+ /**
147
+ * One-time offer to migrate a workspace's legacy `.abapforge/` output root
148
+ * into the branded `.cspeach/` root.
149
+ *
150
+ * Fires only when a top-level `.abapforge/` directory exists in the given
151
+ * workspace root AND the user has not previously chosen "never". Safe to call
152
+ * every startup: a successful move removes the folder (so it stops naturally),
153
+ * and the dual-read resolver keeps old projects working in the interim if the
154
+ * user declines.
155
+ *
156
+ * Idempotent and non-destructive: the move MERGES into any existing
157
+ * `.cspeach/<domain>` rather than overwriting it.
158
+ */
159
+ export async function maybeOfferAbapforgeMigration(workspaceRoot, deps) {
160
+ const legacyDir = join(workspaceRoot, LEGACY_ROOT);
161
+ if (!existsSync(legacyDir))
162
+ return;
163
+ const cfg = await loadConfig();
164
+ if (cfg.abapforge_migration_declined === true)
165
+ return;
166
+ const newDir = join(workspaceRoot, OUTPUT_ROOT);
167
+ let choice;
168
+ try {
169
+ choice = await deps.prompt({ workspaceRoot, legacyDir, newDir });
170
+ }
171
+ catch {
172
+ // User aborted the prompt (Ctrl+C) — leave everything as-is so the
173
+ // offer can re-fire next session. Never silently commit to a default.
174
+ return;
175
+ }
176
+ if (choice === 'never') {
177
+ await saveConfig({ ...cfg, abapforge_migration_declined: true });
178
+ deps.log(`Leaving ${legacyDir} alone. (Will not ask again.)`);
179
+ return;
180
+ }
181
+ if (choice === 'skip') {
182
+ deps.log(`Leaving ${legacyDir} alone for now.`);
183
+ return;
184
+ }
185
+ // choice === 'move'
186
+ try {
187
+ for (const child of readdirSync(legacyDir)) {
188
+ const src = join(legacyDir, child);
189
+ const dst = join(newDir, child);
190
+ if (statSync(src).isDirectory()) {
191
+ mergeMoveTree(src, dst, deps.log);
192
+ }
193
+ else if (existsSync(dst)) {
194
+ deps.log(`(keeping existing ${child} — already migrated)`);
195
+ }
196
+ else {
197
+ mkdirSync(newDir, { recursive: true });
198
+ renameSync(src, dst);
199
+ }
200
+ }
201
+ // Drop the now-(hopefully)-empty legacy root.
202
+ try {
203
+ rmdirSync(legacyDir);
204
+ }
205
+ catch {
206
+ /* a partial merge left files behind — keep the folder for inspection */
207
+ }
208
+ deps.log(`Moved legacy outputs from ${legacyDir} → ${newDir}`);
209
+ }
210
+ catch (err) {
211
+ deps.log(`Migration failed: ${err.message}. Old folder left untouched.`);
212
+ }
213
+ }
@@ -0,0 +1,27 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ /** Branded output root for customer working files (was .abapforge). */
4
+ export const OUTPUT_ROOT = '.cspeach';
5
+ /** Pre-2026-06 root — still READ for back-compat, never written anew. */
6
+ export const LEGACY_ROOT = '.abapforge';
7
+ /** Build `<projectRoot>/.cspeach/<domain>` (domain = cca | upgrades | modernize | tests). */
8
+ export function domainDir(projectRoot, domain) {
9
+ return join(projectRoot, OUTPUT_ROOT, domain);
10
+ }
11
+ /**
12
+ * Resolve a project-relative detail path to an absolute path, preferring the
13
+ * new root and falling back to the legacy `.abapforge/` location when the new
14
+ * one is absent. Returns the NEW absolute path when neither exists (the caller
15
+ * is about to create it). `relPath` may be expressed against either root — we
16
+ * normalise the leading segment before probing.
17
+ */
18
+ export function resolveExistingDomainPath(projectRoot, relPath) {
19
+ const tail = relPath.replace(/^\.(cspeach|abapforge)[/\\]/, '');
20
+ const asNew = join(projectRoot, OUTPUT_ROOT, tail);
21
+ if (existsSync(asNew))
22
+ return asNew;
23
+ const asLegacy = join(projectRoot, LEGACY_ROOT, tail);
24
+ if (existsSync(asLegacy))
25
+ return asLegacy;
26
+ return asNew;
27
+ }
@@ -1,5 +1,53 @@
1
+ // cspeach-cli/src/projects/plan-run.ts
2
+ //
3
+ // Pure plan-execution helpers for /abap-plan --resume (B3, 2026-06-06).
4
+ // No I/O here — file reads, prompts, and the runTurn call live in
5
+ // commands/plan-resume.ts. Keeping these pure makes the next-phase
6
+ // picker, the tracker renderer, and the revision builder unit-testable
7
+ // without touching disk.
8
+ import chalk from 'chalk';
1
9
  /** Statuses a dead session may have left behind — resumable, picked before fresh `todo`s. */
2
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
+ }
37
+ /**
38
+ * C1 (2026-06-11, D30 waiver) — a phase counts as DONE for DAG purposes when
39
+ * it is 'validated' OR 'validated-with-waiver' (exit gate not met, but the
40
+ * user explicitly waived it). Single predicate so eligibility, summary
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.
47
+ */
48
+ export function isPhaseSatisfied(s, audit) {
49
+ return (s === 'validated' || s === 'validated-with-waiver') && isAuditResolved(audit);
50
+ }
3
51
  /**
4
52
  * Phase status lives in interaction.items (store convention). Build the
5
53
  * id→status map; a phase with no matching item defaults to 'todo' rather
@@ -21,13 +69,16 @@ export function statusesFromItems(items, phases) {
21
69
  * re-entered from the top, gates and grounding included.
22
70
  */
23
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]));
24
75
  for (const p of phases) {
25
76
  const s = statuses[p.id] ?? 'todo';
26
- if (s === 'validated' || s === 'blocked')
77
+ if (isPhaseSatisfied(s, p.audit) || s === 'blocked')
27
78
  continue;
28
79
  if (s !== 'todo' && !IN_PROGRESS.includes(s))
29
80
  continue;
30
- if (p.entryCriteria.every((dep) => statuses[dep] === 'validated'))
81
+ if (p.entryCriteria.every((dep) => isPhaseSatisfied(statuses[dep], byId.get(dep)?.audit)))
31
82
  return p;
32
83
  }
33
84
  return null;
@@ -42,41 +93,240 @@ export function computeNextPhase(phases, statuses) {
42
93
  * ▶ c1.orchestration abap-generate ← THIS SESSION
43
94
  * ○ c1.integration abap-generate (needs c1.orchestration)
44
95
  */
96
+ // Human phase labels — a phase is named for the KIND of work it does, so a
97
+ // non-ABAP reader can follow the plan. The exit-gate / produced objects carry
98
+ // the specifics on the detail line.
99
+ const LAYER_LABELS = {
100
+ 'types': 'Types',
101
+ 'persistence': 'Tables & structures',
102
+ 'data-model': 'CDS views',
103
+ 'behavior': 'Behaviour & validations',
104
+ 'service': 'Service binding',
105
+ 'orchestration': 'Jobs & reports',
106
+ 'integration': 'Integration',
107
+ 'ui': 'UI',
108
+ };
109
+ const DELEGATE_LABELS = {
110
+ 'abap-design': 'Design',
111
+ 'abap-test': 'Tests',
112
+ 'abap-eml': 'Behaviour logic',
113
+ 'abap-segw': 'OData (SEGW)',
114
+ };
115
+ function phaseTitle(p) {
116
+ // C2 (2026-06-11, D29): an explicit seed-time title wins — the seed prose
117
+ // instructs short human names ("Design", "Tables", "Service"). The label
118
+ // maps remain the fallback for envelopes seeded before titles existed.
119
+ const explicit = p.title?.trim();
120
+ if (explicit)
121
+ return explicit;
122
+ return DELEGATE_LABELS[p.delegateTo] ?? LAYER_LABELS[p.layer] ?? tidyId(p.id);
123
+ }
124
+ function tidyId(id) {
125
+ const tail = id.split('.').pop() ?? id;
126
+ return tail.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
127
+ }
128
+ // A short plain-language hint from the exit gate: the first clause, clamped.
129
+ function shortGate(gate, n = 60) {
130
+ const first = String(gate ?? '').split(/[.;]/)[0].trim();
131
+ const s = first.length >= 10 ? first : String(gate ?? '').trim();
132
+ return s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s;
133
+ }
134
+ // The detail line: what a done phase produced, else what the phase is about.
135
+ function phaseDetail(p, s) {
136
+ if (isPhaseSatisfied(s)) {
137
+ const objs = (p.work?.generated ?? []).join(', ');
138
+ return objs || shortGate(p.exitGate);
139
+ }
140
+ return shortGate(p.exitGate);
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
+ }
169
+ /**
170
+ * Plan-progress board — a Claude-Code-style checkbox task list. Each phase is
171
+ * named for the work it does (not its mechanical id), so the reader can follow
172
+ * along; ☒ = done (dim), ☐ = to do, the active phase is bold with a `◀ now`
173
+ * marker, blocked is red. Flows through chunkEmitter (Ink) and console.log
174
+ * (classic) alike — chalk degrades to plain text on a non-colour stream.
175
+ */
45
176
  export function renderPlanTracker(a) {
177
+ const PEACH = '#F5A623';
46
178
  const lines = [];
47
179
  const total = a.content.phases.length;
48
- const validated = a.content.phases.filter((p) => a.statuses[p.id] === 'validated').length;
180
+ // Waived phases count as done in the header tally — the DAG treats them as
181
+ // satisfied; the per-line "✓* (waived)" marker carries the distinction.
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;
49
186
  if (a.includeHeader !== false) {
50
- lines.push(`─ Plan: ${a.title} (v${a.version}) ── ${validated}/${total} validated ─`);
187
+ lines.push(chalk.dim(`─ ${a.title} (v${a.version}) · ${validated}/${total} done ─`));
188
+ lines.push('');
51
189
  }
52
- const idWidth = Math.max(...a.content.phases.map((p) => p.id.length));
53
- const skillWidth = Math.max(...a.content.phases.map((p) => p.delegateTo.length));
54
190
  for (const p of a.content.phases) {
55
191
  const s = a.statuses[p.id] ?? 'todo';
56
192
  const isCurrent = a.currentId != null && p.id === a.currentId;
57
- const symbol = isCurrent ? '▶'
58
- : s === 'validated' ? '✔'
59
- : s === 'blocked' ? '✖'
60
- : IN_PROGRESS.includes(s) ? '◌'
61
- : '○';
62
- lines.push(` ${symbol} ${p.id.padEnd(idWidth)} ${p.delegateTo.padEnd(skillWidth)} ${annotationFor(p, s, isCurrent, a.statuses)}`.trimEnd());
193
+ const title = phaseTitle(p);
194
+ const detail = phaseDetail(p, s);
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);
199
+ if (s === 'validated') {
200
+ lines.push(chalk.dim(` ☒ ${title}${dash}`) + audit);
201
+ }
202
+ else if (s === 'validated-with-waiver') {
203
+ // C1 (D30 waiver) — done-but-waived: the exit gate was never met; the
204
+ // user accepted it anyway. Distinct marker so a tracker reader can tell
205
+ // a verified phase from a waived one at a glance.
206
+ lines.push(chalk.dim(` ☒ ${title}${dash}`) + ` ${chalk.yellow('✓* (waived)')}` + audit);
207
+ }
208
+ else if (s === 'blocked') {
209
+ lines.push(` ${chalk.red('☐')} ${chalk.red(title)} ${chalk.red('— blocked')}${detail ? chalk.dim(` · ${detail}`) : ''}${audit}`);
210
+ }
211
+ else if (isCurrent) {
212
+ lines.push(` ${chalk.hex(PEACH)('☐')} ${chalk.bold(title)}${dash}${audit} ${chalk.hex(PEACH).bold('◀ now')}`);
213
+ }
214
+ else if (IN_PROGRESS.includes(s)) {
215
+ lines.push(` ${chalk.hex(PEACH)('☐')} ${chalk.bold(title)}${dash}${audit} ${chalk.dim(`(${s})`)}`);
216
+ }
217
+ else {
218
+ lines.push(` ☐ ${title}${chalk.dim(dash)}${audit}`);
219
+ }
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'));
63
226
  }
64
227
  return lines.join('\n');
65
228
  }
66
- function annotationFor(p, s, isCurrent, statuses) {
67
- if (isCurrent)
68
- return '← THIS SESSION';
69
- if (s === 'validated') {
70
- const objs = p.work?.generated?.join(', ') ?? '';
71
- const tr = p.work?.transport ? ` (${p.work.transport})` : '';
72
- return objs || tr ? `→ ${objs}${tr}`.trimEnd() : '';
229
+ /* ── plan-complete chaining (C2, 2026-06-11, D24) ───────────────────────── */
230
+ /**
231
+ * SRVB-shaped object name. Two naming families seen live + documented in
232
+ * .claude/rules/rap-patterns.md:
233
+ * - ADT default OData suffix: ZUI_PM_MAINTREQ_O4 / ..._O2
234
+ * - rap-patterns convention: ZSalesOrder_UI_V4 / _UI_V2 / _API_V4 / _API_V2
235
+ */
236
+ const SRVB_NAME_RE = /^[zy]\w*(?:_o[24]|_(?:ui|api)_v[24])$/i;
237
+ /**
238
+ * The exact service-binding name a completed plan produced, for the
239
+ * /abap-fiori-build hand-off. Resolution ladder (newest phase wins at
240
+ * each rung — a structurally-revised plan may carry several):
241
+ * 1. explicit `work.binding` (the seed/resume prose instructs the service
242
+ * phase to record it)
243
+ * 2. an SRVB-shaped name in a service-layer phase's `work.generated`
244
+ * 3. an SRVB-shaped name anywhere in `work.generated` (binding built
245
+ * outside a service-layer phase)
246
+ * Returns null when nothing matches — the caller still mentions
247
+ * /abap-fiori-build generically.
248
+ */
249
+ export function findServiceBinding(phases) {
250
+ for (let i = phases.length - 1; i >= 0; i--) {
251
+ const explicit = phases[i].work?.binding?.trim();
252
+ if (explicit)
253
+ return explicit;
254
+ }
255
+ const scan = (ps) => {
256
+ for (let i = ps.length - 1; i >= 0; i--) {
257
+ const hit = (ps[i].work?.generated ?? []).find((n) => SRVB_NAME_RE.test(n.trim()));
258
+ if (hit)
259
+ return hit.trim();
260
+ }
261
+ return null;
262
+ };
263
+ return scan(phases.filter((p) => p.layer === 'service')) ?? scan(phases);
264
+ }
265
+ /**
266
+ * The lines printed when a plan has no next phase because EVERY phase is
267
+ * satisfied. Single construction site for both call points (preparePlanResume
268
+ * re-opening a finished plan, finishPlanResume completing the last phase) so
269
+ * the D24 fiori chain can never drift between them.
270
+ *
271
+ * A UI-less backend stack (service layer present, no ui phase) chains to
272
+ * /abap-fiori-build — with the EXACT binding name when resolvable. A plan
273
+ * that already built its UI, or never built a service, gets no chain line.
274
+ */
275
+ export function planCompletionLines(phases) {
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.`);
73
289
  }
74
- if (s === 'blocked')
75
- return 'BLOCKED';
76
- if (IN_PROGRESS.includes(s))
77
- return `in progress (${s})`;
78
- const unmet = p.entryCriteria.filter((dep) => statuses[dep] !== 'validated');
79
- return unmet.length > 0 ? `(needs ${unmet.join(', ')})` : '';
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
+ }
313
+ const hasUi = phases.some((p) => p.layer === 'ui' || p.delegateTo === 'abap-fiori-build');
314
+ const hasService = phases.some((p) => p.layer === 'service');
315
+ if (!hasUi && hasService) {
316
+ const binding = findServiceBinding(phases);
317
+ lines.push(binding
318
+ ? `Backend complete. Next: /abap-fiori-build — point it at ${binding}.`
319
+ : 'Backend complete. Next: /abap-fiori-build — point it at the published service binding.');
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
+ }
328
+ lines.push('Consider /abap-preflight on the produced transport(s) before release.');
329
+ return lines;
80
330
  }
81
331
  /**
82
332
  * Build the version-N+1 envelope from the original plan envelope and the
@@ -90,7 +340,14 @@ function annotationFor(p, s, isCurrent, statuses) {
90
340
  * - item comments / answers added via the editor survive the revision —
91
341
  * only the status comes from the new extract
92
342
  */
93
- 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) {
94
351
  if (original.artefactType !== 'plan') {
95
352
  throw new Error(`buildPlanRevision expects a plan envelope, got ${original.artefactType}`);
96
353
  }
@@ -107,7 +364,8 @@ export function buildPlanRevision(original, extract, author, nowIso) {
107
364
  if (before !== after)
108
365
  diffs.push(`${p.id}: ${before} → ${after}`);
109
366
  }
110
- 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');
111
369
  return {
112
370
  ...original,
113
371
  lastEditedAt: nowIso,