@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.
- package/README.md +1 -1
- package/dist/agent/intent-system-prompt.js +1 -1
- package/dist/agent/loop.js +228 -26
- package/dist/agent/providers/license-gate.js +44 -0
- package/dist/agent/skill-checkpoint.js +1 -1
- package/dist/agent/tool-dispatch.js +15 -0
- package/dist/approvals/canonical.js +91 -0
- package/dist/approvals/jwt.js +39 -2
- package/dist/approvals/op-labels.js +124 -0
- package/dist/approvals/render.js +42 -36
- package/dist/auth/org-anthropic-key.js +25 -0
- package/dist/classifier/client.js +18 -3
- package/dist/cli.js +15 -0
- package/dist/commands/compact.js +28 -2
- package/dist/commands/config-set.js +284 -0
- package/dist/commands/config-show.js +20 -0
- package/dist/commands/export-audit.js +43 -0
- package/dist/commands/help.js +5 -0
- package/dist/commands/login.js +31 -14
- package/dist/commands/plan-audit-evidence.js +266 -0
- package/dist/commands/plan-audit.js +692 -0
- package/dist/commands/plan-chain.js +671 -0
- package/dist/commands/plan-continue.js +179 -0
- package/dist/commands/plan-gate.js +154 -0
- package/dist/commands/plan-model-tier.js +83 -0
- package/dist/commands/plan-resume.js +728 -46
- package/dist/config/loader.js +223 -5
- package/dist/config/model-defaults.js +14 -0
- package/dist/cost/pricing.js +27 -1
- package/dist/doctor/checks/_http-probe.js +1 -0
- package/dist/doctor/checks/cert.js +14 -3
- package/dist/doctor/checks/sap.js +30 -8
- package/dist/doctor/checks/system-roles.js +41 -0
- package/dist/doctor/checks/zcspeach.js +19 -4
- package/dist/doctor/run.js +2 -0
- package/dist/models/resolve.js +61 -0
- package/dist/models/server-config.js +155 -0
- package/dist/one-shot.js +76 -6
- package/dist/projects/answer-blockers.js +137 -0
- package/dist/projects/extract-cca.js +111 -17
- package/dist/projects/extract-modernize.js +4 -2
- package/dist/projects/extract-plan.js +184 -37
- package/dist/projects/extract-spec-gap.js +34 -7
- package/dist/projects/extract-test-coverage.js +4 -2
- package/dist/projects/extract-upgrade.js +116 -23
- package/dist/projects/handover-md.js +195 -0
- package/dist/projects/index.js +5 -2
- package/dist/projects/merge-cca.js +292 -0
- package/dist/projects/merge-upgrade.js +173 -0
- package/dist/projects/migration.js +103 -1
- package/dist/projects/output-paths.js +27 -0
- package/dist/projects/plan-run.js +285 -27
- package/dist/projects/plan-schema.js +136 -3
- package/dist/projects/promote-command.js +25 -2
- package/dist/projects/promote.js +128 -0
- package/dist/projects/run-lease.js +157 -0
- package/dist/projects/save-command.js +259 -21
- package/dist/projects/status.js +3 -1
- package/dist/projects/validate.js +1 -1
- package/dist/projects/workspace.js +164 -20
- package/dist/renderer/notices.js +64 -0
- package/dist/renderer/progress-chatter.js +8 -0
- package/dist/renderer/status-footer.js +22 -12
- package/dist/renderer/thinking-heartbeat.js +64 -8
- package/dist/renderer/todo-block.js +51 -0
- package/dist/renderer/tool-widget.js +55 -4
- package/dist/renderer/tty.js +43 -4
- package/dist/renderer/verify-chain.js +77 -0
- package/dist/repl/at-picker.js +60 -7
- package/dist/repl/bracketed-paste.js +28 -19
- package/dist/repl/builtin-commands.js +42 -0
- package/dist/repl/current-transport.js +10 -0
- package/dist/repl/early-line-buffer.js +68 -0
- package/dist/repl/history.js +86 -0
- package/dist/repl/ink-stdin-guard.js +64 -0
- package/dist/repl/inquirer-guard.js +70 -5
- package/dist/repl/mode-ceiling.js +16 -0
- package/dist/repl/mode-cycle.js +104 -0
- package/dist/repl/numbered-menu.js +131 -0
- package/dist/repl/post-turn-status.js +26 -6
- package/dist/repl/rule8-detector.js +17 -2
- package/dist/repl/safety-confirm.js +111 -2
- package/dist/repl/safety-mode-state.js +19 -3
- package/dist/repl/slash-completer.js +5 -0
- package/dist/repl/slash-picker.js +10 -15
- package/dist/repl.js +1232 -95
- package/dist/rewind/candidates.js +194 -0
- package/dist/rewind/cli.js +137 -0
- package/dist/rewind/format.js +27 -0
- package/dist/rewind/restore.js +245 -0
- package/dist/router/classifier.js +150 -6
- package/dist/sap/capability-matrix.js +20 -0
- package/dist/sap/capability-matrix.json +11236 -0
- package/dist/sap/capability.js +146 -0
- package/dist/sap/connection-manager.js +19 -1
- package/dist/sap/onboarding.js +42 -4
- package/dist/session/audit-export.js +459 -0
- package/dist/session/context-report.js +163 -0
- package/dist/session/pending.js +27 -0
- package/dist/session/recap.js +160 -0
- package/dist/skill-catalog.js +51 -40
- package/dist/skills/bundled-skills.js +272 -1
- package/dist/skills/promotion-dispatch.js +23 -0
- package/dist/tools/_command-shared.js +36 -12
- package/dist/tools/_filesystem-shared.js +139 -4
- package/dist/tools/_flag.js +25 -0
- package/dist/tools/approval.js +177 -26
- package/dist/tools/ask-question.js +400 -7
- package/dist/tools/capability/tool.js +74 -0
- package/dist/tools/dispatch-skill.js +22 -1
- package/dist/tools/extend-model/anchored-insert.js +1414 -0
- package/dist/tools/extend-model/tool.js +340 -0
- package/dist/tools/filesystem/extract-document.js +57 -0
- package/dist/tools/filesystem/file-edit.js +12 -2
- package/dist/tools/filesystem/file-read.js +2 -2
- package/dist/tools/filesystem/file-write.js +11 -2
- package/dist/tools/filesystem/glob.js +11 -0
- package/dist/tools/filesystem/grep.js +10 -0
- package/dist/tools/filesystem/read-document.js +107 -0
- package/dist/tools/fiori/apply.js +50 -0
- package/dist/tools/fiori/bin.js +3 -0
- package/dist/tools/fiori/catalog/index.js +27 -0
- package/dist/tools/fiori/catalog/value-help.js +230 -0
- package/dist/tools/fiori/catalog/viz-chart.js +177 -0
- package/dist/tools/fiori/cli.js +71 -0
- package/dist/tools/fiori/deploy-config.js +73 -0
- package/dist/tools/fiori/fe-extend.js +76 -0
- package/dist/tools/fiori/fe-scaffold.js +71 -0
- package/dist/tools/fiori/floorplan-map.js +19 -0
- package/dist/tools/fiori/i18n.js +39 -0
- package/dist/tools/fiori/manifest.js +70 -0
- package/dist/tools/fiori/render.js +77 -0
- package/dist/tools/fiori/samples/data/index.json +13602 -0
- package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
- package/dist/tools/fiori/samples/loader.js +248 -0
- package/dist/tools/fiori/samples/search.js +63 -0
- package/dist/tools/fiori/samples/types.js +2 -0
- package/dist/tools/fiori/scaffold.js +39 -0
- package/dist/tools/fiori/smoke/assertions.js +74 -0
- package/dist/tools/fiori/smoke/browser.js +52 -0
- package/dist/tools/fiori/smoke/driver.js +89 -0
- package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
- package/dist/tools/fiori/smoke/run-smoke.js +149 -0
- package/dist/tools/fiori/tools.js +681 -0
- package/dist/tools/fiori/types.js +1 -0
- package/dist/tools/local-build.js +86 -0
- package/dist/tools/local-files.js +31 -0
- package/dist/tools/project/_merge-shared.js +68 -0
- package/dist/tools/project/cca_merge.js +164 -0
- package/dist/tools/project/playbook_get.js +1 -1
- package/dist/tools/project/upgrade_merge_progress.js +206 -0
- package/dist/tools/sap-read.js +132 -20
- package/dist/tools/sap-write.js +550 -21
- package/dist/tools/shell/shell_exec.js +41 -6
- package/dist/tools/snapshot.js +63 -14
- package/dist/tools/subagent/agent_run.js +27 -3
- package/dist/tools/subagent/background_run.js +17 -1
- package/dist/tools/todo.js +144 -0
- package/dist/tools/transport-resolution.js +86 -0
- package/dist/tools/transport.js +224 -5
- package/dist/tools/write-mode.js +4 -0
- package/dist/ui/app.js +378 -21
- package/dist/ui/approval-modal.js +49 -16
- package/dist/ui/ask-question-emitter.js +14 -0
- package/dist/ui/body.js +13 -0
- package/dist/ui/context-grid.js +108 -0
- package/dist/ui/footer.js +120 -27
- package/dist/ui/header.js +7 -0
- package/dist/ui/line-resolution.js +35 -8
- package/dist/ui/rewind-emitter.js +10 -0
- package/dist/ui/rewind-panel.js +81 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/session-timeline.js +1 -0
- package/dist/ui/status-line.js +43 -0
- package/dist/ui/text-input.js +214 -0
- package/dist/ui/todo-emitter.js +25 -0
- package/dist/ui/todo-panel.js +64 -0
- package/dist/ui/turn-status-emitter.js +50 -4
- package/dist/ui/turn-status.js +18 -3
- package/dist/ui/widgets/ask-form.js +242 -0
- package/dist/ui/widgets/ask-question-modal.js +21 -8
- package/package.json +22 -3
- package/bench/README.md +0 -78
- package/bench/prompts/abap-document-cds.md +0 -44
- package/bench/prompts/abap-explain-bdef-handler.md +0 -57
- package/bench/prompts/abap-test-method.md +0 -42
- package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
- package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
- package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
- package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
- package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
- package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
- package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
- package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
- 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 {
|
|
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
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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({
|
package/dist/projects/status.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
443
|
-
|
|
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
|
-
|
|
453
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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)
|