@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
@@ -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';
@@ -7,9 +9,14 @@ import { extractModernizeResult } from './extract-modernize.js';
7
9
  import { extractTestCoverage } from './extract-test-coverage.js';
8
10
  import { extractPlan } from './extract-plan.js';
9
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';
10
16
  import { saveProject } from './save.js';
11
17
  import { renderEmailTemplate } from './email-template.js';
12
- import { ensureWorkspace } from './workspace.js';
18
+ import { writePlanHandover } from './handover-md.js';
19
+ import { ensureWorkspace, resolveWorkspacePath } from './workspace.js';
13
20
  const SKILL_REGISTRY = {
14
21
  'abap-spec-gap': {
15
22
  artefactType: 'spec-gap',
@@ -30,16 +37,19 @@ const SKILL_REGISTRY = {
30
37
  artefactType: 'upgrade-scan',
31
38
  extract: (md) => extractUpgradeScan(md),
32
39
  itemNoun: 'findings',
40
+ manifestMarker: 'upgrade-manifest',
33
41
  },
34
42
  'abap-upgrade-fix': {
35
43
  artefactType: 'upgrade-progress',
36
44
  extract: (md) => extractUpgradeProgress(md),
37
45
  itemNoun: 'fixes',
46
+ manifestMarker: 'upgrade-manifest',
38
47
  },
39
48
  'abap-upgrade-verify': {
40
49
  artefactType: 'upgrade-report',
41
50
  extract: (md) => extractUpgradeReport(md),
42
51
  itemNoun: 'regressions',
52
+ manifestMarker: 'upgrade-manifest',
43
53
  },
44
54
  'abap-upgrade-merge': {
45
55
  // Merge produces a NEW upgrade-progress (consolidated) — same shape
@@ -47,11 +57,13 @@ const SKILL_REGISTRY = {
47
57
  artefactType: 'upgrade-progress',
48
58
  extract: (md) => extractUpgradeProgress(md),
49
59
  itemNoun: 'fixes',
60
+ manifestMarker: 'upgrade-manifest',
50
61
  },
51
62
  'abap-cca': {
52
63
  artefactType: 'cca-assessment',
53
64
  extract: (md) => extractCcaAssessment(md),
54
65
  itemNoun: 'classifications',
66
+ manifestMarker: 'cca-manifest',
55
67
  },
56
68
  'abap-cca-merge': {
57
69
  // Merge produces a NEW cca-assessment (consolidated) — same shape as
@@ -59,36 +71,221 @@ const SKILL_REGISTRY = {
59
71
  artefactType: 'cca-assessment',
60
72
  extract: (md) => extractCcaAssessment(md),
61
73
  itemNoun: 'classifications',
74
+ manifestMarker: 'cca-manifest',
62
75
  },
63
76
  'abap-modernize': {
64
77
  artefactType: 'modernize-result',
65
78
  extract: (md) => extractModernizeResult(md),
66
79
  itemNoun: 'modernizations',
80
+ manifestMarker: 'modernize-manifest',
67
81
  },
68
82
  'abap-test': {
69
83
  artefactType: 'test-coverage',
70
84
  extract: (md) => extractTestCoverage(md),
71
85
  itemNoun: 'tests',
86
+ manifestMarker: 'test-coverage-manifest',
72
87
  },
73
88
  'abap-plan': {
74
- // 2026-06-06 B3: Mode 1 (create) save only. Mode 2 (--resume) turns
89
+ // 2026-06-06 B3: Mode 1 (create) save. Mode 2 (--resume) turns
75
90
  // suppress this hook (RunTurnParams.suppressSaveHook) and persist a
76
91
  // REVISION of the existing envelope via commands/plan-resume.ts.
92
+ // C1 (2026-06-11, D30): this generic path ALSO builds a revision when a
93
+ // prior envelope with the same title exists in the workspace — a steered
94
+ // "save the project file" must never fork a new identity off an existing
95
+ // version chain (the 49dd duplicate). The dispatch in runSaveCommand
96
+ // special-cases artefactType 'plan'; this `extract` is the no-prior
97
+ // fallback shape only.
77
98
  artefactType: 'plan',
78
99
  extract: (md) => extractPlan(md),
79
100
  itemNoun: 'phases',
80
- manifestMarker: 'csforge:plan-manifest',
101
+ manifestMarker: 'plan-manifest',
81
102
  },
82
103
  };
104
+ /**
105
+ * C1 (2026-06-11, defect D22) — map an inline artifact-manifest block in the
106
+ * assistant output to the SKILL_REGISTRY key that knows how to extract it.
107
+ * Used by the post-turn save hook (agent/loop.ts:maybeOfferSave) as the
108
+ * fallback trigger when the routed-skill LABEL would not fire: a misrouted
109
+ * turn that still emitted a recognizable manifest must not silently lose the
110
+ * artifact. Returns null when no recognizable manifest is present (prose-only
111
+ * artefacts — spec-gap / design / estimate — have no inline marker and stay
112
+ * label-triggered).
113
+ *
114
+ * The upgrade marker is shared by three artefact shapes; the `artefact:` line
115
+ * inside the block disambiguates which extractor (registry skill) applies.
116
+ */
117
+ export function detectArtifactSkill(markdown) {
118
+ // E2 dual-read: accept both the legacy `csforge:` and current `cspeach:`
119
+ // marker prefixes (legacy acceptance is permanent).
120
+ if (/<!--\s*(?:csforge|cspeach):plan-manifest\b/.test(markdown))
121
+ return 'abap-plan';
122
+ if (/<!--\s*(?:csforge|cspeach):cca-manifest\b/.test(markdown))
123
+ return 'abap-cca';
124
+ if (/<!--\s*(?:csforge|cspeach):modernize-manifest\b/.test(markdown))
125
+ return 'abap-modernize';
126
+ if (/<!--\s*(?:csforge|cspeach):test-coverage-manifest\b/.test(markdown))
127
+ return 'abap-test';
128
+ const up = /<!--\s*(?:csforge|cspeach):upgrade-manifest\s*\n([\s\S]*?)\n\s*-->/.exec(markdown);
129
+ if (up) {
130
+ const art = /(?:^|\n)\s*artefact:\s*(\S+)/.exec(up[1] ?? '');
131
+ switch (art?.[1]) {
132
+ case 'upgrade-scan': return 'abap-upgrade-scan';
133
+ case 'upgrade-progress': return 'abap-upgrade-fix';
134
+ case 'upgrade-report': return 'abap-upgrade-verify';
135
+ default: return null; // marker present, artefact unknown — extractor would reject it anyway
136
+ }
137
+ }
138
+ return null;
139
+ }
140
+ // Same block grammar as extract-plan.ts:MANIFEST_RE (last block wins).
141
+ // E2 dual-read: accept both the legacy `csforge:` and current `cspeach:` prefixes.
142
+ const PLAN_MANIFEST_RE = /<!--\s*(?:csforge|cspeach):plan-manifest\s*\n([\s\S]*?)\n\s*-->/g;
143
+ /**
144
+ * Cheap title peek into the LAST plan-manifest block — needed BEFORE the full
145
+ * extract because compact manifests can only be expanded against the prior
146
+ * envelope, and the prior envelope is found by title. Returns null on any
147
+ * malformation (the full extract then reports the precise error).
148
+ */
149
+ export function peekPlanManifestTitle(markdown) {
150
+ const re = new RegExp(PLAN_MANIFEST_RE.source, PLAN_MANIFEST_RE.flags);
151
+ let m;
152
+ let last = null;
153
+ while ((m = re.exec(markdown)) !== null)
154
+ last = m;
155
+ if (!last)
156
+ return null;
157
+ try {
158
+ const parsed = JSON.parse(last[1]);
159
+ return typeof parsed.title === 'string' && parsed.title.trim().length > 0
160
+ ? parsed.title.trim()
161
+ : null;
162
+ }
163
+ catch {
164
+ return null;
165
+ }
166
+ }
167
+ /**
168
+ * C1 (2026-06-11, defect D30 — the 49dd duplicate) — find the existing plan
169
+ * envelope a generic save should REVISE instead of forking a new identity.
170
+ *
171
+ * Match key: artefactType 'plan' + same title (trimmed, case-insensitive —
172
+ * the title is model-emitted and may drift in casing). Among matches the
173
+ * HIGHEST version wins (lastEditedAt breaks ties); when a past duplicate
174
+ * already split the family into two ids, this picks the longer (real) chain,
175
+ * so subsequent saves heal onto it instead of extending the fork.
176
+ *
177
+ * Non-recursive scan of `dir` (the workspace folder where saves land);
178
+ * unreadable/invalid files are skipped — resolution is best-effort, never
179
+ * fatal to the save.
180
+ */
181
+ export function resolvePriorPlanEnvelope(title, dir) {
182
+ if (!title)
183
+ return null;
184
+ const want = title.trim().toLowerCase();
185
+ let names;
186
+ try {
187
+ names = readdirSync(dir);
188
+ }
189
+ catch {
190
+ return null;
191
+ }
192
+ let best = null;
193
+ for (const name of names) {
194
+ if (!name.endsWith('.cspeach.json'))
195
+ continue;
196
+ const full = join(dir, name);
197
+ let env;
198
+ try {
199
+ env = readProjectFile(full);
200
+ }
201
+ catch {
202
+ continue;
203
+ }
204
+ if (env.artefactType !== 'plan')
205
+ continue;
206
+ if (env.title.trim().toLowerCase() !== want)
207
+ continue;
208
+ if (!best
209
+ || env.version > best.envelope.version
210
+ || (env.version === best.envelope.version && env.lastEditedAt > best.envelope.lastEditedAt)) {
211
+ best = { path: full, envelope: env };
212
+ }
213
+ }
214
+ return best;
215
+ }
83
216
  export async function runSaveCommand(args) {
84
217
  const spec = SKILL_REGISTRY[args.skillName];
85
218
  if (!spec)
86
219
  return null;
220
+ // C1 (D30): a plan save must continue an existing version chain when one is
221
+ // resolvable. The prior is found by manifest title BEFORE extraction, both
222
+ // to decide new-vs-revision and because a COMPACT manifest (statuses +
223
+ // changed map) can only be expanded against the prior content. The scan is
224
+ // strictly READ-ONLY (resolveWorkspacePath, not ensureWorkspace): nothing
225
+ // before the user's confirm may mkdir the workspace or touch .gitignore as
226
+ // a side effect — a missing workspace dir simply means no prior
227
+ // (resolvePriorPlanEnvelope treats an unreadable dir as "no matches").
228
+ let prior = null;
229
+ let priorContent;
230
+ if (spec.artefactType === 'plan') {
231
+ const peekedTitle = peekPlanManifestTitle(args.skillOutput);
232
+ if (peekedTitle) {
233
+ let scanDir;
234
+ try {
235
+ scanDir = await resolveWorkspacePath();
236
+ }
237
+ catch {
238
+ scanDir = args.cwd;
239
+ }
240
+ prior = resolvePriorPlanEnvelope(peekedTitle, scanDir);
241
+ if (prior) {
242
+ const pc = parsePlanContent(prior.envelope.content);
243
+ if (pc.ok) {
244
+ priorContent = pc.content;
245
+ }
246
+ else {
247
+ // Prior exists but its content no longer parses — revising it would
248
+ // propagate corruption. Fall back to a fresh envelope and say so.
249
+ args.log(`[save] existing plan '${prior.envelope.title}' has invalid content (${pc.errors[0] ?? 'schema error'}) — saving a NEW envelope instead of a revision.`);
250
+ prior = null;
251
+ }
252
+ }
253
+ }
254
+ }
87
255
  let extract;
256
+ let planExtract = null;
88
257
  try {
89
- extract = spec.extract(args.skillOutput);
258
+ if (spec.artefactType === 'plan') {
259
+ planExtract = extractPlan(args.skillOutput, priorContent);
260
+ extract = planExtract;
261
+ }
262
+ else {
263
+ extract = spec.extract(args.skillOutput);
264
+ }
90
265
  }
91
266
  catch (e) {
267
+ // C1 residual D22 — the label IS in the registry but WRONG for the output
268
+ // (e.g. a plan manifest under an 'abap-design' label): the label's
269
+ // extractor throws, and bailing here would silently lose an artifact that
270
+ // carries a complete, recognizable manifest. If the manifest resolves to
271
+ // a DIFFERENT registry skill, retry the save under that skill. Skipped
272
+ // when both skills share the same manifest-marker family (abap-cca-merge
273
+ // ↔ abap-cca, abap-upgrade-merge ↔ abap-upgrade-fix): the family
274
+ // extractor already ran and failed, and re-dispatching would only launder
275
+ // merge provenance onto the non-merge skill — keep the label's failure.
276
+ const detected = detectArtifactSkill(args.skillOutput);
277
+ if (detected && detected !== args.skillName) {
278
+ const detectedSpec = SKILL_REGISTRY[detected];
279
+ const sameFamily = detectedSpec?.manifestMarker !== undefined
280
+ && detectedSpec.manifestMarker === spec.manifestMarker;
281
+ if (detectedSpec && !sameFamily) {
282
+ args.log(`[save] output is a ${detected} artifact, not ${args.skillName} — saving as ${detected}`);
283
+ // Recursion is bounded: if the retry's extract fails too, the
284
+ // re-detected skill equals args.skillName and we fall through to the
285
+ // loud-failure path below instead of recursing again.
286
+ return runSaveCommand({ ...args, skillName: detected });
287
+ }
288
+ }
92
289
  if (spec.manifestMarker && args.skillOutput.includes(spec.manifestMarker)) {
93
290
  args.log('');
94
291
  args.log(`[save] ${args.skillName}: a ${spec.manifestMarker} block IS present but failed to parse — artefact NOT saved.`);
@@ -97,26 +294,57 @@ export async function runSaveCommand(args) {
97
294
  }
98
295
  return null;
99
296
  }
297
+ // C1 review fix — name the revision target BEFORE the confirm so the user
298
+ // knows what a 'y' will do. Log line only, deliberately NOT a second
299
+ // question: the B5 headless contract (below) allows at most one prompt.
300
+ if (planExtract && prior) {
301
+ const priorFilename = prior.path.split(/[\\\/]/).pop();
302
+ args.log(`[save] this will save as v${prior.envelope.version + 1} of existing plan '${prior.envelope.title}' (${priorFilename})`);
303
+ }
304
+ // CONTRACT (B5): runSaveCommand may ask the user at most ONE question —
305
+ // this save confirm. The headless path in loop.ts (maybeOfferSave) installs
306
+ // a prompt that blanket-answers 'y' to WHATEVER it is asked; any second
307
+ // question added here would silently receive 'y' too. If you need another
308
+ // prompt, give maybeOfferSave's headless prompt a question-aware answer
309
+ // first.
100
310
  const answer = (await args.prompt('Save as project file? [y/N]: ')).trim().toLowerCase();
101
311
  if (answer !== 'y' && answer !== 'yes')
102
312
  return null;
103
- const env = buildEnvelope({
104
- artefactType: spec.artefactType,
105
- extract,
106
- author: args.author,
107
- source: {
108
- skill: args.skillName,
109
- skillVersion: args.skillVersion,
110
- input: args.skillInput,
111
- tokensUsed: args.tokensUsed,
112
- model: args.model,
113
- },
114
- promotedFrom: args.promotedFrom ?? null,
115
- });
116
- // Save into the workspace folder (~/CSPeach by default), not cwd. Falls
117
- // back to args.cwd if the workspace can't be created (e.g. permission
118
- // error on the home directory) — saving SOMEWHERE is better than losing
119
- // the artefact.
313
+ let env;
314
+ if (planExtract && prior) {
315
+ // C1 (D30) — the 49dd duplicate: the generic save used to mint a fresh
316
+ // envelope id even when the same plan already lived in the workspace,
317
+ // forking the version chain. With a resolvable prior, the save IS a
318
+ // revision: same id, version+1, history appended (buildPlanRevision —
319
+ // the exact path /abap-plan --resume persistence uses).
320
+ env = buildPlanRevision(prior.envelope, planExtract, args.author, new Date().toISOString());
321
+ // JSON round-trip simulates disk serialization for the validator.
322
+ const check = validateEnvelope(JSON.parse(JSON.stringify(env)));
323
+ if (!check.ok) {
324
+ args.log(`[save] plan revision failed validation (${check.error.message}) — artefact NOT saved.`);
325
+ return null;
326
+ }
327
+ }
328
+ else {
329
+ env = buildEnvelope({
330
+ artefactType: spec.artefactType,
331
+ extract,
332
+ author: args.author,
333
+ source: {
334
+ skill: args.skillName,
335
+ skillVersion: args.skillVersion,
336
+ input: args.skillInput,
337
+ tokensUsed: args.tokensUsed,
338
+ model: args.model,
339
+ },
340
+ promotedFrom: args.promotedFrom ?? null,
341
+ });
342
+ }
343
+ // Saves land in the workspace folder, not cwd — created only NOW, after
344
+ // the user confirmed (declined saves and conversational turns leave no
345
+ // mkdir/.gitignore side effects). Falls back to args.cwd if the workspace
346
+ // can't be created (e.g. permission error on the home directory) — saving
347
+ // SOMEWHERE is better than losing the artefact.
120
348
  let outDir;
121
349
  try {
122
350
  outDir = await ensureWorkspace();
@@ -125,7 +353,17 @@ export async function runSaveCommand(args) {
125
353
  outDir = args.cwd;
126
354
  }
127
355
  const path = await saveProject(env, { cwd: outDir });
356
+ // Task 8 follow-up (fix-wave): a plan envelope saved through the GENERIC
357
+ // save path (Mode 1 create AND a steered revision, C1/D30) must regenerate
358
+ // the markdown handover projection too — otherwise an authoring-session
359
+ // save leaves a stale or missing projection until the first --resume write.
360
+ // Best-effort by design (writePlanHandover logs one dim line on failure).
361
+ if (spec.artefactType === 'plan')
362
+ writePlanHandover(path, args.log);
128
363
  args.log(`Saved: ${path}`);
364
+ if (planExtract && prior) {
365
+ args.log(`(revision v${env.version} of the existing plan — version chain preserved, no new id)`);
366
+ }
129
367
  args.log(`Share at: https://cspeach.dev/edit`);
130
368
  args.log('');
131
369
  args.log(renderEmailTemplate({
@@ -23,7 +23,7 @@ const STATUS_BY_TYPE = {
23
23
  'estimate': ['confirmed', 'disputed', 'deferred'],
24
24
  'cca': ['keep', 'fix', 'retire', 'redesign'],
25
25
  'incident': ['pending', 'done'],
26
- 'plan': ['validated', 'todo', 'designing', 'building', 'verifying', 'blocked'],
26
+ 'plan': ['validated', 'validated-with-waiver', 'todo', 'designing', 'building', 'verifying', 'blocked'],
27
27
  };
28
28
  const PRIMARY_BY_TYPE = {
29
29
  'spec-gap': 'answered',
@@ -120,6 +120,8 @@ export function renderStatus(env) {
120
120
  function displayStatus(s) {
121
121
  if (s === 'n-a')
122
122
  return 'n/a';
123
+ if (s === 'validated-with-waiver')
124
+ return 'validated* (waived)';
123
125
  return s;
124
126
  }
125
127
  function formatDate(iso) {
@@ -19,7 +19,7 @@ const STATUS_BY_TYPE = {
19
19
  'cca-assessment': ['open', 'reviewed', 'classified', 'archived'],
20
20
  'modernize-result': ['applied', 'skipped', 'failed', 'pending'],
21
21
  'test-coverage': ['generated', 'skipped', 'failed', 'pending'],
22
- 'plan': ['todo', 'designing', 'building', 'verifying', 'validated', 'blocked'],
22
+ 'plan': ['todo', 'designing', 'building', 'verifying', 'validated', 'validated-with-waiver', 'blocked'],
23
23
  };
24
24
  const REQUIRED_TOP_LEVEL = [
25
25
  'schemaVersion', 'artefactType', 'id', 'title',
@@ -90,6 +90,24 @@ function maybeAddToGitignore(workspaceDir) {
90
90
  // still works, the user just has to ignore it manually.
91
91
  }
92
92
  }
93
+ /**
94
+ * Resolve the workspace folder PATH without creating it. Same resolution
95
+ * order as ensureWorkspace (config override > project-rooted default) but
96
+ * strictly read-only: no mkdir, no .gitignore append. For callers that only
97
+ * need to LOOK at the workspace — e.g. the pre-confirm prior-plan scan in
98
+ * save-command.ts, which must not leave filesystem side effects before the
99
+ * user says yes to the save.
100
+ */
101
+ export async function resolveWorkspacePath() {
102
+ const cfg = await loadConfig();
103
+ const configured = cfg.workspace_folder?.trim();
104
+ return resolveTarget(configured);
105
+ }
106
+ function resolveTarget(configured) {
107
+ return configured && configured.length > 0
108
+ ? expandHome(configured)
109
+ : defaultWorkspaceFor(process.cwd());
110
+ }
93
111
  /**
94
112
  * Resolve the workspace folder path, creating it if missing.
95
113
  * Returns the absolute path. Idempotent.
@@ -97,9 +115,7 @@ function maybeAddToGitignore(workspaceDir) {
97
115
  export async function ensureWorkspace() {
98
116
  const cfg = await loadConfig();
99
117
  const configured = cfg.workspace_folder?.trim();
100
- const target = configured && configured.length > 0
101
- ? expandHome(configured)
102
- : defaultWorkspaceFor(process.cwd());
118
+ const target = resolveTarget(configured);
103
119
  const justCreated = !existsSync(target);
104
120
  if (justCreated) {
105
121
  mkdirSync(target, { recursive: true });
@@ -159,7 +175,8 @@ export function listProjectFilesSync(workspace) {
159
175
  for (const name of entries) {
160
176
  const isEnvelope = name.endsWith('.cspeach.json');
161
177
  const isText = isTextFilename(name);
162
- if (!isEnvelope && !isText)
178
+ const isDoc = isDocFilename(name);
179
+ if (!isEnvelope && !isText && !isDoc)
163
180
  continue;
164
181
  const full = join(workspace, name);
165
182
  const stat = statSync(full);
@@ -167,7 +184,7 @@ export function listProjectFilesSync(workspace) {
167
184
  continue;
168
185
  out.push({
169
186
  filename: name,
170
- kind: isEnvelope ? 'envelope' : 'text',
187
+ kind: isEnvelope ? 'envelope' : isDoc ? 'doc' : 'text',
171
188
  mtimeMs: stat.mtimeMs,
172
189
  });
173
190
  }
@@ -190,10 +207,31 @@ function expandHome(p) {
190
207
  }
191
208
  /** File suffixes recognised as text-input candidates for skills. */
192
209
  const TEXT_SUFFIXES = ['.txt', '.md'];
193
- function isTextFilename(name) {
210
+ /**
211
+ * Whether `name` is a text-input candidate (.txt / .md). Exported so the
212
+ * @<file> picker (at-picker.ts) reuses the exact same suffix rule instead
213
+ * of duplicating it.
214
+ */
215
+ export function isTextFilename(name) {
194
216
  const lower = name.toLowerCase();
195
217
  return TEXT_SUFFIXES.some((s) => lower.endsWith(s));
196
218
  }
219
+ /** File suffixes recognised as binary document inputs (read via extraction). */
220
+ const DOC_SUFFIXES = ['.docx', '.pdf'];
221
+ /**
222
+ * Whether `name` is a binary document input (.docx / .pdf). Listed and pickable
223
+ * like text inputs, but ingested via document extraction (the same path
224
+ * read_document uses) rather than read as UTF-8 text — so a picked Word/PDF
225
+ * spec just works as skill input.
226
+ */
227
+ export function isDocFilename(name) {
228
+ const lower = name.toLowerCase();
229
+ return DOC_SUFFIXES.some((s) => lower.endsWith(s));
230
+ }
231
+ /** Any user-droppable skill input the @<file> picker should surface. */
232
+ export function isAttachableFilename(name) {
233
+ return isTextFilename(name) || isDocFilename(name);
234
+ }
197
235
  /**
198
236
  * List supported workspace files (.cspeach.json envelopes AND .txt/.md
199
237
  * text inputs), parsed for a tiny metadata summary. Sorted by mtime
@@ -210,7 +248,8 @@ export async function listProjectFiles() {
210
248
  for (const name of entries) {
211
249
  const isEnvelope = name.endsWith('.cspeach.json');
212
250
  const isText = isTextFilename(name);
213
- if (!isEnvelope && !isText)
251
+ const isDoc = isDocFilename(name);
252
+ if (!isEnvelope && !isText && !isDoc)
214
253
  continue;
215
254
  const path = join(ws, name);
216
255
  const stat = statSync(path);
@@ -244,11 +283,11 @@ export async function listProjectFiles() {
244
283
  });
245
284
  }
246
285
  else {
247
- // text — no envelope metadata, just the file presence
286
+ // text / doc — no envelope metadata, just the file presence
248
287
  out.push({
249
288
  path,
250
289
  filename: name,
251
- kind: 'text',
290
+ kind: isDoc ? 'doc' : 'text',
252
291
  artefactType: 'unknown',
253
292
  version: null,
254
293
  title: null,
@@ -336,14 +375,96 @@ export function resolveAtToken(args) {
336
375
  }
337
376
  return { kind: 'ambiguous', matches };
338
377
  }
378
+ /**
379
+ * Whether `token` is a BARE name (no path semantics) — the only case the
380
+ * cwd fallback applies to. Absolute paths and relative paths (./, ../, or
381
+ * containing a separator) already resolve verbatim in resolveAtToken.
382
+ */
383
+ function isBareToken(token) {
384
+ if (isAbsolute(token))
385
+ return false;
386
+ if (token.startsWith('./') || token.startsWith('../') || token.startsWith('.\\') || token.startsWith('..\\')) {
387
+ return false;
388
+ }
389
+ if (token.includes('/') || token.includes('\\'))
390
+ return false;
391
+ return true;
392
+ }
393
+ /**
394
+ * Substring-match a BARE token against the .txt/.md/.docx/.pdf files directly
395
+ * in `cwd` (non-recursive). Mirrors the workspace matching rule:
396
+ * - exactly one match → { kind: 'path', path: join(cwd, filename) }
397
+ * - multiple matches → { kind: 'ambiguous', matches }
398
+ * - none → null (caller keeps the original notFound)
399
+ *
400
+ * Never throws — an unreadable cwd yields null. Exported for direct unit
401
+ * testing of the fallback in isolation from ensureWorkspace I/O.
402
+ */
403
+ export function resolveBareTokenInCwd(token, cwd) {
404
+ let entries;
405
+ try {
406
+ entries = readdirSync(cwd);
407
+ }
408
+ catch {
409
+ return null;
410
+ }
411
+ const lower = token.toLowerCase();
412
+ const matchedFiles = [];
413
+ for (const name of entries) {
414
+ if (name.startsWith('.'))
415
+ continue;
416
+ if (!isAttachableFilename(name))
417
+ continue;
418
+ if (!name.toLowerCase().includes(lower))
419
+ continue;
420
+ const path = join(cwd, name);
421
+ let stat;
422
+ try {
423
+ stat = statSync(path);
424
+ }
425
+ catch {
426
+ continue;
427
+ }
428
+ if (!stat.isFile())
429
+ continue;
430
+ matchedFiles.push({
431
+ path,
432
+ filename: name,
433
+ kind: isDocFilename(name) ? 'doc' : 'text',
434
+ artefactType: 'unknown',
435
+ version: null,
436
+ title: null,
437
+ sizeBytes: stat.size,
438
+ mtimeMs: stat.mtimeMs,
439
+ });
440
+ }
441
+ if (matchedFiles.length === 1) {
442
+ return { kind: 'path', path: matchedFiles[0].path };
443
+ }
444
+ if (matchedFiles.length > 1) {
445
+ return { kind: 'ambiguous', matches: matchedFiles };
446
+ }
447
+ return null;
448
+ }
339
449
  /**
340
450
  * High-level resolver — combines listProjectFiles + resolveAtToken into a
341
451
  * single async call. Callers in promote-command, status, etc. use this.
452
+ *
453
+ * Bug #4: when a BARE token doesn't match anything in the workspace, fall
454
+ * back to matching .txt/.md files in `cwd` before reporting notFound. This
455
+ * lets a user drop a spec file next to their project and reference it with
456
+ * a plain `@spec.txt` instead of an absolute path.
342
457
  */
343
458
  export async function resolveAtTokenAsync(token, cwd) {
344
459
  const workspace = await ensureWorkspace();
345
460
  const files = await listProjectFiles();
346
- return resolveAtToken({ token, cwd, workspace, files });
461
+ const result = resolveAtToken({ token, cwd, workspace, files });
462
+ if (result.kind === 'notFound' && isBareToken(token)) {
463
+ const cwdResult = resolveBareTokenInCwd(token, cwd);
464
+ if (cwdResult)
465
+ return cwdResult;
466
+ }
467
+ return result;
347
468
  }
348
469
  /**
349
470
  * Format `listProjectFiles` output as a human-readable block for the
@@ -362,7 +483,7 @@ export function formatProjectFileList(files, workspace) {
362
483
  const ageStr = relativeTime(now - f.mtimeMs);
363
484
  const meta = f.kind === 'envelope'
364
485
  ? `${f.artefactType.padEnd(9)} v${f.version ?? '?'}`
365
- : `text ${formatBytes(f.sizeBytes).padStart(7)}`;
486
+ : `${f.kind.padEnd(9)} ${formatBytes(f.sizeBytes).padStart(7)}`;
366
487
  lines.push(` ${f.filename} — ${meta} ${ageStr}`);
367
488
  }
368
489
  lines.push('');
@@ -439,8 +560,10 @@ export async function expandTextFileAttachments(userMessage, cwd, log) {
439
560
  if (resolved.kind !== 'path')
440
561
  continue;
441
562
  const path = resolved.path;
442
- if (!isTextFilename(path))
443
- continue;
563
+ const asText = isTextFilename(path);
564
+ const asDoc = isDocFilename(path);
565
+ if (!asText && !asDoc)
566
+ continue; // envelopes / unknown → left for --from/--status
444
567
  // Only ingest if the file exists. Missing files are just left as
445
568
  // literal @<token> in the message (the LLM gets to see them too).
446
569
  if (!existsSync(path)) {
@@ -448,19 +571,40 @@ export async function expandTextFileAttachments(userMessage, cwd, log) {
448
571
  log(`(attached @${token} not found in workspace — left as literal)`);
449
572
  continue;
450
573
  }
574
+ const filename = path.split(/[\\\/]/).pop() ?? token;
451
575
  let content;
452
- try {
453
- content = readFileSync(path, 'utf8');
576
+ if (asDoc) {
577
+ // .docx/.pdf — extract text the same way read_document does, so a picked
578
+ // Word/PDF spec becomes inline skill input with no separate tool call.
579
+ try {
580
+ const buf = readFileSync(path);
581
+ const ext = path.toLowerCase().endsWith('.pdf') ? '.pdf' : '.docx';
582
+ const { extractDocument } = await import('../tools/filesystem/extract-document.js');
583
+ const res = await extractDocument(buf, ext, { maxPages: 150 });
584
+ content = res.text;
585
+ if (log)
586
+ for (const w of res.warnings)
587
+ log(`(${filename}: ${w})`);
588
+ }
589
+ catch (err) {
590
+ if (log)
591
+ log(`(could not read @${token}: ${err.message})`);
592
+ continue;
593
+ }
454
594
  }
455
- catch (err) {
456
- if (log)
457
- log(`(could not read @${token}: ${err.message})`);
458
- continue;
595
+ else {
596
+ try {
597
+ content = readFileSync(path, 'utf8');
598
+ }
599
+ catch (err) {
600
+ if (log)
601
+ log(`(could not read @${token}: ${err.message})`);
602
+ continue;
603
+ }
459
604
  }
460
605
  if (content.length > MAX_TEXT_FILE_CHARS) {
461
606
  content = content.slice(0, MAX_TEXT_FILE_CHARS) + '\n... [truncated, file exceeds 64 KB]';
462
607
  }
463
- const filename = path.split(/[\\\/]/).pop() ?? token;
464
608
  const replacement = `<attached file="${filename}">\n${content}\n</attached>`;
465
609
  substitutions.push({ index, matchLen: match.length, replacement });
466
610
  if (log)