@cspeach/cli 0.9.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +228 -26
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/tool-dispatch.js +15 -0
  7. package/dist/approvals/canonical.js +91 -0
  8. package/dist/approvals/jwt.js +39 -2
  9. package/dist/approvals/op-labels.js +124 -0
  10. package/dist/approvals/render.js +42 -36
  11. package/dist/auth/org-anthropic-key.js +25 -0
  12. package/dist/classifier/client.js +18 -3
  13. package/dist/cli.js +15 -0
  14. package/dist/commands/compact.js +28 -2
  15. package/dist/commands/config-set.js +284 -0
  16. package/dist/commands/config-show.js +20 -0
  17. package/dist/commands/export-audit.js +43 -0
  18. package/dist/commands/help.js +5 -0
  19. package/dist/commands/login.js +31 -14
  20. package/dist/commands/plan-audit-evidence.js +266 -0
  21. package/dist/commands/plan-audit.js +692 -0
  22. package/dist/commands/plan-chain.js +671 -0
  23. package/dist/commands/plan-continue.js +179 -0
  24. package/dist/commands/plan-gate.js +154 -0
  25. package/dist/commands/plan-model-tier.js +83 -0
  26. package/dist/commands/plan-resume.js +728 -46
  27. package/dist/config/loader.js +223 -5
  28. package/dist/config/model-defaults.js +14 -0
  29. package/dist/cost/pricing.js +27 -1
  30. package/dist/doctor/checks/_http-probe.js +1 -0
  31. package/dist/doctor/checks/cert.js +14 -3
  32. package/dist/doctor/checks/sap.js +30 -8
  33. package/dist/doctor/checks/system-roles.js +41 -0
  34. package/dist/doctor/checks/zcspeach.js +19 -4
  35. package/dist/doctor/run.js +2 -0
  36. package/dist/models/resolve.js +61 -0
  37. package/dist/models/server-config.js +155 -0
  38. package/dist/one-shot.js +76 -6
  39. package/dist/projects/answer-blockers.js +137 -0
  40. package/dist/projects/extract-cca.js +111 -17
  41. package/dist/projects/extract-modernize.js +4 -2
  42. package/dist/projects/extract-plan.js +184 -37
  43. package/dist/projects/extract-spec-gap.js +34 -7
  44. package/dist/projects/extract-test-coverage.js +4 -2
  45. package/dist/projects/extract-upgrade.js +116 -23
  46. package/dist/projects/handover-md.js +195 -0
  47. package/dist/projects/index.js +5 -2
  48. package/dist/projects/merge-cca.js +292 -0
  49. package/dist/projects/merge-upgrade.js +173 -0
  50. package/dist/projects/migration.js +103 -1
  51. package/dist/projects/output-paths.js +27 -0
  52. package/dist/projects/plan-run.js +285 -27
  53. package/dist/projects/plan-schema.js +136 -3
  54. package/dist/projects/promote-command.js +25 -2
  55. package/dist/projects/promote.js +128 -0
  56. package/dist/projects/run-lease.js +157 -0
  57. package/dist/projects/save-command.js +259 -21
  58. package/dist/projects/status.js +3 -1
  59. package/dist/projects/validate.js +1 -1
  60. package/dist/projects/workspace.js +164 -20
  61. package/dist/renderer/notices.js +64 -0
  62. package/dist/renderer/progress-chatter.js +8 -0
  63. package/dist/renderer/status-footer.js +22 -12
  64. package/dist/renderer/thinking-heartbeat.js +64 -8
  65. package/dist/renderer/todo-block.js +51 -0
  66. package/dist/renderer/tool-widget.js +55 -4
  67. package/dist/renderer/tty.js +43 -4
  68. package/dist/renderer/verify-chain.js +77 -0
  69. package/dist/repl/at-picker.js +60 -7
  70. package/dist/repl/bracketed-paste.js +28 -19
  71. package/dist/repl/builtin-commands.js +42 -0
  72. package/dist/repl/current-transport.js +10 -0
  73. package/dist/repl/early-line-buffer.js +68 -0
  74. package/dist/repl/history.js +86 -0
  75. package/dist/repl/ink-stdin-guard.js +64 -0
  76. package/dist/repl/inquirer-guard.js +70 -5
  77. package/dist/repl/mode-ceiling.js +16 -0
  78. package/dist/repl/mode-cycle.js +104 -0
  79. package/dist/repl/numbered-menu.js +131 -0
  80. package/dist/repl/post-turn-status.js +26 -6
  81. package/dist/repl/rule8-detector.js +17 -2
  82. package/dist/repl/safety-confirm.js +111 -2
  83. package/dist/repl/safety-mode-state.js +19 -3
  84. package/dist/repl/slash-completer.js +5 -0
  85. package/dist/repl/slash-picker.js +10 -15
  86. package/dist/repl.js +1232 -95
  87. package/dist/rewind/candidates.js +194 -0
  88. package/dist/rewind/cli.js +137 -0
  89. package/dist/rewind/format.js +27 -0
  90. package/dist/rewind/restore.js +245 -0
  91. package/dist/router/classifier.js +150 -6
  92. package/dist/sap/capability-matrix.js +20 -0
  93. package/dist/sap/capability-matrix.json +11236 -0
  94. package/dist/sap/capability.js +146 -0
  95. package/dist/sap/connection-manager.js +19 -1
  96. package/dist/sap/onboarding.js +42 -4
  97. package/dist/session/audit-export.js +459 -0
  98. package/dist/session/context-report.js +163 -0
  99. package/dist/session/pending.js +27 -0
  100. package/dist/session/recap.js +160 -0
  101. package/dist/skill-catalog.js +51 -40
  102. package/dist/skills/bundled-skills.js +272 -1
  103. package/dist/skills/promotion-dispatch.js +23 -0
  104. package/dist/tools/_command-shared.js +36 -12
  105. package/dist/tools/_filesystem-shared.js +139 -4
  106. package/dist/tools/_flag.js +25 -0
  107. package/dist/tools/approval.js +177 -26
  108. package/dist/tools/ask-question.js +400 -7
  109. package/dist/tools/capability/tool.js +74 -0
  110. package/dist/tools/dispatch-skill.js +22 -1
  111. package/dist/tools/extend-model/anchored-insert.js +1414 -0
  112. package/dist/tools/extend-model/tool.js +340 -0
  113. package/dist/tools/filesystem/extract-document.js +57 -0
  114. package/dist/tools/filesystem/file-edit.js +12 -2
  115. package/dist/tools/filesystem/file-read.js +2 -2
  116. package/dist/tools/filesystem/file-write.js +11 -2
  117. package/dist/tools/filesystem/glob.js +11 -0
  118. package/dist/tools/filesystem/grep.js +10 -0
  119. package/dist/tools/filesystem/read-document.js +107 -0
  120. package/dist/tools/fiori/apply.js +50 -0
  121. package/dist/tools/fiori/bin.js +3 -0
  122. package/dist/tools/fiori/catalog/index.js +27 -0
  123. package/dist/tools/fiori/catalog/value-help.js +230 -0
  124. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  125. package/dist/tools/fiori/cli.js +71 -0
  126. package/dist/tools/fiori/deploy-config.js +73 -0
  127. package/dist/tools/fiori/fe-extend.js +76 -0
  128. package/dist/tools/fiori/fe-scaffold.js +71 -0
  129. package/dist/tools/fiori/floorplan-map.js +19 -0
  130. package/dist/tools/fiori/i18n.js +39 -0
  131. package/dist/tools/fiori/manifest.js +70 -0
  132. package/dist/tools/fiori/render.js +77 -0
  133. package/dist/tools/fiori/samples/data/index.json +13602 -0
  134. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  135. package/dist/tools/fiori/samples/loader.js +248 -0
  136. package/dist/tools/fiori/samples/search.js +63 -0
  137. package/dist/tools/fiori/samples/types.js +2 -0
  138. package/dist/tools/fiori/scaffold.js +39 -0
  139. package/dist/tools/fiori/smoke/assertions.js +74 -0
  140. package/dist/tools/fiori/smoke/browser.js +52 -0
  141. package/dist/tools/fiori/smoke/driver.js +89 -0
  142. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  143. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  144. package/dist/tools/fiori/tools.js +681 -0
  145. package/dist/tools/fiori/types.js +1 -0
  146. package/dist/tools/local-build.js +86 -0
  147. package/dist/tools/local-files.js +31 -0
  148. package/dist/tools/project/_merge-shared.js +68 -0
  149. package/dist/tools/project/cca_merge.js +164 -0
  150. package/dist/tools/project/playbook_get.js +1 -1
  151. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  152. package/dist/tools/sap-read.js +132 -20
  153. package/dist/tools/sap-write.js +550 -21
  154. package/dist/tools/shell/shell_exec.js +41 -6
  155. package/dist/tools/snapshot.js +63 -14
  156. package/dist/tools/subagent/agent_run.js +27 -3
  157. package/dist/tools/subagent/background_run.js +17 -1
  158. package/dist/tools/todo.js +144 -0
  159. package/dist/tools/transport-resolution.js +86 -0
  160. package/dist/tools/transport.js +224 -5
  161. package/dist/tools/write-mode.js +4 -0
  162. package/dist/ui/app.js +378 -21
  163. package/dist/ui/approval-modal.js +49 -16
  164. package/dist/ui/ask-question-emitter.js +14 -0
  165. package/dist/ui/body.js +13 -0
  166. package/dist/ui/context-grid.js +108 -0
  167. package/dist/ui/footer.js +120 -27
  168. package/dist/ui/header.js +7 -0
  169. package/dist/ui/line-resolution.js +35 -8
  170. package/dist/ui/rewind-emitter.js +10 -0
  171. package/dist/ui/rewind-panel.js +81 -0
  172. package/dist/ui/sap-state-store.js +1 -0
  173. package/dist/ui/session-timeline.js +1 -0
  174. package/dist/ui/status-line.js +43 -0
  175. package/dist/ui/text-input.js +214 -0
  176. package/dist/ui/todo-emitter.js +25 -0
  177. package/dist/ui/todo-panel.js +64 -0
  178. package/dist/ui/turn-status-emitter.js +50 -4
  179. package/dist/ui/turn-status.js +18 -3
  180. package/dist/ui/widgets/ask-form.js +242 -0
  181. package/dist/ui/widgets/ask-question-modal.js +21 -8
  182. package/package.json +22 -3
  183. package/bench/README.md +0 -78
  184. package/bench/prompts/abap-document-cds.md +0 -44
  185. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  186. package/bench/prompts/abap-test-method.md +0 -42
  187. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  188. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  189. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  190. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  191. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  192. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  193. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  194. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  195. package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
@@ -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
- /** Keep in sync with ItemStatusByType['plan'] in types.ts. */
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
- const extendedSkillInput = buildExtendedInput(promotedFrom);
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
  }
@@ -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
+ }