@cspeach/cli 0.8.0 → 0.9.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.
@@ -0,0 +1,308 @@
1
+ // cspeach-cli/src/commands/plan-resume.ts
2
+ //
3
+ // `/abap-plan --resume @<plan-file>` — B3 (2026-06-06).
4
+ //
5
+ // Split around the model turn:
6
+ //
7
+ // preparePlanResume (token-free, BEFORE runTurn)
8
+ // resolve @token → load + validate envelope → compute next eligible
9
+ // phase → print the tracker board → inline the phase's declared rule
10
+ // files → return the bounded LLM prompt. Terminal states (plan
11
+ // complete / everything blocked) print and return null — no model
12
+ // turn happens at all.
13
+ //
14
+ // finishPlanResume (AFTER runTurn)
15
+ // collect the turn's assistant text (ALL messages — the manifest may
16
+ // precede a closing ask_question, see turn-assistant-text.ts) →
17
+ // extract the LAST csforge:plan-manifest block → build the version
18
+ // N+1 revision (same id, history appended) → save → print the
19
+ // updated tracker + next-step hint.
20
+ //
21
+ // The save hook inside runTurn is suppressed for resume turns
22
+ // (RunTurnParams.suppressSaveHook) — it would otherwise offer to save a
23
+ // duplicate NEW envelope with a fresh id, breaking the version chain.
24
+ // Resume persistence is unconditional: the envelope is the only state
25
+ // that survives the session, so losing the write-back breaks the plan.
26
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
27
+ import { basename, dirname, join } from 'node:path';
28
+ import { readProjectFile } from '../projects/status.js';
29
+ import { resolveAtTokenAsync, formatProjectFileList, ensureWorkspace } from '../projects/workspace.js';
30
+ import { parsePlanContent } from '../projects/plan-schema.js';
31
+ import { statusesFromItems, computeNextPhase, renderPlanTracker, buildPlanRevision, } from '../projects/plan-run.js';
32
+ import { extractPlan } from '../projects/extract-plan.js';
33
+ import { saveProject } from '../projects/save.js';
34
+ import { validateEnvelope } from '../projects/validate.js';
35
+ import { collectTurnAssistantText } from '../agent/turn-assistant-text.js';
36
+ import { getAuthorIdentity } from '../agent/loop.js';
37
+ export async function preparePlanResume(args) {
38
+ const tokens = args.body
39
+ .split(/\s+/)
40
+ .filter((tok) => tok.startsWith('@'))
41
+ .map((tok) => tok.slice(1))
42
+ .filter((tok) => tok.length > 0);
43
+ if (tokens.length === 0) {
44
+ args.log('Usage: /abap-plan --resume @<plan>.cspeach.json');
45
+ args.log('Bare names work too — e.g. @hr-extract-plan — resolved from the workspace folder.');
46
+ return null;
47
+ }
48
+ const resolved = await resolveAtTokenAsync(tokens[0], args.cwd);
49
+ let path;
50
+ if (resolved.kind === 'path') {
51
+ path = resolved.path;
52
+ }
53
+ else if (resolved.kind === 'notFound') {
54
+ args.log(`--resume: no file matching '${resolved.token}' in workspace ${resolved.workspace}`);
55
+ args.log('Run /files to see available .cspeach.json files.');
56
+ return null;
57
+ }
58
+ else {
59
+ // Version-less resume: a base name / short id matches every version of one
60
+ // plan family. Resolve to the newest instead of erroring — the user (and
61
+ // the auto-fire dispatch) should never have to name a version number.
62
+ const newestOfFamily = pickNewestPlanVersion(resolved.matches.map((m) => m.path));
63
+ if (newestOfFamily) {
64
+ args.log(`↪ resolved @${tokens[0]} to the latest version ${basename(newestOfFamily)}`);
65
+ path = newestOfFamily;
66
+ }
67
+ else {
68
+ args.log(`--resume: '${tokens[0]}' is ambiguous — multiple matches:`);
69
+ args.log(formatProjectFileList(resolved.matches, ''));
70
+ args.log('Use a more specific @<fragment> to disambiguate.');
71
+ return null;
72
+ }
73
+ }
74
+ let envelope;
75
+ try {
76
+ envelope = readProjectFile(path);
77
+ }
78
+ catch (e) {
79
+ args.log(`--resume: ${e instanceof Error ? e.message : String(e)}`);
80
+ return null;
81
+ }
82
+ // 2026-06-06 (live-smoke UX): users should never have to remember which
83
+ // vN file is current — resuming a stale version re-runs already-validated
84
+ // phases. If a newer version of the SAME envelope (matching id) exists
85
+ // next to the picked file, silently redirect to it with a note.
86
+ const newest = findNewestVersion(path, envelope);
87
+ if (newest) {
88
+ args.log(`↪ newer version found — resuming ${basename(newest.path)} (you picked ${basename(path)})`);
89
+ path = newest.path;
90
+ envelope = newest.envelope;
91
+ }
92
+ if (envelope.artefactType !== 'plan') {
93
+ args.log(`--resume expects a plan envelope; got ${envelope.artefactType}.`);
94
+ args.log(`For ${envelope.artefactType} files use --status / --from instead.`);
95
+ return null;
96
+ }
97
+ // Defense-in-depth: top-level validation passed in readProjectFile, but
98
+ // the content payload is only checked by the Zod schema.
99
+ const pc = parsePlanContent(envelope.content);
100
+ if (!pc.ok) {
101
+ args.log('--resume: plan content failed validation — fix the file before resuming:');
102
+ for (const e of pc.errors)
103
+ args.log(` ${e}`);
104
+ return null;
105
+ }
106
+ const content = pc.content;
107
+ const statuses = statusesFromItems(envelope.interaction.items, content.phases);
108
+ const next = computeNextPhase(content.phases, statuses);
109
+ args.log('');
110
+ args.log(renderPlanTracker({
111
+ title: envelope.title,
112
+ version: envelope.version,
113
+ content,
114
+ statuses,
115
+ currentId: next?.id ?? null,
116
+ }));
117
+ args.log('');
118
+ if (!next) {
119
+ const allValidated = content.phases.every((p) => statuses[p.id] === 'validated');
120
+ if (allValidated) {
121
+ args.log('Plan complete — every phase is validated.');
122
+ args.log('Consider /abap-preflight on the produced transport(s) before release.');
123
+ }
124
+ else {
125
+ const blocked = content.phases.filter((p) => statuses[p.id] === 'blocked').map((p) => p.id);
126
+ args.log(`No eligible phase. Blocked: ${blocked.join(', ') || '(none)'} — remaining phases wait on them.`);
127
+ args.log('Unblock (fix + edit the envelope status back to todo) and resume again.');
128
+ }
129
+ return null;
130
+ }
131
+ // Inline the phase's declared rule files (coarse v1: whole files).
132
+ // Paths resolve against cwd — dogfood runs sit inside a repo carrying
133
+ // .claude/rules/. A missing file is noted, not fatal: the skill falls
134
+ // back to its built-in Forge defaults.
135
+ const ruleBlocks = [];
136
+ for (const rel of next.manifest.rules) {
137
+ try {
138
+ const text = readFileSync(join(args.cwd, rel), 'utf8');
139
+ ruleBlocks.push(`<rule file="${rel}">\n${text}\n</rule>`);
140
+ }
141
+ catch {
142
+ ruleBlocks.push(`<rule file="${rel}" missing="true"/> <!-- not found locally — apply built-in Forge defaults -->`);
143
+ }
144
+ }
145
+ const planState = JSON.stringify({ title: envelope.title, version: envelope.version, statuses, content }, null, 2);
146
+ const llmPrompt = [
147
+ `Resume execution of the project plan "${envelope.title}" (envelope v${envelope.version}).`,
148
+ '',
149
+ `Execute Mode 2 of the abap-plan skill for phase "${next.id}" ONLY — it is the computed next eligible phase. Do not execute any other phase unless the user explicitly picks "continue" at the phase-end question.`,
150
+ '',
151
+ '<plan_state>',
152
+ planState,
153
+ '</plan_state>',
154
+ '',
155
+ '<phase_rules>',
156
+ ...ruleBlocks,
157
+ '</phase_rules>',
158
+ '',
159
+ // 2026-06-06 live-smoke lesson #3: the model asked the continuation
160
+ // question FIRST, then ended the turn on the user's "exit" answer with
161
+ // a one-line acknowledgment — and the whole phase result was lost. The
162
+ // ordering must be explicit and the consequence named.
163
+ 'CRITICAL — write-back ordering: emit the COMPLETE updated <!-- csforge:plan-manifest --> block (full phase list, statuses entry for every phase, this phase\'s work filled in — including work.notes with the compact decision register when the phase produced decisions rather than SAP objects) BEFORE the phase-end continuation question. The manifest block is how the result is persisted; a turn that ends without it LOSES the phase. After the user answers "exit", reply with at most one short line — the manifest must already be in the transcript by then.',
164
+ ].join('\n');
165
+ return { path, envelope, statuses, nextPhaseId: next.id, llmPrompt };
166
+ }
167
+ /**
168
+ * Given a set of candidate file paths (e.g. the matches from an ambiguous
169
+ * @token resolution), return the newest version IF they are all versions of
170
+ * ONE plan family (`<base>-v<N>[-<sub>].cspeach.json`), else null. Lets a
171
+ * version-less resume token (`@<base>` or a short id matching every version)
172
+ * resolve to the current file instead of erroring "ambiguous". Pure —
173
+ * deterministic ordering, no fs / mtime.
174
+ */
175
+ export function pickNewestPlanVersion(paths) {
176
+ // Sibling: findNewestVersion (below) uses the same filename convention with a
177
+ // different capture shape (prefix-based + mtime) for the post-read redirect.
178
+ const re = /^(.+)-v(\d+)(?:-(\d+))?\.cspeach\.json$/;
179
+ const parsed = paths.map((p) => {
180
+ const m = re.exec(basename(p));
181
+ return m ? { path: p, base: m[1], version: Number(m[2]), sub: m[3] ? Number(m[3]) : 0 } : null;
182
+ });
183
+ if (parsed.length === 0 || parsed.some((x) => x === null))
184
+ return null;
185
+ const items = parsed;
186
+ const base0 = items[0].base;
187
+ if (items.some((x) => x.base !== base0))
188
+ return null;
189
+ items.sort((a, b) =>
190
+ // Tiebreak: lexically-greater path wins — deterministic, arbitrary, never
191
+ // reached in practice (version+sub are always distinct siblings from saveProject).
192
+ b.version - a.version || b.sub - a.sub || (a.path < b.path ? 1 : -1));
193
+ return items[0].path;
194
+ }
195
+ /**
196
+ * True when a queued dispatch command is a `/abap-plan --resume …`. The REPL
197
+ * uses this to clear session.messages before re-entering, so the auto-fired
198
+ * next phase runs in fresh, bounded context (the cheap path).
199
+ */
200
+ export function isPlanResumeCommand(cmd) {
201
+ const c = cmd.trim();
202
+ return /^\/abap-plan\b/.test(c) && /(^|\s)--resume(\s|$)/.test(c);
203
+ }
204
+ /**
205
+ * Find the newest sibling version of the envelope at `path` — same
206
+ * filename family (`<slug>-<shortid>-vN[...]`) AND same envelope id (the
207
+ * filename check is just a cheap pre-filter; slug collisions are settled
208
+ * by the id). Returns null when `path` is already the newest.
209
+ */
210
+ export function findNewestVersion(path, picked) {
211
+ const fam = /^(.*-v)(\d+)(?:-\d+)?\.cspeach\.json$/.exec(basename(path));
212
+ if (!fam)
213
+ return null;
214
+ const familyPrefix = fam[1];
215
+ const dir = dirname(path);
216
+ let candidates;
217
+ try {
218
+ candidates = readdirSync(dir)
219
+ .map((name) => {
220
+ const m = new RegExp(`^${familyPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)(?:-\\d+)?\\.cspeach\\.json$`).exec(name);
221
+ if (!m)
222
+ return null;
223
+ const full = join(dir, name);
224
+ return { full, version: Number(m[1]), mtimeMs: statSync(full).mtimeMs };
225
+ })
226
+ .filter((c) => c !== null)
227
+ .sort((a, b) => b.version - a.version || b.mtimeMs - a.mtimeMs);
228
+ }
229
+ catch {
230
+ return null;
231
+ }
232
+ for (const c of candidates) {
233
+ if (c.full === path)
234
+ break; // picked file is already the newest valid one
235
+ try {
236
+ const env = readProjectFile(c.full);
237
+ if (env.artefactType === 'plan' && env.id === picked.id && env.version > picked.version) {
238
+ return { path: c.full, envelope: env };
239
+ }
240
+ }
241
+ catch {
242
+ // Unreadable sibling — skip, keep looking.
243
+ }
244
+ }
245
+ return null;
246
+ }
247
+ export async function finishPlanResume(args) {
248
+ const text = args.textOverride ?? collectTurnAssistantText(args.messages, args.messagesStart);
249
+ if (text.trim().length === 0) {
250
+ args.log('');
251
+ args.log('[plan] turn produced no assistant output — plan envelope UNCHANGED.');
252
+ args.log(`[plan] re-run: /abap-plan --resume @${basename(args.prepared.path)}`);
253
+ return;
254
+ }
255
+ let extract;
256
+ try {
257
+ extract = extractPlan(text);
258
+ }
259
+ catch (e) {
260
+ // Loud by design: the phase may have built real SAP objects, but the
261
+ // envelope write-back failed — the user must know state and file have
262
+ // diverged before the next resume.
263
+ args.log('');
264
+ args.log(`[plan] PHASE RESULT NOT PERSISTED — ${e instanceof Error ? e.message : String(e)}`);
265
+ args.log('[plan] The plan envelope is unchanged. Check what the phase actually built');
266
+ args.log('[plan] (transport, activated objects), update the envelope statuses by hand or');
267
+ args.log(`[plan] re-run: /abap-plan --resume @${basename(args.prepared.path)}`);
268
+ return;
269
+ }
270
+ const revision = buildPlanRevision(args.prepared.envelope, extract, getAuthorIdentity(), new Date().toISOString());
271
+ const check = validateEnvelope(JSON.parse(JSON.stringify(revision)));
272
+ if (!check.ok) {
273
+ args.log('');
274
+ args.log(`[plan] PHASE RESULT NOT PERSISTED — revision failed validation: ${check.error.message}`);
275
+ return;
276
+ }
277
+ let outDir;
278
+ try {
279
+ outDir = await ensureWorkspace();
280
+ }
281
+ catch {
282
+ outDir = process.cwd();
283
+ }
284
+ const savedPath = await saveProject(revision, { cwd: outDir });
285
+ const statuses = extract.statuses;
286
+ const next = computeNextPhase(extract.content.phases, statuses);
287
+ args.log('');
288
+ args.log(`Plan updated: ${savedPath}`);
289
+ args.log('');
290
+ args.log(renderPlanTracker({
291
+ title: revision.title,
292
+ version: revision.version,
293
+ content: extract.content,
294
+ statuses,
295
+ currentId: null,
296
+ }));
297
+ args.log('');
298
+ if (next) {
299
+ args.log(`Next: ${next.id} (${next.delegateTo}) — run /abap-plan --resume @${basename(savedPath)} in a fresh session.`);
300
+ }
301
+ else if (extract.content.phases.every((p) => statuses[p.id] === 'validated')) {
302
+ args.log('Plan complete — every phase validated. Consider /abap-preflight before release.');
303
+ }
304
+ else {
305
+ const blocked = extract.content.phases.filter((p) => statuses[p.id] === 'blocked').map((p) => p.id);
306
+ args.log(`No eligible next phase. Blocked: ${blocked.join(', ')}. Unblock, then resume again.`);
307
+ }
308
+ }
@@ -11,7 +11,7 @@ export function isValidWriteMode(v) {
11
11
  }
