@cspeach/cli 0.9.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 (131) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +209 -20
  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/auth/org-anthropic-key.js +25 -0
  10. package/dist/classifier/client.js +18 -3
  11. package/dist/commands/config-set.js +95 -0
  12. package/dist/commands/login.js +31 -14
  13. package/dist/commands/plan-model-tier.js +83 -0
  14. package/dist/commands/plan-resume.js +148 -21
  15. package/dist/config/loader.js +95 -1
  16. package/dist/doctor/checks/_http-probe.js +1 -0
  17. package/dist/doctor/checks/cert.js +14 -3
  18. package/dist/doctor/checks/sap.js +30 -8
  19. package/dist/doctor/checks/zcspeach.js +19 -4
  20. package/dist/one-shot.js +52 -4
  21. package/dist/projects/answer-blockers.js +137 -0
  22. package/dist/projects/extract-cca.js +108 -16
  23. package/dist/projects/extract-modernize.js +1 -1
  24. package/dist/projects/extract-plan.js +130 -37
  25. package/dist/projects/extract-spec-gap.js +34 -7
  26. package/dist/projects/extract-test-coverage.js +1 -1
  27. package/dist/projects/extract-upgrade.js +113 -22
  28. package/dist/projects/index.js +5 -2
  29. package/dist/projects/merge-cca.js +292 -0
  30. package/dist/projects/merge-upgrade.js +173 -0
  31. package/dist/projects/migration.js +103 -1
  32. package/dist/projects/output-paths.js +27 -0
  33. package/dist/projects/plan-run.js +159 -25
  34. package/dist/projects/plan-schema.js +63 -3
  35. package/dist/projects/promote-command.js +25 -2
  36. package/dist/projects/promote.js +128 -0
  37. package/dist/projects/save-command.js +247 -20
  38. package/dist/projects/status.js +3 -1
  39. package/dist/projects/validate.js +1 -1
  40. package/dist/projects/workspace.js +164 -20
  41. package/dist/renderer/notices.js +64 -0
  42. package/dist/renderer/progress-chatter.js +8 -0
  43. package/dist/renderer/tool-widget.js +18 -4
  44. package/dist/renderer/tty.js +43 -4
  45. package/dist/renderer/verify-chain.js +77 -0
  46. package/dist/repl/at-picker.js +60 -7
  47. package/dist/repl/builtin-commands.js +37 -0
  48. package/dist/repl/early-line-buffer.js +68 -0
  49. package/dist/repl/inquirer-guard.js +70 -5
  50. package/dist/repl/numbered-menu.js +131 -0
  51. package/dist/repl/post-turn-status.js +2 -2
  52. package/dist/repl/rule8-detector.js +17 -2
  53. package/dist/repl/safety-confirm.js +111 -2
  54. package/dist/repl/safety-mode-state.js +19 -3
  55. package/dist/repl/slash-picker.js +10 -15
  56. package/dist/repl.js +301 -35
  57. package/dist/router/classifier.js +150 -6
  58. package/dist/sap/capability-matrix.js +20 -0
  59. package/dist/sap/capability-matrix.json +11236 -0
  60. package/dist/sap/capability.js +146 -0
  61. package/dist/sap/connection-manager.js +19 -1
  62. package/dist/sap/onboarding.js +42 -4
  63. package/dist/session/pending.js +27 -0
  64. package/dist/skill-catalog.js +48 -43
  65. package/dist/skills/bundled-skills.js +279 -1
  66. package/dist/skills/promotion-dispatch.js +23 -0
  67. package/dist/tools/_command-shared.js +36 -12
  68. package/dist/tools/_filesystem-shared.js +139 -4
  69. package/dist/tools/_flag.js +25 -0
  70. package/dist/tools/approval.js +64 -21
  71. package/dist/tools/ask-question.js +96 -4
  72. package/dist/tools/capability/tool.js +74 -0
  73. package/dist/tools/dispatch-skill.js +22 -1
  74. package/dist/tools/extend-model/anchored-insert.js +810 -0
  75. package/dist/tools/extend-model/tool.js +188 -0
  76. package/dist/tools/filesystem/extract-document.js +57 -0
  77. package/dist/tools/filesystem/file-edit.js +12 -2
  78. package/dist/tools/filesystem/file-read.js +2 -2
  79. package/dist/tools/filesystem/file-write.js +11 -2
  80. package/dist/tools/filesystem/glob.js +11 -0
  81. package/dist/tools/filesystem/grep.js +10 -0
  82. package/dist/tools/filesystem/read-document.js +107 -0
  83. package/dist/tools/fiori/apply.js +50 -0
  84. package/dist/tools/fiori/bin.js +3 -0
  85. package/dist/tools/fiori/catalog/index.js +27 -0
  86. package/dist/tools/fiori/catalog/value-help.js +230 -0
  87. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  88. package/dist/tools/fiori/cli.js +71 -0
  89. package/dist/tools/fiori/deploy-config.js +73 -0
  90. package/dist/tools/fiori/fe-scaffold.js +45 -0
  91. package/dist/tools/fiori/i18n.js +39 -0
  92. package/dist/tools/fiori/manifest.js +70 -0
  93. package/dist/tools/fiori/render.js +77 -0
  94. package/dist/tools/fiori/scaffold.js +39 -0
  95. package/dist/tools/fiori/tools.js +356 -0
  96. package/dist/tools/fiori/types.js +1 -0
  97. package/dist/tools/local-build.js +76 -0
  98. package/dist/tools/local-files.js +31 -0
  99. package/dist/tools/project/_merge-shared.js +68 -0
  100. package/dist/tools/project/cca_merge.js +164 -0
  101. package/dist/tools/project/playbook_get.js +1 -1
  102. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  103. package/dist/tools/sap-read.js +53 -9
  104. package/dist/tools/sap-write.js +530 -21
  105. package/dist/tools/shell/shell_exec.js +41 -6
  106. package/dist/tools/snapshot.js +37 -14
  107. package/dist/tools/subagent/background_run.js +17 -1
  108. package/dist/tools/transport-resolution.js +86 -0
  109. package/dist/tools/transport.js +224 -5
  110. package/dist/tools/write-mode.js +4 -0
  111. package/dist/ui/app.js +6 -2
  112. package/dist/ui/body.js +13 -0
  113. package/dist/ui/footer.js +20 -6
  114. package/dist/ui/line-resolution.js +17 -6
  115. package/dist/ui/session-timeline.js +1 -0
  116. package/dist/ui/text-input.js +150 -0
  117. package/dist/ui/widgets/ask-question-modal.js +4 -1
  118. package/package.json +19 -3
  119. package/bench/README.md +0 -78
  120. package/bench/prompts/abap-document-cds.md +0 -44
  121. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  122. package/bench/prompts/abap-test-method.md +0 -42
  123. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  124. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  125. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  126. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  127. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  128. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  129. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  130. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  131. 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,13 @@ 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 { ensureWorkspace, resolveWorkspacePath } from './workspace.js';
