@cspeach/cli 0.8.0 → 1.0.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 (141) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +330 -36
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/steering-queue.js +27 -0
  7. package/dist/agent/tool-dispatch.js +15 -0
  8. package/dist/approvals/canonical.js +91 -0
  9. package/dist/approvals/jwt.js +84 -7
  10. package/dist/approvals/render.js +38 -0
  11. package/dist/auth/org-anthropic-key.js +25 -0
  12. package/dist/classifier/client.js +24 -5
  13. package/dist/commands/config-set.js +95 -0
  14. package/dist/commands/login.js +31 -14
  15. package/dist/commands/plan-model-tier.js +83 -0
  16. package/dist/commands/plan-resume.js +435 -0
  17. package/dist/config/loader.js +102 -3
  18. package/dist/cost/pricing.js +14 -5
  19. package/dist/doctor/checks/_http-probe.js +1 -0
  20. package/dist/doctor/checks/cert.js +14 -3
  21. package/dist/doctor/checks/sap.js +30 -8
  22. package/dist/doctor/checks/zcspeach.js +19 -4
  23. package/dist/one-shot.js +52 -4
  24. package/dist/projects/answer-blockers.js +137 -0
  25. package/dist/projects/email-template.js +2 -0
  26. package/dist/projects/extract-cca.js +108 -16
  27. package/dist/projects/extract-modernize.js +1 -1
  28. package/dist/projects/extract-plan.js +178 -0
  29. package/dist/projects/extract-spec-gap.js +34 -7
  30. package/dist/projects/extract-test-coverage.js +1 -1
  31. package/dist/projects/extract-upgrade.js +113 -22
  32. package/dist/projects/index.js +7 -1
  33. package/dist/projects/merge-cca.js +292 -0
  34. package/dist/projects/merge-upgrade.js +173 -0
  35. package/dist/projects/migration.js +103 -1
  36. package/dist/projects/output-paths.js +27 -0
  37. package/dist/projects/plan-run.js +254 -0
  38. package/dist/projects/plan-schema.js +210 -0
  39. package/dist/projects/promote-command.js +26 -2
  40. package/dist/projects/promote.js +128 -0
  41. package/dist/projects/save-command.js +263 -20
  42. package/dist/projects/status.js +22 -0
  43. package/dist/projects/validate.js +2 -0
  44. package/dist/projects/workspace.js +164 -20
  45. package/dist/renderer/notices.js +64 -0
  46. package/dist/renderer/progress-chatter.js +8 -0
  47. package/dist/renderer/syntax.js +16 -1
  48. package/dist/renderer/thinking-heartbeat.js +13 -1
  49. package/dist/renderer/tool-widget.js +18 -4
  50. package/dist/renderer/tty.js +43 -4
  51. package/dist/renderer/verify-chain.js +77 -0
  52. package/dist/repl/at-picker.js +93 -21
  53. package/dist/repl/builtin-commands.js +37 -0
  54. package/dist/repl/early-line-buffer.js +68 -0
  55. package/dist/repl/inquirer-guard.js +70 -5
  56. package/dist/repl/numbered-menu.js +131 -0
  57. package/dist/repl/post-turn-status.js +2 -2
  58. package/dist/repl/rule8-detector.js +17 -2
  59. package/dist/repl/safety-confirm.js +111 -2
  60. package/dist/repl/safety-mode-state.js +19 -3
  61. package/dist/repl/slash-picker.js +25 -19
  62. package/dist/repl.js +470 -22
  63. package/dist/router/classifier.js +150 -6
  64. package/dist/sap/capability-matrix.js +20 -0
  65. package/dist/sap/capability-matrix.json +11236 -0
  66. package/dist/sap/capability.js +146 -0
  67. package/dist/sap/connection-manager.js +19 -1
  68. package/dist/sap/onboarding.js +42 -4
  69. package/dist/session/pending.js +27 -0
  70. package/dist/skill-catalog.js +54 -43
  71. package/dist/skills/bundled-skills.js +279 -1
  72. package/dist/skills/promotion-dispatch.js +23 -0
  73. package/dist/tools/_command-shared.js +36 -12
  74. package/dist/tools/_filesystem-shared.js +139 -4
  75. package/dist/tools/_flag.js +25 -0
  76. package/dist/tools/approval.js +64 -21
  77. package/dist/tools/ask-question.js +96 -4
  78. package/dist/tools/capability/tool.js +74 -0
  79. package/dist/tools/dispatch-skill.js +22 -1
  80. package/dist/tools/extend-model/anchored-insert.js +810 -0
  81. package/dist/tools/extend-model/tool.js +188 -0
  82. package/dist/tools/filesystem/extract-document.js +57 -0
  83. package/dist/tools/filesystem/file-edit.js +12 -2
  84. package/dist/tools/filesystem/file-read.js +2 -2
  85. package/dist/tools/filesystem/file-write.js +11 -2
  86. package/dist/tools/filesystem/glob.js +11 -0
  87. package/dist/tools/filesystem/grep.js +10 -0
  88. package/dist/tools/filesystem/read-document.js +107 -0
  89. package/dist/tools/fiori/apply.js +50 -0
  90. package/dist/tools/fiori/bin.js +3 -0
  91. package/dist/tools/fiori/catalog/index.js +27 -0
  92. package/dist/tools/fiori/catalog/value-help.js +230 -0
  93. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  94. package/dist/tools/fiori/cli.js +71 -0
  95. package/dist/tools/fiori/deploy-config.js +73 -0
  96. package/dist/tools/fiori/fe-scaffold.js +45 -0
  97. package/dist/tools/fiori/i18n.js +39 -0
  98. package/dist/tools/fiori/manifest.js +70 -0
  99. package/dist/tools/fiori/render.js +77 -0
  100. package/dist/tools/fiori/scaffold.js +39 -0
  101. package/dist/tools/fiori/tools.js +356 -0
  102. package/dist/tools/fiori/types.js +1 -0
  103. package/dist/tools/local-build.js +76 -0
  104. package/dist/tools/local-files.js +31 -0
  105. package/dist/tools/project/_merge-shared.js +68 -0
  106. package/dist/tools/project/cca_merge.js +164 -0
  107. package/dist/tools/project/playbook_get.js +1 -1
  108. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  109. package/dist/tools/sap-read.js +53 -9
  110. package/dist/tools/sap-write.js +530 -21
  111. package/dist/tools/shell/shell_exec.js +41 -6
  112. package/dist/tools/snapshot.js +37 -14
  113. package/dist/tools/subagent/background_run.js +17 -1
  114. package/dist/tools/transport-resolution.js +86 -0
  115. package/dist/tools/transport.js +224 -5
  116. package/dist/tools/write-mode.js +4 -0
  117. package/dist/ui/app.js +84 -11
  118. package/dist/ui/body.js +13 -0
  119. package/dist/ui/command-palette.js +46 -10
  120. package/dist/ui/file-palette.js +44 -0
  121. package/dist/ui/footer.js +28 -11
  122. package/dist/ui/line-resolution.js +92 -0
  123. package/dist/ui/session-timeline.js +1 -0
  124. package/dist/ui/text-input.js +150 -0
  125. package/dist/ui/turn-status-emitter.js +52 -0
  126. package/dist/ui/turn-status.js +59 -0
  127. package/dist/ui/widgets/ask-question-modal.js +30 -2
  128. package/package.json +23 -4
  129. package/bench/README.md +0 -78
  130. package/bench/prompts/abap-document-cds.md +0 -44
  131. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  132. package/bench/prompts/abap-test-method.md +0 -42
  133. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  134. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  135. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  136. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  137. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  138. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  139. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  140. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  141. package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