12
12
  const DEFAULT_CONFIG = {
13
13
  proxy_url: 'https://api.cspeach.dev',
14
- default_model: 'claude-opus-4-7',
14
+ default_model: 'claude-opus-4-8',
15
15
  effort: 'xhigh',
16
16
  telemetry: 'minimal',
17
17
  sap: {},
@@ -65,7 +65,12 @@ function sanitiseCompact(raw) {
65
65
  export async function loadConfig() {
66
66
  try {
67
67
  const raw = await fs.readFile(configFile(), 'utf-8');
68
- const parsed = toml.parse(raw);
68
+ // Strip a leading UTF-8 BOM (U+FEFF). Node's 'utf-8' read does NOT remove it,
69
+ // and @iarna/toml throws "Unknown character 65279" on a BOM at row 1 col 1.
70
+ // PowerShell's Set-Content / Out-File add a BOM by default, so a config edited
71
+ // on Windows can crash the REPL on next launch — tolerate it instead.
72
+ const noBom = raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw;
73
+ const parsed = toml.parse(noBom);
69
74
  // Shallow merge top-level, but deep-merge `ui` and `classifier` so a user
70
75
  // config that omits the new `ui.rendering` key still receives the default.
71
76
  // Sanitize the optional shell_exec.allow array. The interface types it as
@@ -11,16 +11,25 @@
11
11
  * BOTH this file AND the proxy's version. Verify on the public pricing page:
12
12
  * https://www.anthropic.com/pricing
13
13
  *
14
- * Last verified 2026-05-12. Same as proxy.
14
+ * Last verified 2026-06-07. Same as proxy.
15
+ *
16
+ * 2026-06-07 SYNC: e294cff switched the default model to claude-opus-4-8 AND
17
+ * applied the 3x Opus correction, but only to the proxy copy — this CLI copy
18
+ * was left stale, so every opus-4-8 turn was an unknown model (footer $0) and
19
+ * opus-4-5/4-6/4-7 still carried the legacy $15/$75 (~3x overstated). Brought
20
+ * back in line with cspeach-proxy/src/anthropic/pricing.ts below.
15
21
  *
16
22
  * Unknown models → returns null + records the model name to a process-level
17
23
  * Set so we surface them as an obvious warning rather than silently zeroing.
18
24
  */
19
25
  const PRICING = {
20
- // Claude Opus 4.x family — input 15, output 75, cache_read 1.50, cache_write 18.75
21
- 'claude-opus-4-7': { inputPer1M: 15.00, outputPer1M: 75.00, cacheReadPer1M: 1.50, cacheCreatePer1M: 18.75 },
22
- 'claude-opus-4-6': { inputPer1M: 15.00, outputPer1M: 75.00, cacheReadPer1M: 1.50, cacheCreatePer1M: 18.75 },
23
- 'claude-opus-4-5': { inputPer1M: 15.00, outputPer1M: 75.00, cacheReadPer1M: 1.50, cacheCreatePer1M: 18.75 },
26
+ // Claude Opus 4.5+ — input 5, output 25, cache_read 0.50, cache_write 6.25 (1.25x input).
27
+ // 2026-06-07 correction: 4.5/4.6/4.7 were carried at the LEGACY $15/$75; actual
28
+ // since Opus 4.5 is $5/$25. Only claude-opus-4 (4.0/4.1) genuinely was $15/$75.
29
+ 'claude-opus-4-8': { inputPer1M: 5.00, outputPer1M: 25.00, cacheReadPer1M: 0.50, cacheCreatePer1M: 6.25 },
30
+ 'claude-opus-4-7': { inputPer1M: 5.00, outputPer1M: 25.00, cacheReadPer1M: 0.50, cacheCreatePer1M: 6.25 },
31
+ 'claude-opus-4-6': { inputPer1M: 5.00, outputPer1M: 25.00, cacheReadPer1M: 0.50, cacheCreatePer1M: 6.25 },
32
+ 'claude-opus-4-5': { inputPer1M: 5.00, outputPer1M: 25.00, cacheReadPer1M: 0.50, cacheCreatePer1M: 6.25 },
24
33
  'claude-opus-4': { inputPer1M: 15.00, outputPer1M: 75.00, cacheReadPer1M: 1.50, cacheCreatePer1M: 18.75 },
25
34
  // Claude Sonnet 4.x family — input 3, output 15, cache_read 0.30, cache_write 3.75
26
35
  'claude-sonnet-4-6': { inputPer1M: 3.00, outputPer1M: 15.00, cacheReadPer1M: 0.30, cacheCreatePer1M: 3.75 },
@@ -11,6 +11,7 @@ const SUBJECT_BY_TYPE = {
11
11
  'cca-assessment': (t) => `Custom code assessment — ${t}`,
12
12
  'modernize-result': (t) => `Modernization result — ${t}`,
13
13
  'test-coverage': (t) => `Test coverage report — ${t}`,
14
+ 'plan': (t) => `Project plan — ${t}`,
14
15
  };
15
16
  const BODY_BY_TYPE = {
16
17
  'spec-gap': (a) => `Attached is the gap analysis for the ${a.title}.\n${a.itemCount} ${a.itemNoun} across business / data / authorisation / edge cases.`,
@@ -24,6 +25,7 @@ const BODY_BY_TYPE = {
24
25
  'cca-assessment': (a) => `Attached is the custom code assessment for ${a.title}.\n${a.itemCount} ${a.itemNoun} classified — review keep / fix / retire / redesign per object.`,
25
26
  'modernize-result': (a) => `Attached is the modernization result for ${a.title}.\n${a.itemCount} ${a.itemNoun} attempted — review applied / skipped / failed per object before sign-off.`,
26
27
  'test-coverage': (a) => `Attached is the test coverage report for ${a.title}.\n${a.itemCount} test ${a.itemNoun} generated — review the targets + method counts before merging.`,
28
+ 'plan': (a) => `Attached is the project plan for ${a.title}.\n${a.itemCount} ${a.itemNoun} ordered bottom-up — each phase runs in its own session via /abap-plan --resume.`,
27
29
  };
28
30
  export function renderEmailTemplate(a) {
29
31
  const subject = SUBJECT_BY_TYPE[a.artefactType](a.title);
@@ -0,0 +1,85 @@
1
+ import { parsePlanContent, PLAN_PHASE_STATUSES } from './plan-schema.js';
2
+ const MANIFEST_RE = /<!--\s*csforge:plan-manifest\s*\n([\s\S]*?)\n\s*-->/g;
3
+ export function extractPlan(markdown) {
4
+ // Last block wins — a continue-in-session turn emits one block per phase.
5
+ const re = new RegExp(MANIFEST_RE.source, MANIFEST_RE.flags);
6
+ let m;
7
+ let last = null;
8
+ while ((m = re.exec(markdown)) !== null)
9
+ last = m;
10
+ if (!last) {
11
+ throw new Error('No <!-- csforge:plan-manifest --> block found in skill output');
12
+ }
13
+ let parsed;
14
+ try {
15
+ parsed = JSON.parse(last[1]);
16
+ }
17
+ catch (e) {
18
+ throw new Error(`plan-manifest block is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
19
+ }
20
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
21
+ throw new Error('plan-manifest block must be a JSON object');
22
+ }
23
+ const obj = parsed;
24
+ if (typeof obj.title !== 'string' || obj.title.trim().length === 0) {
25
+ throw new Error('plan-manifest is missing a non-empty "title"');
26
+ }
27
+ // 2026-06-06 live-smoke lesson #2: the model cannot know the consumed
28
+ // spec-gap's real path/sha — that provenance lives CLI-side in the
29
+ // envelope's promotedFrom (threaded by the --from save flow). Models
30
+ // emit placeholders like {"path":"@equip","sha256":""}, and a junk
31
+ // provenance pointer must not cost the whole plan: strip it.
32
+ const rawContent = obj.content;
33
+ if (rawContent && typeof rawContent === 'object' && rawContent.from?.specGap) {
34
+ const sha = String(rawContent.from.specGap.sha256 ?? '');
35
+ if (!/^[0-9a-f]{64}$/.test(sha))
36
+ delete rawContent.from.specGap;
37
+ }
38
+ // 2026-06-06 live-smoke lesson #4 (Postel): normalise model-emitted
39
+ // `work` blocks instead of rejecting them. Models naturally write
40
+ // `"transport": null` for "none" (schema fields are optional, NOT
41
+ // nullable) and a structured OBJECT for notes (a design phase's
42
+ // decision register) — both reasonable; both killed a fully correct
43
+ // phase write-back. null -> absent; object notes -> JSON string.
44
+ const rawPhases = rawContent?.phases;
45
+ if (Array.isArray(rawPhases)) {
46
+ for (const p of rawPhases) {
47
+ const w = p?.work;
48
+ if (!w || typeof w !== 'object')
49
+ continue;
50
+ for (const k of ['generated', 'transport', 'snapshot', 'notes']) {
51
+ if (w[k] === null)
52
+ delete w[k];
53
+ }
54
+ if (w.notes !== undefined && typeof w.notes !== 'string') {
55
+ w.notes = JSON.stringify(w.notes);
56
+ }
57
+ }
58
+ }
59
+ const pc = parsePlanContent(obj.content);
60
+ if (!pc.ok) {
61
+ throw new Error(`plan-manifest content failed validation:\n ${pc.errors.join('\n ')}`);
62
+ }
63
+ if (typeof obj.statuses !== 'object' || obj.statuses === null || Array.isArray(obj.statuses)) {
64
+ throw new Error('plan-manifest is missing the "statuses" object');
65
+ }
66
+ const rawStatuses = obj.statuses;
67
+ const statuses = {};
68
+ for (const p of pc.content.phases) {
69
+ const s = rawStatuses[p.id];
70
+ if (typeof s !== 'string' || !PLAN_PHASE_STATUSES.includes(s)) {
71
+ throw new Error(`plan-manifest statuses["${p.id}"] is ${s === undefined ? 'missing' : `invalid: ${String(s)}`}; ` +
72
+ `allowed: ${PLAN_PHASE_STATUSES.join(', ')}`);
73
+ }
74
+ statuses[p.id] = s;
75
+ }
76
+ const items = pc.content.phases.map((p) => ({
77
+ id: p.id,
78
+ status: statuses[p.id],
79
+ answer: null,
80
+ answeredBy: null,
81
+ answeredAt: null,
82
+ comments: [],
83
+ }));
84
+ return { title: obj.title.trim(), content: pc.content, items, statuses };
85
+ }
@@ -1,3 +1,6 @@
1
+ export { planContentSchema, parsePlanContent, PLAN_LAYERS, PLAN_DELEGATE_SKILLS, PLAN_PHASE_STATUSES } from './plan-schema.js';
2
+ export { extractPlan } from './extract-plan.js';
3
+ export { statusesFromItems, computeNextPhase, renderPlanTracker, buildPlanRevision } from './plan-run.js';
1
4
  export { validateEnvelope } from './validate.js';
2
5
  export { canonicalSha256 } from './canonicalize.js';
3
6
  export { titleSlug, shortId, projectFilename } from './filename.js';
@@ -0,0 +1,120 @@
1
+ /** Statuses a dead session may have left behind — resumable, picked before fresh `todo`s. */
2
+ const IN_PROGRESS = ['designing', 'building', 'verifying'];
3
+ /**
4
+ * Phase status lives in interaction.items (store convention). Build the
5
+ * id→status map; a phase with no matching item defaults to 'todo' rather
6
+ * than failing — the envelope passed top-level validation, so a missing
7
+ * item is a seeding gap, not corruption.
8
+ */
9
+ export function statusesFromItems(items, phases) {
10
+ const byId = new Map(items.map((it) => [it.id, it.status]));
11
+ const out = {};
12
+ for (const p of phases)
13
+ out[p.id] = byId.get(p.id) ?? 'todo';
14
+ return out;
15
+ }
16
+ /**
17
+ * The next phase a resume session should execute: first phase (in the
18
+ * bottom-up `phases[]` order) that is not yet terminal AND whose
19
+ * entryCriteria are all 'validated'. In-progress statuses (a prior
20
+ * session died mid-phase) qualify the same as 'todo' — the phase is
21
+ * re-entered from the top, gates and grounding included.
22
+ */
23
+ export function computeNextPhase(phases, statuses) {
24
+ for (const p of phases) {
25
+ const s = statuses[p.id] ?? 'todo';
26
+ if (s === 'validated' || s === 'blocked')
27
+ continue;
28
+ if (s !== 'todo' && !IN_PROGRESS.includes(s))
29
+ continue;
30
+ if (p.entryCriteria.every((dep) => statuses[dep] === 'validated'))
31
+ return p;
32
+ }
33
+ return null;
34
+ }
35
+ /**
36
+ * Plan-progress board printed before and after every resume turn (and by
37
+ * `--status` for plan envelopes). Plain text — flows through chunkEmitter
38
+ * in Ink mode and console.log in classic mode alike.
39
+ *
40
+ * ─ Plan: HR Dayforce Extract (v3) ── 2/7 validated ─
41
+ * ✔ c1.types abap-data-model → ZDOM_DAYF_STATUS (S4HK903412)
42
+ * ▶ c1.orchestration abap-generate ← THIS SESSION
43
+ * ○ c1.integration abap-generate (needs c1.orchestration)
44
+ */
45
+ export function renderPlanTracker(a) {
46
+ const lines = [];
47
+ const total = a.content.phases.length;
48
+ const validated = a.content.phases.filter((p) => a.statuses[p.id] === 'validated').length;
49
+ if (a.includeHeader !== false) {
50
+ lines.push(`─ Plan: ${a.title} (v${a.version}) ── ${validated}/${total} validated ─`);
51
+ }
52
+ const idWidth = Math.max(...a.content.phases.map((p) => p.id.length));
53
+ const skillWidth = Math.max(...a.content.phases.map((p) => p.delegateTo.length));
54
+ for (const p of a.content.phases) {
55
+ const s = a.statuses[p.id] ?? 'todo';
56
+ const isCurrent = a.currentId != null && p.id === a.currentId;
57
+ const symbol = isCurrent ? '▶'
58
+ : s === 'validated' ? '✔'
59
+ : s === 'blocked' ? '✖'
60
+ : IN_PROGRESS.includes(s) ? '◌'
61
+ : '○';
62
+ lines.push(` ${symbol} ${p.id.padEnd(idWidth)} ${p.delegateTo.padEnd(skillWidth)} ${annotationFor(p, s, isCurrent, a.statuses)}`.trimEnd());
63
+ }
64
+ return lines.join('\n');
65
+ }
66
+ function annotationFor(p, s, isCurrent, statuses) {
67
+ if (isCurrent)
68
+ return '← THIS SESSION';
69
+ if (s === 'validated') {
70
+ const objs = p.work?.generated?.join(', ') ?? '';
71
+ const tr = p.work?.transport ? ` (${p.work.transport})` : '';
72
+ return objs || tr ? `→ ${objs}${tr}`.trimEnd() : '';
73
+ }
74
+ if (s === 'blocked')
75
+ return 'BLOCKED';
76
+ if (IN_PROGRESS.includes(s))
77
+ return `in progress (${s})`;
78
+ const unmet = p.entryCriteria.filter((dep) => statuses[dep] !== 'validated');
79
+ return unmet.length > 0 ? `(needs ${unmet.join(', ')})` : '';
80
+ }
81
+ /**
82
+ * Build the version-N+1 envelope from the original plan envelope and the
83
+ * manifest block a resume turn emitted.
84
+ *
85
+ * Invariants preserved:
86
+ * - id / createdAt / createdBy / title / source / promotedFrom unchanged
87
+ * (the extract's title is intentionally IGNORED — title drift would
88
+ * break the multi-file same-source detection in status.ts)
89
+ * - history is append-only; version === history.length stays true
90
+ * - item comments / answers added via the editor survive the revision —
91
+ * only the status comes from the new extract
92
+ */
93
+ export function buildPlanRevision(original, extract, author, nowIso) {
94
+ if (original.artefactType !== 'plan') {
95
+ throw new Error(`buildPlanRevision expects a plan envelope, got ${original.artefactType}`);
96
+ }
97
+ const oldItems = new Map(original.interaction.items.map((it) => [it.id, it]));
98
+ const mergedItems = extract.items.map((ni) => {
99
+ const old = oldItems.get(ni.id);
100
+ return old ? { ...old, status: ni.status } : ni;
101
+ });
102
+ const oldStatuses = statusesFromItems(original.interaction.items, original.content.phases);
103
+ const diffs = [];
104
+ for (const p of extract.content.phases) {
105
+ const before = oldStatuses[p.id] ?? 'todo';
106
+ const after = extract.statuses[p.id];
107
+ if (before !== after)
108
+ diffs.push(`${p.id}: ${before} → ${after}`);
109
+ }
110
+ const summary = diffs.length > 0 ? diffs.join('; ') : 'resume run — no phase status change';
111
+ return {
112
+ ...original,
113
+ lastEditedAt: nowIso,
114
+ lastEditedBy: author,
115
+ version: original.version + 1,
116
+ content: extract.content,
117
+ interaction: { items: mergedItems },
118
+ history: [...original.history, { at: nowIso, by: author, action: 'edited', summary }],
119
+ };
120
+ }