13
19
  const SKILL_REGISTRY = {
14
20
  'abap-spec-gap': {
15
21
  artefactType: 'spec-gap',
@@ -30,16 +36,19 @@ const SKILL_REGISTRY = {
30
36
  artefactType: 'upgrade-scan',
31
37
  extract: (md) => extractUpgradeScan(md),
32
38
  itemNoun: 'findings',
39
+ manifestMarker: 'csforge:upgrade-manifest',
33
40
  },
34
41
  'abap-upgrade-fix': {
35
42
  artefactType: 'upgrade-progress',
36
43
  extract: (md) => extractUpgradeProgress(md),
37
44
  itemNoun: 'fixes',
45
+ manifestMarker: 'csforge:upgrade-manifest',
38
46
  },
39
47
  'abap-upgrade-verify': {
40
48
  artefactType: 'upgrade-report',
41
49
  extract: (md) => extractUpgradeReport(md),
42
50
  itemNoun: 'regressions',
51
+ manifestMarker: 'csforge:upgrade-manifest',
43
52
  },
44
53
  'abap-upgrade-merge': {
45
54
  // Merge produces a NEW upgrade-progress (consolidated) — same shape
@@ -47,11 +56,13 @@ const SKILL_REGISTRY = {
47
56
  artefactType: 'upgrade-progress',
48
57
  extract: (md) => extractUpgradeProgress(md),
49
58
  itemNoun: 'fixes',
59
+ manifestMarker: 'csforge:upgrade-manifest',
50
60
  },
51
61
  'abap-cca': {
52
62
  artefactType: 'cca-assessment',
53
63
  extract: (md) => extractCcaAssessment(md),
54
64
  itemNoun: 'classifications',
65
+ manifestMarker: 'csforge:cca-manifest',
55
66
  },
56
67
  'abap-cca-merge': {
57
68
  // Merge produces a NEW cca-assessment (consolidated) — same shape as
@@ -59,36 +70,218 @@ const SKILL_REGISTRY = {
59
70
  artefactType: 'cca-assessment',
60
71
  extract: (md) => extractCcaAssessment(md),
61
72
  itemNoun: 'classifications',
73
+ manifestMarker: 'csforge:cca-manifest',
62
74
  },
63
75
  'abap-modernize': {
64
76
  artefactType: 'modernize-result',
65
77
  extract: (md) => extractModernizeResult(md),
66
78
  itemNoun: 'modernizations',
79
+ manifestMarker: 'csforge:modernize-manifest',
67
80
  },
68
81
  'abap-test': {
69
82
  artefactType: 'test-coverage',
70
83
  extract: (md) => extractTestCoverage(md),
71
84
  itemNoun: 'tests',
85
+ manifestMarker: 'csforge:test-coverage-manifest',
72
86
  },
73
87
  'abap-plan': {
74
- // 2026-06-06 B3: Mode 1 (create) save only. Mode 2 (--resume) turns
88
+ // 2026-06-06 B3: Mode 1 (create) save. Mode 2 (--resume) turns
75
89
  // suppress this hook (RunTurnParams.suppressSaveHook) and persist a
76
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.
77
97
  artefactType: 'plan',
78
98
  extract: (md) => extractPlan(md),
79
99
  itemNoun: 'phases',
80
100
  manifestMarker: 'csforge:plan-manifest',
81
101
  },
82
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
+ }
83
212
  export async function runSaveCommand(args) {
84
213
  const spec = SKILL_REGISTRY[args.skillName];
85
214
  if (!spec)
86
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
+ }
87
251
  let extract;
252
+ let planExtract = null;
88
253
  try {
89
- 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
+ }
90
261
  }
91
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
+ }
92
285
  if (spec.manifestMarker && args.skillOutput.includes(spec.manifestMarker)) {
93
286
  args.log('');
94
287
  args.log(`[save] ${args.skillName}: a ${spec.manifestMarker} block IS present but failed to parse — artefact NOT saved.`);
@@ -97,26 +290,57 @@ export async function runSaveCommand(args) {
97
290
  }
98
291
  return null;
99
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.
100
306
  const answer = (await args.prompt('Save as project file? [y/N]: ')).trim().toLowerCase();
101
307
  if (answer !== 'y' && answer !== 'yes')
102
308
  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.
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.
120
344
  let outDir;
121
345
  try {
122
346
  outDir = await ensureWorkspace();
@@ -126,6 +350,9 @@ export async function runSaveCommand(args) {
126
350
  }
127
351
  const path = await saveProject(env, { cwd: outDir });
128
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
+ }
129
356
  args.log(`Share at: https://cspeach.dev/edit`);
130
357
  args.log('');
131
358
  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)