@@ -1,3 +1,5 @@
1
+ import { readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
1
3
  import { extractSpecGap } from './extract-spec-gap.js';
2
4
  import { extractDesign } from './extract-design.js';
3
5
  import { extractEstimate } from './extract-estimate.js';
@@ -5,10 +7,15 @@ import { extractUpgradeScan, extractUpgradeProgress, extractUpgradeReport } from
5
7
  import { extractCcaAssessment } from './extract-cca.js';
6
8
  import { extractModernizeResult } from './extract-modernize.js';
7
9
  import { extractTestCoverage } from './extract-test-coverage.js';
10
+ import { extractPlan } from './extract-plan.js';
8
11
  import { buildEnvelope } from './build.js';
12
+ import { buildPlanRevision } from './plan-run.js';
13
+ import { parsePlanContent } from './plan-schema.js';
14
+ import { validateEnvelope } from './validate.js';
15
+ import { readProjectFile } from './status.js';
9
16
  import { saveProject } from './save.js';
10
17
  import { renderEmailTemplate } from './email-template.js';
11
- import { ensureWorkspace } from './workspace.js';
18
+ import { ensureWorkspace, resolveWorkspacePath } from './workspace.js';
12
19
  const SKILL_REGISTRY = {
13
20
  'abap-spec-gap': {
14
21
  artefactType: 'spec-gap',
@@ -29,16 +36,19 @@ const SKILL_REGISTRY = {
29
36
  artefactType: 'upgrade-scan',
30
37
  extract: (md) => extractUpgradeScan(md),
31
38
  itemNoun: 'findings',
39
+ manifestMarker: 'csforge:upgrade-manifest',
32
40
  },
33
41
  'abap-upgrade-fix': {
34
42
  artefactType: 'upgrade-progress',
35
43
  extract: (md) => extractUpgradeProgress(md),
36
44
  itemNoun: 'fixes',
45
+ manifestMarker: 'csforge:upgrade-manifest',
37
46
  },
38
47
  'abap-upgrade-verify': {
39
48
  artefactType: 'upgrade-report',
40
49
  extract: (md) => extractUpgradeReport(md),
41
50
  itemNoun: 'regressions',
51
+ manifestMarker: 'csforge:upgrade-manifest',
42
52
  },
43
53
  'abap-upgrade-merge': {
44
54
  // Merge produces a NEW upgrade-progress (consolidated) — same shape
@@ -46,11 +56,13 @@ const SKILL_REGISTRY = {
46
56
  artefactType: 'upgrade-progress',
47
57
  extract: (md) => extractUpgradeProgress(md),
48
58
  itemNoun: 'fixes',
59
+ manifestMarker: 'csforge:upgrade-manifest',
49
60
  },
50
61
  'abap-cca': {
51
62
  artefactType: 'cca-assessment',
52
63
  extract: (md) => extractCcaAssessment(md),
53
64
  itemNoun: 'classifications',
65
+ manifestMarker: 'csforge:cca-manifest',
54
66
  },
55
67
  'abap-cca-merge': {
56
68
  // Merge produces a NEW cca-assessment (consolidated) — same shape as
@@ -58,49 +70,277 @@ const SKILL_REGISTRY = {
58
70
  artefactType: 'cca-assessment',
59
71
  extract: (md) => extractCcaAssessment(md),
60
72
  itemNoun: 'classifications',
73
+ manifestMarker: 'csforge:cca-manifest',
61
74
  },
62
75
  'abap-modernize': {
63
76
  artefactType: 'modernize-result',
64
77
  extract: (md) => extractModernizeResult(md),
65
78
  itemNoun: 'modernizations',
79
+ manifestMarker: 'csforge:modernize-manifest',
66
80
  },
67
81
  'abap-test': {
68
82
  artefactType: 'test-coverage',
69
83
  extract: (md) => extractTestCoverage(md),
70
84
  itemNoun: 'tests',
85
+ manifestMarker: 'csforge:test-coverage-manifest',
86
+ },
87
+ 'abap-plan': {
88
+ // 2026-06-06 B3: Mode 1 (create) save. Mode 2 (--resume) turns
89
+ // suppress this hook (RunTurnParams.suppressSaveHook) and persist a
90
+ // REVISION of the existing envelope via commands/plan-resume.ts.
91
+ // C1 (2026-06-11, D30): this generic path ALSO builds a revision when a
92
+ // prior envelope with the same title exists in the workspace — a steered
93
+ // "save the project file" must never fork a new identity off an existing
94
+ // version chain (the 49dd duplicate). The dispatch in runSaveCommand
95
+ // special-cases artefactType 'plan'; this `extract` is the no-prior
96
+ // fallback shape only.
97
+ artefactType: 'plan',
98
+ extract: (md) => extractPlan(md),
99
+ itemNoun: 'phases',
100
+ manifestMarker: 'csforge:plan-manifest',
71
101
  },
72
102
  };
103
+ /**
104
+ * C1 (2026-06-11, defect D22) — map an inline artifact-manifest block in the
105
+ * assistant output to the SKILL_REGISTRY key that knows how to extract it.
106
+ * Used by the post-turn save hook (agent/loop.ts:maybeOfferSave) as the
107
+ * fallback trigger when the routed-skill LABEL would not fire: a misrouted
108
+ * turn that still emitted a recognizable manifest must not silently lose the
109
+ * artifact. Returns null when no recognizable manifest is present (prose-only
110
+ * artefacts — spec-gap / design / estimate — have no inline marker and stay
111
+ * label-triggered).
112
+ *
113
+ * The upgrade marker is shared by three artefact shapes; the `artefact:` line
114
+ * inside the block disambiguates which extractor (registry skill) applies.
115
+ */
116
+ export function detectArtifactSkill(markdown) {
117
+ if (/<!--\s*csforge:plan-manifest\b/.test(markdown))
118
+ return 'abap-plan';
119
+ if (/<!--\s*csforge:cca-manifest\b/.test(markdown))
120
+ return 'abap-cca';
121
+ if (/<!--\s*csforge:modernize-manifest\b/.test(markdown))
122
+ return 'abap-modernize';
123
+ if (/<!--\s*csforge:test-coverage-manifest\b/.test(markdown))
124
+ return 'abap-test';
125
+ const up = /<!--\s*csforge:upgrade-manifest\s*\n([\s\S]*?)\n\s*-->/.exec(markdown);
126
+ if (up) {
127
+ const art = /(?:^|\n)\s*artefact:\s*(\S+)/.exec(up[1] ?? '');
128
+ switch (art?.[1]) {
129
+ case 'upgrade-scan': return 'abap-upgrade-scan';
130
+ case 'upgrade-progress': return 'abap-upgrade-fix';
131
+ case 'upgrade-report': return 'abap-upgrade-verify';
132
+ default: return null; // marker present, artefact unknown — extractor would reject it anyway
133
+ }
134
+ }
135
+ return null;
136
+ }
137
+ // Same block grammar as extract-plan.ts:MANIFEST_RE (last block wins).
138
+ const PLAN_MANIFEST_RE = /<!--\s*csforge:plan-manifest\s*\n([\s\S]*?)\n\s*-->/g;
139
+ /**
140
+ * Cheap title peek into the LAST plan-manifest block — needed BEFORE the full
141
+ * extract because compact manifests can only be expanded against the prior
142
+ * envelope, and the prior envelope is found by title. Returns null on any
143
+ * malformation (the full extract then reports the precise error).
144
+ */
145
+ export function peekPlanManifestTitle(markdown) {
146
+ const re = new RegExp(PLAN_MANIFEST_RE.source, PLAN_MANIFEST_RE.flags);
147
+ let m;
148
+ let last = null;
149
+ while ((m = re.exec(markdown)) !== null)
150
+ last = m;
151
+ if (!last)
152
+ return null;
153
+ try {
154
+ const parsed = JSON.parse(last[1]);
155
+ return typeof parsed.title === 'string' && parsed.title.trim().length > 0
156
+ ? parsed.title.trim()
157
+ : null;
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ }
163
+ /**
164
+ * C1 (2026-06-11, defect D30 — the 49dd duplicate) — find the existing plan
165
+ * envelope a generic save should REVISE instead of forking a new identity.
166
+ *
167
+ * Match key: artefactType 'plan' + same title (trimmed, case-insensitive —
168
+ * the title is model-emitted and may drift in casing). Among matches the
169
+ * HIGHEST version wins (lastEditedAt breaks ties); when a past duplicate
170
+ * already split the family into two ids, this picks the longer (real) chain,
171
+ * so subsequent saves heal onto it instead of extending the fork.
172
+ *
173
+ * Non-recursive scan of `dir` (the workspace folder where saves land);
174
+ * unreadable/invalid files are skipped — resolution is best-effort, never
175
+ * fatal to the save.
176
+ */
177
+ export function resolvePriorPlanEnvelope(title, dir) {
178
+ if (!title)
179
+ return null;
180
+ const want = title.trim().toLowerCase();
181
+ let names;
182
+ try {
183
+ names = readdirSync(dir);
184
+ }
185
+ catch {
186
+ return null;
187
+ }
188
+ let best = null;
189
+ for (const name of names) {
190
+ if (!name.endsWith('.cspeach.json'))
191
+ continue;
192
+ const full = join(dir, name);
193
+ let env;
194
+ try {
195
+ env = readProjectFile(full);
196
+ }
197
+ catch {
198
+ continue;
199
+ }
200
+ if (env.artefactType !== 'plan')
201
+ continue;
202
+ if (env.title.trim().toLowerCase() !== want)
203
+ continue;
204
+ if (!best
205
+ || env.version > best.envelope.version
206
+ || (env.version === best.envelope.version && env.lastEditedAt > best.envelope.lastEditedAt)) {
207
+ best = { path: full, envelope: env };
208
+ }
209
+ }
210
+ return best;
211
+ }
73
212
  export async function runSaveCommand(args) {
74
213
  const spec = SKILL_REGISTRY[args.skillName];
75
214
  if (!spec)
76
215
  return null;
216
+ // C1 (D30): a plan save must continue an existing version chain when one is
217
+ // resolvable. The prior is found by manifest title BEFORE extraction, both
218
+ // to decide new-vs-revision and because a COMPACT manifest (statuses +
219
+ // changed map) can only be expanded against the prior content. The scan is
220
+ // strictly READ-ONLY (resolveWorkspacePath, not ensureWorkspace): nothing
221
+ // before the user's confirm may mkdir the workspace or touch .gitignore as
222
+ // a side effect — a missing workspace dir simply means no prior
223
+ // (resolvePriorPlanEnvelope treats an unreadable dir as "no matches").
224
+ let prior = null;
225
+ let priorContent;
226
+ if (spec.artefactType === 'plan') {
227
+ const peekedTitle = peekPlanManifestTitle(args.skillOutput);
228
+ if (peekedTitle) {
229
+ let scanDir;
230
+ try {
231
+ scanDir = await resolveWorkspacePath();
232
+ }
233
+ catch {
234
+ scanDir = args.cwd;
235
+ }
236
+ prior = resolvePriorPlanEnvelope(peekedTitle, scanDir);
237
+ if (prior) {
238
+ const pc = parsePlanContent(prior.envelope.content);
239
+ if (pc.ok) {
240
+ priorContent = pc.content;
241
+ }
242
+ else {
243
+ // Prior exists but its content no longer parses — revising it would
244
+ // propagate corruption. Fall back to a fresh envelope and say so.
245
+ args.log(`[save] existing plan '${prior.envelope.title}' has invalid content (${pc.errors[0] ?? 'schema error'}) — saving a NEW envelope instead of a revision.`);
246
+ prior = null;
247
+ }
248
+ }
249
+ }
250
+ }
77
251
  let extract;
252
+ let planExtract = null;
78
253
  try {
79
- extract = spec.extract(args.skillOutput);
254
+ if (spec.artefactType === 'plan') {
255
+ planExtract = extractPlan(args.skillOutput, priorContent);
256
+ extract = planExtract;
257
+ }
258
+ else {
259
+ extract = spec.extract(args.skillOutput);
260
+ }
80
261
  }
81
- catch {
262
+ catch (e) {
263
+ // C1 residual D22 — the label IS in the registry but WRONG for the output
264
+ // (e.g. a plan manifest under an 'abap-design' label): the label's
265
+ // extractor throws, and bailing here would silently lose an artifact that
266
+ // carries a complete, recognizable manifest. If the manifest resolves to
267
+ // a DIFFERENT registry skill, retry the save under that skill. Skipped
268
+ // when both skills share the same manifest-marker family (abap-cca-merge
269
+ // ↔ abap-cca, abap-upgrade-merge ↔ abap-upgrade-fix): the family
270
+ // extractor already ran and failed, and re-dispatching would only launder
271
+ // merge provenance onto the non-merge skill — keep the label's failure.
272
+ const detected = detectArtifactSkill(args.skillOutput);
273
+ if (detected && detected !== args.skillName) {
274
+ const detectedSpec = SKILL_REGISTRY[detected];
275
+ const sameFamily = detectedSpec?.manifestMarker !== undefined
276
+ && detectedSpec.manifestMarker === spec.manifestMarker;
277
+ if (detectedSpec && !sameFamily) {
278
+ args.log(`[save] output is a ${detected} artifact, not ${args.skillName} — saving as ${detected}`);
279
+ // Recursion is bounded: if the retry's extract fails too, the
280
+ // re-detected skill equals args.skillName and we fall through to the
281
+ // loud-failure path below instead of recursing again.
282
+ return runSaveCommand({ ...args, skillName: detected });
283
+ }
284
+ }
285
+ if (spec.manifestMarker && args.skillOutput.includes(spec.manifestMarker)) {
286
+ args.log('');
287
+ args.log(`[save] ${args.skillName}: a ${spec.manifestMarker} block IS present but failed to parse — artefact NOT saved.`);
288
+ args.log(`[save] ${e instanceof Error ? e.message : String(e)}`);
289
+ args.log(`[save] The output above is otherwise complete — re-run the skill or report this as a manifest bug.`);
290
+ }
82
291
  return null;
83
292
  }
293
+ // C1 review fix — name the revision target BEFORE the confirm so the user
294
+ // knows what a 'y' will do. Log line only, deliberately NOT a second
295
+ // question: the B5 headless contract (below) allows at most one prompt.
296
+ if (planExtract && prior) {
297
+ const priorFilename = prior.path.split(/[\\\/]/).pop();
298
+ args.log(`[save] this will save as v${prior.envelope.version + 1} of existing plan '${prior.envelope.title}' (${priorFilename})`);
299
+ }
300
+ // CONTRACT (B5): runSaveCommand may ask the user at most ONE question —
301
+ // this save confirm. The headless path in loop.ts (maybeOfferSave) installs
302
+ // a prompt that blanket-answers 'y' to WHATEVER it is asked; any second
303
+ // question added here would silently receive 'y' too. If you need another
304
+ // prompt, give maybeOfferSave's headless prompt a question-aware answer
305
+ // first.
84
306
  const answer = (await args.prompt('Save as project file? [y/N]: ')).trim().toLowerCase();
85
307
  if (answer !== 'y' && answer !== 'yes')
86
308
  return null;
87
- const env = buildEnvelope({
88
- artefactType: spec.artefactType,
89
- extract,
90
- author: args.author,
91
- source: {
92
- skill: args.skillName,
93
- skillVersion: args.skillVersion,
94
- input: args.skillInput,
95
- tokensUsed: args.tokensUsed,
96
- model: args.model,
97
- },
98
- promotedFrom: args.promotedFrom ?? null,
99
- });
100
- // Save into the workspace folder (~/CSPeach by default), not cwd. Falls
101
- // back to args.cwd if the workspace can't be created (e.g. permission
102
- // error on the home directory) — saving SOMEWHERE is better than losing
103
- // the artefact.
309
+ let env;
310
+ if (planExtract && prior) {
311
+ // C1 (D30) — the 49dd duplicate: the generic save used to mint a fresh
312
+ // envelope id even when the same plan already lived in the workspace,
313
+ // forking the version chain. With a resolvable prior, the save IS a
314
+ // revision: same id, version+1, history appended (buildPlanRevision —
315
+ // the exact path /abap-plan --resume persistence uses).
316
+ env = buildPlanRevision(prior.envelope, planExtract, args.author, new Date().toISOString());
317
+ // JSON round-trip simulates disk serialization for the validator.
318
+ const check = validateEnvelope(JSON.parse(JSON.stringify(env)));
319
+ if (!check.ok) {
320
+ args.log(`[save] plan revision failed validation (${check.error.message}) — artefact NOT saved.`);
321
+ return null;
322
+ }
323
+ }
324
+ else {
325
+ env = buildEnvelope({
326
+ artefactType: spec.artefactType,
327
+ extract,
328
+ author: args.author,
329
+ source: {
330
+ skill: args.skillName,
331
+ skillVersion: args.skillVersion,
332
+ input: args.skillInput,
333
+ tokensUsed: args.tokensUsed,
334
+ model: args.model,
335
+ },
336
+ promotedFrom: args.promotedFrom ?? null,
337
+ });
338
+ }
339
+ // Saves land in the workspace folder, not cwd — created only NOW, after
340
+ // the user confirmed (declined saves and conversational turns leave no
341
+ // mkdir/.gitignore side effects). Falls back to args.cwd if the workspace
342
+ // can't be created (e.g. permission error on the home directory) — saving
343
+ // SOMEWHERE is better than losing the artefact.
104
344
  let outDir;
105
345
  try {
106
346
  outDir = await ensureWorkspace();
@@ -110,6 +350,9 @@ export async function runSaveCommand(args) {
110
350
  }
111
351
  const path = await saveProject(env, { cwd: outDir });
112
352
  args.log(`Saved: ${path}`);
353
+ if (planExtract && prior) {
354
+ args.log(`(revision v${env.version} of the existing plan — version chain preserved, no new id)`);
355
+ }
113
356
  args.log(`Share at: https://cspeach.dev/edit`);
114
357
  args.log('');
115
358
  args.log(renderEmailTemplate({
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { validateEnvelope } from './validate.js';
3
3
  import { canonicalSha256 } from './canonicalize.js';
4
+ import { statusesFromItems, computeNextPhase, renderPlanTracker } from './plan-run.js';
4
5
  export function readProjectFile(path) {
5
6
  const raw = readFileSync(path, 'utf8');
6
7
  let parsed;
@@ -22,6 +23,7 @@ const STATUS_BY_TYPE = {
22
23
  'estimate': ['confirmed', 'disputed', 'deferred'],
23
24
  'cca': ['keep', 'fix', 'retire', 'redesign'],
24
25
  'incident': ['pending', 'done'],
26
+ 'plan': ['validated', 'validated-with-waiver', 'todo', 'designing', 'building', 'verifying', 'blocked'],
25
27
  };
26
28
  const PRIMARY_BY_TYPE = {
27
29
  'spec-gap': 'answered',
@@ -29,6 +31,7 @@ const PRIMARY_BY_TYPE = {
29
31
  'estimate': 'confirmed',
30
32
  'cca': 'keep',
31
33
  'incident': 'done',
34
+ 'plan': 'validated',
32
35
  };
33
36
  const PROMOTE_HINT_BY_TYPE = {
34
37
  'spec-gap': '/abap-design --from',
@@ -36,6 +39,7 @@ const PROMOTE_HINT_BY_TYPE = {
36
39
  'estimate': null,
37
40
  'cca': null,
38
41
  'incident': null,
42
+ 'plan': '/abap-plan --resume',
39
43
  };
40
44
  export function renderStatus(env) {
41
45
  const items = env.interaction.items;
@@ -85,6 +89,22 @@ export function renderStatus(env) {
85
89
  lines.push(` ... ${stillOpen.length - 10} more`);
86
90
  lines.push('');
87
91
  }
92
+ // B3 (2026-06-06): plan envelopes get the phase tracker board — the same
93
+ // renderer the --resume flow prints before/after each phase. Header off:
94
+ // title + version were already printed above.
95
+ if (env.artefactType === 'plan') {
96
+ const content = env.content;
97
+ const statuses = statusesFromItems(items, content.phases);
98
+ lines.push(renderPlanTracker({
99
+ title: env.title,
100
+ version: env.version,
101
+ content,
102
+ statuses,
103
+ currentId: computeNextPhase(content.phases, statuses)?.id ?? null,
104
+ includeHeader: false,
105
+ }));
106
+ lines.push('');
107
+ }
88
108
  // Promote hint fires whenever a "next phase" exists for this artefactType.
89
109
  // Unlike the v1 implementation we no longer gate on `primaryCount > 0` —
90
110
  // a design with zero `keep` items (all `redesign` / `defer`) is still a
@@ -100,6 +120,8 @@ export function renderStatus(env) {
100
120
  function displayStatus(s) {
101
121
  if (s === 'n-a')
102
122
  return 'n/a';
123
+ if (s === 'validated-with-waiver')
124
+ return 'validated* (waived)';
103
125
  return s;
104
126
  }
105
127
  function formatDate(iso) {
@@ -5,6 +5,7 @@ const KNOWN_ARTEFACT_TYPES = [
5
5
  'upgrade-scan', 'upgrade-progress', 'upgrade-report',
6
6
  'cca-assessment',
7
7
  'modernize-result', 'test-coverage',
8
+ 'plan',
8
9
  ];
9
10
  const STATUS_BY_TYPE = {
10
11
  'spec-gap': ['open', 'answered', 'deferred', 'n-a'],
@@ -18,6 +19,7 @@ const STATUS_BY_TYPE = {
18
19
  'cca-assessment': ['open', 'reviewed', 'classified', 'archived'],
19
20
  'modernize-result': ['applied', 'skipped', 'failed', 'pending'],
20
21
  'test-coverage': ['generated', 'skipped', 'failed', 'pending'],
22
+ 'plan': ['todo', 'designing', 'building', 'verifying', 'validated', 'validated-with-waiver', 'blocked'],
21
23
  };
22
24
  const REQUIRED_TOP_LEVEL = [
23
25
  'schemaVersion', 'artefactType', 'id', 'title',