@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
|
@@ -28,11 +28,17 @@ export const PLAN_LAYERS = [
|
|
|
28
28
|
*/
|
|
29
29
|
export const PLAN_DELEGATE_SKILLS = [
|
|
30
30
|
'abap-design', 'abap-data-model', 'abap-rap', 'abap-eml',
|
|
31
|
-
'abap-generate', 'abap-segw', 'abap-test',
|
|
31
|
+
'abap-generate', 'abap-segw', 'abap-test', 'abap-fiori-build',
|
|
32
|
+
'abap-extend-model',
|
|
32
33
|
];
|
|
33
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Keep in sync with ItemStatusByType['plan'] in types.ts.
|
|
36
|
+
* 'validated-with-waiver' (C1, 2026-06-11): the exit gate was NOT met but the
|
|
37
|
+
* user explicitly waived it — DAG eligibility treats it like 'validated'
|
|
38
|
+
* (see isPhaseSatisfied in plan-run.ts).
|
|
39
|
+
*/
|
|
34
40
|
export const PLAN_PHASE_STATUSES = [
|
|
35
|
-
'todo', 'designing', 'building', 'verifying', 'validated', 'blocked',
|
|
41
|
+
'todo', 'designing', 'building', 'verifying', 'validated', 'validated-with-waiver', 'blocked',
|
|
36
42
|
];
|
|
37
43
|
const SHA256_RE = /^[0-9a-f]{64}$/;
|
|
38
44
|
const planPhaseManifestSchema = z.object({
|
|
@@ -48,9 +54,78 @@ const planPhaseWorkSchema = z.object({
|
|
|
48
54
|
transport: z.string().optional(),
|
|
49
55
|
snapshot: z.string().optional(),
|
|
50
56
|
notes: z.string().optional(),
|
|
57
|
+
// C2 (D24): exact SRVB name for the plan-complete /abap-fiori-build chain.
|
|
58
|
+
binding: z.string().optional(),
|
|
59
|
+
// Track 1 ui-phase fields — tolerant on purpose (written mid-resume by the
|
|
60
|
+
// model; a malformed value must degrade to undefined, never fail the whole
|
|
61
|
+
// envelope parse). Keep in sync with PlanPhaseWork in types.ts.
|
|
62
|
+
app: z.object({ dir: z.string().min(1) }).optional().catch(undefined),
|
|
63
|
+
service: z.object({
|
|
64
|
+
url: z.string().min(1).optional(),
|
|
65
|
+
path: z.string().min(1).optional(),
|
|
66
|
+
version: z.enum(['2.0', '4.0']).optional(),
|
|
67
|
+
}).optional().catch(undefined),
|
|
68
|
+
deployedUrl: z.string().min(1).optional().catch(undefined),
|
|
51
69
|
}).passthrough();
|
|
70
|
+
export const PLAN_UI_FLAVORS = ['fiori-elements', 'freestyle'];
|
|
71
|
+
export const PLAN_UI_FLOORPLANS = ['listReport', 'worklist', 'ovp', 'alp'];
|
|
72
|
+
// Strict on purpose: written once at plan-create under save validation —
|
|
73
|
+
// a bad flavor must fail the save, not silently degrade.
|
|
74
|
+
const planPhaseUiSchema = z.object({
|
|
75
|
+
flavor: z.enum(PLAN_UI_FLAVORS),
|
|
76
|
+
floorplan: z.enum(PLAN_UI_FLOORPLANS).optional(),
|
|
77
|
+
appId: z.string().min(1).optional(),
|
|
78
|
+
appTitle: z.string().min(1).optional(),
|
|
79
|
+
});
|
|
80
|
+
/** Keep in sync with PlanAuditState in types.ts. 'warn' is the non-blocking
|
|
81
|
+
* tier (confidence-tiered redesign, 2026-07-08) — persisted literally so a
|
|
82
|
+
* warned phase records its notes and renders distinctly from a clean pass. */
|
|
83
|
+
export const PLAN_AUDIT_STATES = [
|
|
84
|
+
'pending', 'passed', 'warn', 'failed', 'infra_failed', 'waived', 'not_audited_hand_edited',
|
|
85
|
+
];
|
|
86
|
+
/**
|
|
87
|
+
* Reasons a chain stop can record on content.lastStop. Keep in sync with
|
|
88
|
+
* PlanContent['lastStop']['reason'] in types.ts (same single-source pattern
|
|
89
|
+
* as PLAN_AUDIT_STATES/PlanAuditState — the zod enum below derives from this).
|
|
90
|
+
*/
|
|
91
|
+
export const PLAN_STOP_REASONS = [
|
|
92
|
+
'write-phase', 'audit-failed', 'audit-infra', 'deviation',
|
|
93
|
+
];
|
|
94
|
+
/**
|
|
95
|
+
* §10 Q2 (audit-confidence tiered redesign, 2026-07-08): the light category a
|
|
96
|
+
* human waive records so the audit's false-positive rate is measurable. Keep in
|
|
97
|
+
* sync with PlanWaiveCategory in types.ts (the zod enum below derives from it).
|
|
98
|
+
*/
|
|
99
|
+
export const PLAN_WAIVE_CATEGORIES = ['false-positive', 'accepted-risk'];
|
|
100
|
+
const planPhaseAuditSchema = z.object({
|
|
101
|
+
state: z.enum(PLAN_AUDIT_STATES),
|
|
102
|
+
findings: z.array(z.string()).optional(),
|
|
103
|
+
waivedReason: z.string().optional(),
|
|
104
|
+
// §10 Q2 (2026-07-08): light waive category (see PlanPhaseAudit in types.ts).
|
|
105
|
+
// Must be listed here or z.object strips it on the next parse — losing the
|
|
106
|
+
// datum the audit FP-rate query depends on. Optional for back-compat: legacy
|
|
107
|
+
// waivers carry no category and must still parse.
|
|
108
|
+
waiveCategory: z.enum(PLAN_WAIVE_CATEGORIES).optional(),
|
|
109
|
+
at: z.string().optional(),
|
|
110
|
+
// Task 6 (2026-07-03): evidence-session pointer + consecutive-failure
|
|
111
|
+
// counter (see PlanPhaseAudit in types.ts). Both MUST be in the schema —
|
|
112
|
+
// z.object strips unknown keys, so an unlisted field would be silently
|
|
113
|
+
// dropped on the next parse and the counter/evidence pointer lost.
|
|
114
|
+
sessionId: z.string().optional(),
|
|
115
|
+
// D-B review item 1 (2026-07-05): attempt-window cutoff for re-audits (see
|
|
116
|
+
// PlanPhaseAudit in types.ts). Must be in the schema or z.object strips it.
|
|
117
|
+
attemptStartedAt: z.string().optional(),
|
|
118
|
+
consecutiveFailures: z.number().int().nonnegative().optional(),
|
|
119
|
+
});
|
|
120
|
+
/** Fail-safe write-ness: a phase that doesn't declare is treated as writing. */
|
|
121
|
+
export function phaseWrites(phase) {
|
|
122
|
+
return phase.writes !== false;
|
|
123
|
+
}
|
|
52
124
|
const planPhaseSchema = z.object({
|
|
53
125
|
id: z.string().min(1),
|
|
126
|
+
// C2 (D29): optional short human name for the tracker board; old envelopes
|
|
127
|
+
// without it still parse and render via the layer/delegate label fallback.
|
|
128
|
+
title: z.string().min(1).optional(),
|
|
54
129
|
component: z.string().min(1),
|
|
55
130
|
layer: z.enum(PLAN_LAYERS),
|
|
56
131
|
entryCriteria: z.array(z.string()),
|
|
@@ -58,8 +133,46 @@ const planPhaseSchema = z.object({
|
|
|
58
133
|
manifest: planPhaseManifestSchema,
|
|
59
134
|
exitGate: z.string().min(1),
|
|
60
135
|
approval: z.boolean().optional(),
|
|
136
|
+
writes: z.boolean().optional(),
|
|
137
|
+
ui: planPhaseUiSchema.optional(),
|
|
138
|
+
audit: planPhaseAuditSchema.optional(),
|
|
61
139
|
work: planPhaseWorkSchema.optional(),
|
|
62
140
|
});
|
|
141
|
+
/**
|
|
142
|
+
* Validates the discovered live stack for a revision plan (RevisionTarget in
|
|
143
|
+
* types.ts). All layer fields are optional/nullable — a partial stack (e.g.
|
|
144
|
+
* a table view with no MDE) is valid. Only `anchor`, `uiKind`, and `binding`
|
|
145
|
+
* are required shape — layers is always an object but all its members are opt.
|
|
146
|
+
*/
|
|
147
|
+
const revisionTargetSchema = z
|
|
148
|
+
.object({
|
|
149
|
+
anchor: z.string().min(1),
|
|
150
|
+
uiKind: z.enum(['fiori-elements', 'freestyle', 'none']),
|
|
151
|
+
binding: z.string().nullable(),
|
|
152
|
+
// C2 (2026-06-26): freestyle app on-disk location (see types.ts). Additive
|
|
153
|
+
// and optional; the superRefine below makes it mandatory ONLY for freestyle.
|
|
154
|
+
app: z.object({ dir: z.string().min(1) }).optional(),
|
|
155
|
+
layers: z.object({
|
|
156
|
+
table: z.string().nullable().optional(),
|
|
157
|
+
interfaceView: z.string().nullable().optional(),
|
|
158
|
+
projectionView: z.string().nullable().optional(),
|
|
159
|
+
bdef: z.string().nullable().optional(),
|
|
160
|
+
behaviorPool: z.string().nullable().optional(),
|
|
161
|
+
mde: z.string().nullable().optional(),
|
|
162
|
+
serviceDef: z.string().nullable().optional(),
|
|
163
|
+
}),
|
|
164
|
+
})
|
|
165
|
+
.superRefine((rev, ctx) => {
|
|
166
|
+
// A freestyle revision must record where the app lives on disk, or the
|
|
167
|
+
// ui-phase delegation to /abap-fiori-build amend has no appDir to amend.
|
|
168
|
+
if (rev.uiKind === 'freestyle' && !rev.app) {
|
|
169
|
+
ctx.addIssue({
|
|
170
|
+
code: z.ZodIssueCode.custom,
|
|
171
|
+
path: ['app'],
|
|
172
|
+
message: 'uiKind "freestyle" requires `revision.app.dir` (the freestyle app location for amend)',
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
});
|
|
63
176
|
// Input type is `unknown` (third param): the `.default({})` on `from` means
|
|
64
177
|
// the schema ACCEPTS input without the key while still OUTPUTTING PlanContent.
|
|
65
178
|
export const planContentSchema = z
|
|
@@ -68,6 +181,8 @@ export const planContentSchema = z
|
|
|
68
181
|
goal: z.string().min(1),
|
|
69
182
|
source: z.string(),
|
|
70
183
|
target: z.string(),
|
|
184
|
+
// Absent mode parses as undefined (treated as 'create') — back-compat.
|
|
185
|
+
mode: z.enum(['create', 'revision']).optional(),
|
|
71
186
|
}),
|
|
72
187
|
// Tolerant by lesson (2026-06-06 live smoke): the model omitted the whole
|
|
73
188
|
// `from` key for a goal-only plan and the strict schema killed the save.
|
|
@@ -82,6 +197,9 @@ export const planContentSchema = z
|
|
|
82
197
|
.optional(),
|
|
83
198
|
})
|
|
84
199
|
.default({}),
|
|
200
|
+
// Present when project.mode='revision'. Optional here; the superRefine
|
|
201
|
+
// below enforces the cross-field rule: mode='revision' requires revision.
|
|
202
|
+
revision: revisionTargetSchema.optional(),
|
|
85
203
|
phases: z.array(planPhaseSchema).min(1),
|
|
86
204
|
summary: z.object({
|
|
87
205
|
total: z.number().int().nonnegative(),
|
|
@@ -89,8 +207,23 @@ export const planContentSchema = z
|
|
|
89
207
|
blocked: z.number().int().nonnegative(),
|
|
90
208
|
next: z.string().nullable(),
|
|
91
209
|
}),
|
|
210
|
+
lastStop: z.object({
|
|
211
|
+
phaseId: z.string().min(1),
|
|
212
|
+
reason: z.enum(PLAN_STOP_REASONS),
|
|
213
|
+
detail: z.string().optional(),
|
|
214
|
+
at: z.string(),
|
|
215
|
+
}).optional(),
|
|
92
216
|
})
|
|
93
217
|
.superRefine((content, ctx) => {
|
|
218
|
+
// mode:'revision' requires a revision target map (the discovered live stack).
|
|
219
|
+
// Absent mode is treated as 'create' — this check is a no-op in that case.
|
|
220
|
+
if (content.project.mode === 'revision' && !content.revision) {
|
|
221
|
+
ctx.addIssue({
|
|
222
|
+
code: z.ZodIssueCode.custom,
|
|
223
|
+
path: ['revision'],
|
|
224
|
+
message: 'mode "revision" requires a `revision` target map (discovered live stack)',
|
|
225
|
+
});
|
|
226
|
+
}
|
|
94
227
|
// Phase ids must be unique, and entryCriteria may only reference phases
|
|
95
228
|
// EARLIER in the array. Bottom-up ordering by construction — this is what
|
|
96
229
|
// guarantees resume is deterministic and the dependency graph acyclic
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readProjectFile } from './status.js';
|
|
2
|
-
import { buildPromotedFromSnapshot, validatePromotionSource } from './promote.js';
|
|
2
|
+
import { buildPromotedFromSnapshot, buildReviewDecisionsBlock, buildDetailPathHint, validatePromotionSource } from './promote.js';
|
|
3
3
|
import { resolveAtTokenAsync, formatProjectFileList } from './workspace.js';
|
|
4
4
|
/**
|
|
5
5
|
* Per-skill predecessor map. v0.7 admits multi-source via array values —
|
|
@@ -92,6 +92,29 @@ export async function runPromoteCommand(args) {
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
const promotedFrom = await buildPromotedFromSnapshot(env);
|
|
95
|
-
|
|
95
|
+
let extendedSkillInput = buildExtendedInput(promotedFrom);
|
|
96
|
+
// Always hand the skill the source detail-file path so it reads the baseline
|
|
97
|
+
// directly (the envelope at .cspeach/ root is unreadable; glob skips .cspeach/).
|
|
98
|
+
// This is what makes `--from` actually work for upgrade-fix on any scan,
|
|
99
|
+
// triaged or not — without it the skill globs for the file and fails.
|
|
100
|
+
const detailHint = buildDetailPathHint(env);
|
|
101
|
+
if (detailHint) {
|
|
102
|
+
extendedSkillInput = `${extendedSkillInput}\n\n${detailHint}`;
|
|
103
|
+
}
|
|
104
|
+
// When the source is a triaged upgrade-scan envelope, append a dedicated
|
|
105
|
+
// "Review decisions (honor these)" block joining each finding's status with
|
|
106
|
+
// its objectName/line/rule. This drives /abap-upgrade-fix off the viewer
|
|
107
|
+
// triage instead of re-asking object-by-object. Returns null (and we append
|
|
108
|
+
// nothing) for non-upgrade-scan sources or fully-untriaged scans.
|
|
109
|
+
const reviewBlock = buildReviewDecisionsBlock(env);
|
|
110
|
+
if (reviewBlock) {
|
|
111
|
+
extendedSkillInput = `${extendedSkillInput}\n\n${reviewBlock}`;
|
|
112
|
+
}
|
|
113
|
+
// Carry the originating spec document into the handoff so the downstream
|
|
114
|
+
// skill (e.g. /abap-plan) can cite the running spec the answered gaps trace
|
|
115
|
+
// back to — the Word/PDF doc → spec-gap → plan thread stays visible.
|
|
116
|
+
if (env.artefactType === 'spec-gap' && env.content.sourceDocument) {
|
|
117
|
+
extendedSkillInput = `${extendedSkillInput}\n\nSource spec document: ${env.content.sourceDocument}`;
|
|
118
|
+
}
|
|
96
119
|
return { promotedFrom, extendedSkillInput };
|
|
97
120
|
}
|
package/dist/projects/promote.js
CHANGED
|
@@ -67,6 +67,134 @@ export async function buildPromotedFromSnapshot(source) {
|
|
|
67
67
|
snapshot: { items },
|
|
68
68
|
};
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Build the compact "Review decisions" block for an upgrade-scan source.
|
|
72
|
+
*
|
|
73
|
+
* The viewer lets the user triage each finding to one of
|
|
74
|
+
* `open | auto-fix | manual | suppress` (stored in `interaction.items[].status`).
|
|
75
|
+
* Those decisions must DRIVE /abap-upgrade-fix instead of the skill re-asking
|
|
76
|
+
* object-by-object. The skill cannot file_read the root `.cspeach/<name>.json`
|
|
77
|
+
* envelope (denylisted), so the statuses must reach it via the `--from`
|
|
78
|
+
* prompt injection — this block is what the CLI appends to that injection.
|
|
79
|
+
*
|
|
80
|
+
* Joins each item's status with the matching `content.findings[]` row
|
|
81
|
+
* (objectName / line / rule) and renders one aligned line per finding,
|
|
82
|
+
* e.g.:
|
|
83
|
+
* finding-001 ZFI_DUNNING_SELECT line 52 SELECT_WO_ORDER_BY → auto-fix
|
|
84
|
+
*
|
|
85
|
+
* Returns `null` when the source is not an upgrade-scan, or when every
|
|
86
|
+
* finding is still `open` (untriaged — nothing to honor; the skill falls
|
|
87
|
+
* back to its existing interactive ask for all of them).
|
|
88
|
+
*/
|
|
89
|
+
export function buildReviewDecisionsBlock(source) {
|
|
90
|
+
if (source.artefactType === 'upgrade-scan')
|
|
91
|
+
return buildUpgradeScanDecisions(source);
|
|
92
|
+
if (source.artefactType === 'cca-assessment')
|
|
93
|
+
return buildCcaDecisions(source);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Always-inject hint for any `--from` source that carries a `content.detailPath`
|
|
98
|
+
* (upgrade-scan/progress/report, cca-assessment, modernize-result, test-coverage).
|
|
99
|
+
*
|
|
100
|
+
* Why this is REQUIRED, not optional: the `.cspeach.json` envelope itself lives at
|
|
101
|
+
* the denylisted `.cspeach/` root and cannot be read by the skill, and `glob` skips
|
|
102
|
+
* `.cspeach/` — so without an explicit path the skill hunts for the baseline and
|
|
103
|
+
* fails (observed live 2026-06-16: `/abap-upgrade-fix --from` globbing endlessly).
|
|
104
|
+
* The detail file lives under `.cspeach/<domain>/`, which the filesystem carve-out
|
|
105
|
+
* makes readable — so we hand the skill that exact path to `file_read` directly.
|
|
106
|
+
*/
|
|
107
|
+
export function buildDetailPathHint(source) {
|
|
108
|
+
const detailPath = source.content?.detailPath;
|
|
109
|
+
if (!detailPath)
|
|
110
|
+
return null;
|
|
111
|
+
return [
|
|
112
|
+
'Source detail file — READ THIS DIRECTLY with file_read at the exact path below.',
|
|
113
|
+
'Do NOT glob for it, and do NOT try to read the .cspeach.json envelope (that path',
|
|
114
|
+
'is protected). The detail file holds the full per-finding data (object, line,',
|
|
115
|
+
'rule, message, successor) and is readable under .cspeach/:',
|
|
116
|
+
` ${detailPath}`,
|
|
117
|
+
].join('\n');
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Render a fixed-width "Review decisions (honor these)" table from already-built
|
|
121
|
+
* rows. `summary` is the one-line counts header; `countKeys` lists the decision
|
|
122
|
+
* tokens to tally (in display order). Shared by the upgrade-scan and
|
|
123
|
+
* cca-assessment branches so both render identically.
|
|
124
|
+
*/
|
|
125
|
+
function renderDecisionRows(rows, countKeys, summaryLabel) {
|
|
126
|
+
const wId = Math.max(...rows.map((r) => r.id.length));
|
|
127
|
+
const wObj = Math.max(...rows.map((r) => r.objectName.length));
|
|
128
|
+
const wLine = Math.max(...rows.map((r) => r.line.length));
|
|
129
|
+
const wRule = Math.max(...rows.map((r) => r.rule.length));
|
|
130
|
+
const counts = {};
|
|
131
|
+
for (const k of countKeys)
|
|
132
|
+
counts[k] = 0;
|
|
133
|
+
for (const r of rows)
|
|
134
|
+
counts[r.status] = (counts[r.status] ?? 0) + 1;
|
|
135
|
+
const summary = countKeys.map((k) => `${counts[k]} ${k}`).join(', ');
|
|
136
|
+
const lines = [
|
|
137
|
+
'Review decisions from the source envelope (honor these):',
|
|
138
|
+
` ${summaryLabel}: ${summary}.`,
|
|
139
|
+
'',
|
|
140
|
+
];
|
|
141
|
+
for (const r of rows) {
|
|
142
|
+
const parts = [
|
|
143
|
+
r.id.padEnd(wId),
|
|
144
|
+
r.objectName.padEnd(wObj),
|
|
145
|
+
r.line.padEnd(wLine),
|
|
146
|
+
r.rule.padEnd(wRule),
|
|
147
|
+
`→ ${r.status}`,
|
|
148
|
+
];
|
|
149
|
+
// Collapse the run of spaces from an empty (line-less) column so we never
|
|
150
|
+
// emit a stray "line undefined"; padEnd of "" already keeps alignment.
|
|
151
|
+
lines.push(` ${parts.join(' ').replace(/\s+$/, '')}`);
|
|
152
|
+
}
|
|
153
|
+
return lines.join('\n');
|
|
154
|
+
}
|
|
155
|
+
function buildUpgradeScanDecisions(source) {
|
|
156
|
+
const items = source.interaction.items;
|
|
157
|
+
if (items.length === 0)
|
|
158
|
+
return null;
|
|
159
|
+
// Untriaged source (all open) → omit the block; nothing was decided.
|
|
160
|
+
if (items.every((it) => it.status === 'open'))
|
|
161
|
+
return null;
|
|
162
|
+
const findingById = new Map(source.content.findings.map((f) => [f.id, f]));
|
|
163
|
+
const rows = items.map((it) => {
|
|
164
|
+
const f = findingById.get(it.id);
|
|
165
|
+
return {
|
|
166
|
+
id: it.id,
|
|
167
|
+
objectName: f?.objectName ?? '<unknown>',
|
|
168
|
+
line: f?.line !== undefined ? `line ${f.line}` : '',
|
|
169
|
+
rule: f?.rule ?? '',
|
|
170
|
+
status: it.status,
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
return renderDecisionRows(rows, ['auto-fix', 'suppress', 'manual', 'open'], 'Applying your review')
|
|
174
|
+
// The upgrade-scan summary historically reads "... K still open"; keep that
|
|
175
|
+
// wording for the open bucket while the shared renderer emits "K open".
|
|
176
|
+
.replace(/(\d+) open\./, '$1 still open.');
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* cca-assessment: the per-object review decision is the `classification`
|
|
180
|
+
* (keep/fix/retire/redesign) the consultant assigned in the worksheet, carried
|
|
181
|
+
* in content.classifications[]. interaction.items[].status is the triage
|
|
182
|
+
* lifecycle (open/reviewed/classified/archived), not the fix decision — so we
|
|
183
|
+
* surface the classification as the decision token the skill must honor.
|
|
184
|
+
*/
|
|
185
|
+
function buildCcaDecisions(source) {
|
|
186
|
+
const classifications = source.content.classifications;
|
|
187
|
+
if (!classifications || classifications.length === 0)
|
|
188
|
+
return null;
|
|
189
|
+
const rows = classifications.map((c) => ({
|
|
190
|
+
id: c.id,
|
|
191
|
+
objectName: c.objectName,
|
|
192
|
+
line: '',
|
|
193
|
+
rule: c.objectType ?? '',
|
|
194
|
+
status: c.classification,
|
|
195
|
+
}));
|
|
196
|
+
return renderDecisionRows(rows, ['fix', 'redesign', 'retire', 'keep'], 'Applying your assessment');
|
|
197
|
+
}
|
|
70
198
|
export function validatePromotionSource(source, expectedPredecessor) {
|
|
71
199
|
// v0.7 — accept either a single predecessor or an array (multi-source).
|
|
72
200
|
// /abap-upgrade-fix accepts both upgrade-scan AND cca-assessment, etc.
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// cspeach-cli/src/projects/run-lease.ts
|
|
2
|
+
//
|
|
3
|
+
// Task 9 (agentic-flow, 2026-07-03) — run lease: concurrent-resume guard for
|
|
4
|
+
// plan envelopes.
|
|
5
|
+
//
|
|
6
|
+
// Problem: two sessions resuming the same plan fork sibling envelope
|
|
7
|
+
// versions silently — saveProject appends collision suffixes and the
|
|
8
|
+
// newest-version redirect tie-breaks on mtime, so neither session notices
|
|
9
|
+
// the other. Guarded mode's long unattended chains make a double-resume
|
|
10
|
+
// likelier. A lightweight lease file makes the second session STOP and say
|
|
11
|
+
// who is already running.
|
|
12
|
+
//
|
|
13
|
+
// Design choices (pinned by __tests__/run-lease.test.ts):
|
|
14
|
+
//
|
|
15
|
+
// - FAMILY-KEYED: the lock path strips the `-vN[-M]` version suffix (same
|
|
16
|
+
// convention as handoverPathFor in handover-md.ts), so the v3→v4 rotation
|
|
17
|
+
// every phase save performs does NOT drop the lease —
|
|
18
|
+
// `hr-plan-ab12-v3.cspeach.json` and `...-v4.cspeach.json` share
|
|
19
|
+
// `hr-plan-ab12.cspeach.lock`. The FAMILY is the thing being resumed;
|
|
20
|
+
// individual version files are just its snapshots.
|
|
21
|
+
//
|
|
22
|
+
// - SAME-SESSION IDEMPOTENT: re-acquiring with the sessionId already on the
|
|
23
|
+
// lease succeeds (and refreshes it). The plan chain re-enters
|
|
24
|
+
// preparePlanResume on EVERY phase via queueDispatch — without this the
|
|
25
|
+
// guarded chain would deadlock on its own lease.
|
|
26
|
+
//
|
|
27
|
+
// - STALENESS BY PID LIVENESS: a lease whose holder pid is not alive
|
|
28
|
+
// (process.kill(pid, 0) throws — works for liveness probing on Windows
|
|
29
|
+
// Node too) is silently replaced. A crashed or killed session therefore
|
|
30
|
+
// never needs manual cleanup.
|
|
31
|
+
//
|
|
32
|
+
// - BEST-EFFORT, NOT A SECURITY BOUNDARY: any fs failure while checking or
|
|
33
|
+
// writing the lease logs one line and reports `acquired: true` —
|
|
34
|
+
// availability over strictness; a resume must never be blocked by a
|
|
35
|
+
// broken lock file or a read-only workspace. This is a courtesy guard
|
|
36
|
+
// against accidental double-resume, nothing more.
|
|
37
|
+
import fs from 'node:fs';
|
|
38
|
+
import { basename, dirname, join } from 'node:path';
|
|
39
|
+
/**
|
|
40
|
+
* The lease file for an envelope path — keyed on the plan FAMILY base
|
|
41
|
+
* (version suffix `-vN` and any `-vN-M` collision sub-suffix stripped, same
|
|
42
|
+
* convention as handoverPathFor), so every version of one plan family maps
|
|
43
|
+
* to ONE lock file. Non-envelope paths fall back to `<path>.lock`.
|
|
44
|
+
*/
|
|
45
|
+
export function leasePathFor(envelopePath) {
|
|
46
|
+
const name = basename(envelopePath);
|
|
47
|
+
if (!name.endsWith('.cspeach.json'))
|
|
48
|
+
return `${envelopePath}.lock`;
|
|
49
|
+
const base = name.replace(/(?:-v\d+(?:-\d+)?)?\.cspeach\.json$/, '');
|
|
50
|
+
return join(dirname(envelopePath), `${base}.cspeach.lock`);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Liveness probe: signal 0 never delivers, only checks existence. On
|
|
54
|
+
* Windows Node this maps to an OpenProcess check — valid for liveness.
|
|
55
|
+
* EPERM means "alive but not ours to signal" — that IS alive.
|
|
56
|
+
*/
|
|
57
|
+
function isPidAlive(pid) {
|
|
58
|
+
try {
|
|
59
|
+
process.kill(pid, 0);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
return e.code === 'EPERM';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Try to take the run lease for a plan envelope's family.
|
|
68
|
+
*
|
|
69
|
+
* - no lease / stale lease (dead pid) / corrupt lock → acquired, silently
|
|
70
|
+
* - lease held by the SAME sessionId → acquired (idempotent re-entry;
|
|
71
|
+
* the file is refreshed with the current pid + timestamp)
|
|
72
|
+
* - lease held by a LIVE other session → `{ acquired: false, holder }`
|
|
73
|
+
* - `force: true` → acquired unconditionally (caller obtained user consent)
|
|
74
|
+
* - ANY fs error → `{ acquired: true }` after one log line (best-effort)
|
|
75
|
+
*
|
|
76
|
+
* The write is atomic (tmp + rename, same style as session/store.ts) so a
|
|
77
|
+
* concurrent reader never sees a half-written lock.
|
|
78
|
+
*/
|
|
79
|
+
export function acquireRunLease(envelopePath, sessionId, opts) {
|
|
80
|
+
const lockPath = leasePathFor(envelopePath);
|
|
81
|
+
try {
|
|
82
|
+
if (!opts?.force) {
|
|
83
|
+
let existing = null;
|
|
84
|
+
try {
|
|
85
|
+
const raw = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
86
|
+
if (typeof raw.pid === 'number' && typeof raw.sessionId === 'string') {
|
|
87
|
+
existing = { pid: raw.pid, sessionId: raw.sessionId, at: typeof raw.at === 'string' ? raw.at : '' };
|
|
88
|
+
}
|
|
89
|
+
// Malformed shape → existing stays null: garbage never holds a lease.
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
existing = null; // missing or unparseable — treat as absent
|
|
93
|
+
}
|
|
94
|
+
if (existing && existing.sessionId !== sessionId && isPidAlive(existing.pid)) {
|
|
95
|
+
return { acquired: false, holder: existing };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const holder = { pid: process.pid, sessionId, at: new Date().toISOString() };
|
|
99
|
+
const tmp = `${lockPath}.tmp`;
|
|
100
|
+
fs.writeFileSync(tmp, JSON.stringify(holder, null, 2), 'utf8');
|
|
101
|
+
try {
|
|
102
|
+
fs.renameSync(tmp, lockPath);
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
// Don't orphan the tmp file — best-effort cleanup, then let the outer
|
|
106
|
+
// catch handle the failure as usual (proceed-as-acquired).
|
|
107
|
+
try {
|
|
108
|
+
fs.unlinkSync(tmp);
|
|
109
|
+
}
|
|
110
|
+
catch { /* best-effort */ }
|
|
111
|
+
throw e;
|
|
112
|
+
}
|
|
113
|
+
return { acquired: true, holder };
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
// Best-effort by design: a broken lock dir must never block a resume.
|
|
117
|
+
opts?.log?.(`[plan] run-lease check skipped (${e instanceof Error ? e.message : String(e)}) — proceeding without the concurrency guard.`);
|
|
118
|
+
return { acquired: true };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Release the run lease for a plan envelope's family. Ownership-checked:
|
|
123
|
+
* a lock held by a DIFFERENT live pid (e.g. another session overrode us
|
|
124
|
+
* mid-run with user consent) is left untouched — we only ever delete our
|
|
125
|
+
* own, or a dead holder's, lock. Best-effort: all failures are swallowed
|
|
126
|
+
* (a leaked lease goes stale with the process anyway).
|
|
127
|
+
*/
|
|
128
|
+
export function releaseRunLease(envelopePath) {
|
|
129
|
+
const lockPath = leasePathFor(envelopePath);
|
|
130
|
+
try {
|
|
131
|
+
const raw = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
132
|
+
if (typeof raw.pid === 'number' && raw.pid !== process.pid && isPidAlive(raw.pid)) {
|
|
133
|
+
return; // live foreign holder — never delete someone else's lease
|
|
134
|
+
}
|
|
135
|
+
fs.unlinkSync(lockPath);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// missing / unreadable / undeletable — nothing useful to do
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Chain-aware release for the REPL's plan-resume legs: when a follow-up
|
|
143
|
+
* dispatch is QUEUED (`queuedDispatch` non-empty — the pendingDispatch slot
|
|
144
|
+
* still holds the harness-built next `--resume` command), the lease is HELD
|
|
145
|
+
* across the gap. Releasing between auto-chained phases would let a second
|
|
146
|
+
* session grab the family lock mid-chain (headless: the chain silently
|
|
147
|
+
* stops on re-entry; interactive: the user is prompted mid-auto-chain, and
|
|
148
|
+
* a takeover forks the plan — the exact failure the lease exists to
|
|
149
|
+
* prevent). The re-entry's same-session re-acquire is idempotent, so
|
|
150
|
+
* holding through the gap is free. Only when the chain truly ends (nothing
|
|
151
|
+
* queued) is the lease released.
|
|
152
|
+
*/
|
|
153
|
+
export function releaseRunLeaseUnlessChained(envelopePath, queuedDispatch) {
|
|
154
|
+
if (queuedDispatch)
|
|
155
|
+
return; // chain continues — hold the lease across the dispatch
|
|
156
|
+
releaseRunLease(envelopePath);
|
|
157
|
+
}
|