@cspeach/cli 1.0.0 → 1.1.1

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 (100) hide show
  1. package/dist/agent/loop.js +22 -9
  2. package/dist/approvals/op-labels.js +124 -0
  3. package/dist/approvals/render.js +42 -36
  4. package/dist/cli.js +15 -0
  5. package/dist/commands/compact.js +28 -2
  6. package/dist/commands/config-set.js +189 -0
  7. package/dist/commands/config-show.js +20 -0
  8. package/dist/commands/export-audit.js +43 -0
  9. package/dist/commands/help.js +5 -0
  10. package/dist/commands/plan-audit-evidence.js +266 -0
  11. package/dist/commands/plan-audit.js +692 -0
  12. package/dist/commands/plan-chain.js +671 -0
  13. package/dist/commands/plan-continue.js +179 -0
  14. package/dist/commands/plan-gate.js +154 -0
  15. package/dist/commands/plan-resume.js +588 -33
  16. package/dist/config/loader.js +128 -4
  17. package/dist/config/model-defaults.js +14 -0
  18. package/dist/cost/pricing.js +27 -1
  19. package/dist/doctor/checks/system-roles.js +41 -0
  20. package/dist/doctor/run.js +2 -0
  21. package/dist/models/resolve.js +61 -0
  22. package/dist/models/server-config.js +155 -0
  23. package/dist/one-shot.js +25 -3
  24. package/dist/projects/extract-cca.js +3 -1
  25. package/dist/projects/extract-modernize.js +3 -1
  26. package/dist/projects/extract-plan.js +60 -6
  27. package/dist/projects/extract-test-coverage.js +3 -1
  28. package/dist/projects/extract-upgrade.js +3 -1
  29. package/dist/projects/handover-md.js +195 -0
  30. package/dist/projects/index.js +1 -1
  31. package/dist/projects/plan-run.js +137 -13
  32. package/dist/projects/plan-schema.js +73 -0
  33. package/dist/projects/run-lease.js +157 -0
  34. package/dist/projects/save-command.js +26 -15
  35. package/dist/renderer/status-footer.js +22 -12
  36. package/dist/renderer/thinking-heartbeat.js +64 -8
  37. package/dist/renderer/todo-block.js +51 -0
  38. package/dist/renderer/tool-widget.js +37 -0
  39. package/dist/repl/bracketed-paste.js +28 -19
  40. package/dist/repl/builtin-commands.js +5 -0
  41. package/dist/repl/current-transport.js +10 -0
  42. package/dist/repl/history.js +86 -0
  43. package/dist/repl/ink-stdin-guard.js +64 -0
  44. package/dist/repl/mode-ceiling.js +16 -0
  45. package/dist/repl/mode-cycle.js +104 -0
  46. package/dist/repl/post-turn-status.js +24 -4
  47. package/dist/repl/slash-completer.js +5 -0
  48. package/dist/repl.js +954 -83
  49. package/dist/rewind/candidates.js +194 -0
  50. package/dist/rewind/cli.js +137 -0
  51. package/dist/rewind/format.js +27 -0
  52. package/dist/rewind/restore.js +245 -0
  53. package/dist/session/audit-export.js +459 -0
  54. package/dist/session/context-report.js +163 -0
  55. package/dist/session/recap.js +160 -0
  56. package/dist/skill-catalog.js +9 -3
  57. package/dist/skills/bundled-skills.js +71 -78
  58. package/dist/tools/approval.js +115 -7
  59. package/dist/tools/ask-question.js +304 -3
  60. package/dist/tools/extend-model/anchored-insert.js +604 -0
  61. package/dist/tools/extend-model/tool.js +162 -10
  62. package/dist/tools/fiori/fe-extend.js +76 -0
  63. package/dist/tools/fiori/fe-scaffold.js +29 -3
  64. package/dist/tools/fiori/floorplan-map.js +19 -0
  65. package/dist/tools/fiori/samples/data/index.json +13602 -0
  66. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  67. package/dist/tools/fiori/samples/loader.js +248 -0
  68. package/dist/tools/fiori/samples/search.js +63 -0
  69. package/dist/tools/fiori/samples/types.js +2 -0
  70. package/dist/tools/fiori/smoke/assertions.js +74 -0
  71. package/dist/tools/fiori/smoke/browser.js +52 -0
  72. package/dist/tools/fiori/smoke/driver.js +89 -0
  73. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  74. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  75. package/dist/tools/fiori/tools.js +328 -3
  76. package/dist/tools/local-build.js +11 -1
  77. package/dist/tools/sap-read.js +79 -11
  78. package/dist/tools/sap-write.js +24 -4
  79. package/dist/tools/snapshot.js +27 -1
  80. package/dist/tools/subagent/agent_run.js +27 -3
  81. package/dist/tools/todo.js +144 -0
  82. package/dist/ui/app.js +372 -19
  83. package/dist/ui/approval-modal.js +49 -16
  84. package/dist/ui/ask-question-emitter.js +14 -0
  85. package/dist/ui/context-grid.js +108 -0
  86. package/dist/ui/footer.js +109 -30
  87. package/dist/ui/header.js +7 -0
  88. package/dist/ui/line-resolution.js +18 -2
  89. package/dist/ui/rewind-emitter.js +10 -0
  90. package/dist/ui/rewind-panel.js +81 -0
  91. package/dist/ui/sap-state-store.js +1 -0
  92. package/dist/ui/status-line.js +43 -0
  93. package/dist/ui/text-input.js +72 -8
  94. package/dist/ui/todo-emitter.js +25 -0
  95. package/dist/ui/todo-panel.js +64 -0
  96. package/dist/ui/turn-status-emitter.js +50 -4
  97. package/dist/ui/turn-status.js +18 -3
  98. package/dist/ui/widgets/ask-form.js +242 -0
  99. package/dist/ui/widgets/ask-question-modal.js +17 -7
  100. package/package.json +4 -1
@@ -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
+ }
@@ -15,6 +15,7 @@ import { validateEnvelope } from './validate.js';
15
15
  import { readProjectFile } from './status.js';
16
16
  import { saveProject } from './save.js';
17
17
  import { renderEmailTemplate } from './email-template.js';
18
+ import { writePlanHandover } from './handover-md.js';
18
19
  import { ensureWorkspace, resolveWorkspacePath } from './workspace.js';
19
20
  const SKILL_REGISTRY = {
20
21
  'abap-spec-gap': {
@@ -36,19 +37,19 @@ const SKILL_REGISTRY = {
36
37
  artefactType: 'upgrade-scan',
37
38
  extract: (md) => extractUpgradeScan(md),
38
39
  itemNoun: 'findings',
39
- manifestMarker: 'csforge:upgrade-manifest',
40
+ manifestMarker: 'upgrade-manifest',
40
41
  },
41
42
  'abap-upgrade-fix': {
42
43
  artefactType: 'upgrade-progress',
43
44
  extract: (md) => extractUpgradeProgress(md),
44
45
  itemNoun: 'fixes',
45
- manifestMarker: 'csforge:upgrade-manifest',
46
+ manifestMarker: 'upgrade-manifest',
46
47
  },
47
48
  'abap-upgrade-verify': {
48
49
  artefactType: 'upgrade-report',
49
50
  extract: (md) => extractUpgradeReport(md),
50
51
  itemNoun: 'regressions',
51
- manifestMarker: 'csforge:upgrade-manifest',
52
+ manifestMarker: 'upgrade-manifest',
52
53
  },
53
54
  'abap-upgrade-merge': {
54
55
  // Merge produces a NEW upgrade-progress (consolidated) — same shape
@@ -56,13 +57,13 @@ const SKILL_REGISTRY = {
56
57
  artefactType: 'upgrade-progress',
57
58
  extract: (md) => extractUpgradeProgress(md),
58
59
  itemNoun: 'fixes',
59
- manifestMarker: 'csforge:upgrade-manifest',
60
+ manifestMarker: 'upgrade-manifest',
60
61
  },
61
62
  'abap-cca': {
62
63
  artefactType: 'cca-assessment',
63
64
  extract: (md) => extractCcaAssessment(md),
64
65
  itemNoun: 'classifications',
65
- manifestMarker: 'csforge:cca-manifest',
66
+ manifestMarker: 'cca-manifest',
66
67
  },
67
68
  'abap-cca-merge': {
68
69
  // Merge produces a NEW cca-assessment (consolidated) — same shape as
@@ -70,19 +71,19 @@ const SKILL_REGISTRY = {
70
71
  artefactType: 'cca-assessment',
71
72
  extract: (md) => extractCcaAssessment(md),
72
73
  itemNoun: 'classifications',
73
- manifestMarker: 'csforge:cca-manifest',
74
+ manifestMarker: 'cca-manifest',
74
75
  },
75
76
  'abap-modernize': {
76
77
  artefactType: 'modernize-result',
77
78
  extract: (md) => extractModernizeResult(md),
78
79
  itemNoun: 'modernizations',
79
- manifestMarker: 'csforge:modernize-manifest',
80
+ manifestMarker: 'modernize-manifest',
80
81
  },
81
82
  'abap-test': {
82
83
  artefactType: 'test-coverage',
83
84
  extract: (md) => extractTestCoverage(md),
84
85
  itemNoun: 'tests',
85
- manifestMarker: 'csforge:test-coverage-manifest',
86
+ manifestMarker: 'test-coverage-manifest',
86
87
  },
87
88
  'abap-plan': {
88
89
  // 2026-06-06 B3: Mode 1 (create) save. Mode 2 (--resume) turns
@@ -97,7 +98,7 @@ const SKILL_REGISTRY = {
97
98
  artefactType: 'plan',
98
99
  extract: (md) => extractPlan(md),
99
100
  itemNoun: 'phases',
100
- manifestMarker: 'csforge:plan-manifest',
101
+ manifestMarker: 'plan-manifest',
101
102
  },
102
103
  };
103
104
  /**
@@ -114,15 +115,17 @@ const SKILL_REGISTRY = {
114
115
  * inside the block disambiguates which extractor (registry skill) applies.
115
116
  */
116
117
  export function detectArtifactSkill(markdown) {
117
- if (/<!--\s*csforge:plan-manifest\b/.test(markdown))
118
+ // E2 dual-read: accept both the legacy `csforge:` and current `cspeach:`
119
+ // marker prefixes (legacy acceptance is permanent).
120
+ if (/<!--\s*(?:csforge|cspeach):plan-manifest\b/.test(markdown))
118
121
  return 'abap-plan';
119
- if (/<!--\s*csforge:cca-manifest\b/.test(markdown))
122
+ if (/<!--\s*(?:csforge|cspeach):cca-manifest\b/.test(markdown))
120
123
  return 'abap-cca';
121
- if (/<!--\s*csforge:modernize-manifest\b/.test(markdown))
124
+ if (/<!--\s*(?:csforge|cspeach):modernize-manifest\b/.test(markdown))
122
125
  return 'abap-modernize';
123
- if (/<!--\s*csforge:test-coverage-manifest\b/.test(markdown))
126
+ if (/<!--\s*(?:csforge|cspeach):test-coverage-manifest\b/.test(markdown))
124
127
  return 'abap-test';
125
- const up = /<!--\s*csforge:upgrade-manifest\s*\n([\s\S]*?)\n\s*-->/.exec(markdown);
128
+ const up = /<!--\s*(?:csforge|cspeach):upgrade-manifest\s*\n([\s\S]*?)\n\s*-->/.exec(markdown);
126
129
  if (up) {
127
130
  const art = /(?:^|\n)\s*artefact:\s*(\S+)/.exec(up[1] ?? '');
128
131
  switch (art?.[1]) {
@@ -135,7 +138,8 @@ export function detectArtifactSkill(markdown) {
135
138
  return null;
136
139
  }
137
140
  // Same block grammar as extract-plan.ts:MANIFEST_RE (last block wins).
138
- const PLAN_MANIFEST_RE = /<!--\s*csforge:plan-manifest\s*\n([\s\S]*?)\n\s*-->/g;
141
+ // E2 dual-read: accept both the legacy `csforge:` and current `cspeach:` prefixes.
142
+ const PLAN_MANIFEST_RE = /<!--\s*(?:csforge|cspeach):plan-manifest\s*\n([\s\S]*?)\n\s*-->/g;
139
143
  /**
140
144
  * Cheap title peek into the LAST plan-manifest block — needed BEFORE the full
141
145
  * extract because compact manifests can only be expanded against the prior
@@ -349,6 +353,13 @@ export async function runSaveCommand(args) {
349
353
  outDir = args.cwd;
350
354
  }
351
355
  const path = await saveProject(env, { cwd: outDir });
356
+ // Task 8 follow-up (fix-wave): a plan envelope saved through the GENERIC
357
+ // save path (Mode 1 create AND a steered revision, C1/D30) must regenerate
358
+ // the markdown handover projection too — otherwise an authoring-session
359
+ // save leaves a stale or missing projection until the first --resume write.
360
+ // Best-effort by design (writePlanHandover logs one dim line on failure).
361
+ if (spec.artefactType === 'plan')
362
+ writePlanHandover(path, args.log);
352
363
  args.log(`Saved: ${path}`);
353
364
  if (planExtract && prior) {
354
365
  args.log(`(revision v${env.version} of the existing plan — version chain preserved, no new id)`);
@@ -40,7 +40,7 @@ export const LARGE_SESSION_THRESHOLD = 100_000;
40
40
  * direct stdout (which would corrupt the live Ink frame).
41
41
  */
42
42
  export function formatStatusFooter(params) {
43
- const { sessionId, sapAlias, skill, turnCount, tokens, cachedTokens, elapsedMs, costThisTurn, costSession, sessionInputTokens } = params;
43
+ const { sessionId, sapAlias, skill, turnCount, tokens, cachedTokens, elapsedMs, costThisTurn, costSession, sessionInputTokens, variant } = params;
44
44
  const shortSession = sessionId.slice(0, 8);
45
45
  const skillDisplay = skill.startsWith('/') ? skill : `/${skill}`;
46
46
  const turns = `${turnCount} turn${turnCount === 1 ? '' : 's'}`;
@@ -49,17 +49,27 @@ export function formatStatusFooter(params) {
49
49
  const elapsed = elapsedMs > 0 ? `${(elapsedMs / 1000).toFixed(1)}s` : '';
50
50
  const costTurnStr = costThisTurn != null ? formatCost(costThisTurn) : '';
51
51
  const costSessStr = costSession != null ? `session: ${formatCost(costSession)}` : '';
52
- const parts = [
53
- chalk.dim(`session ${shortSession}`),
54
- chalk.dim(sapAlias),
55
- chalk.dim(skillDisplay),
56
- chalk.dim(turns),
57
- chalk.dim(tokStr),
58
- ...(costTurnStr ? [chalk.dim(costTurnStr)] : []),
59
- ...(cachedStr ? [chalk.dim(cachedStr)] : []),
60
- ...(costSessStr ? [chalk.dim(costSessStr)] : []),
61
- ...(elapsed ? [chalk.dim(elapsed)] : []),
62
- ];
52
+ // Ink turn-receipt (see the variant doc on StatusFooterParams):
53
+ // turn-only facts — everything session-scoped lives in <StatusLine />.
54
+ const parts = variant === 'turn'
55
+ ? [
56
+ chalk.dim(skillDisplay),
57
+ chalk.dim(turns),
58
+ chalk.dim(tokStr),
59
+ ...(costTurnStr ? [chalk.dim(costTurnStr)] : []),
60
+ ...(elapsed ? [chalk.dim(elapsed)] : []),
61
+ ]
62
+ : [
63
+ chalk.dim(`session ${shortSession}`),
64
+ chalk.dim(sapAlias),
65
+ chalk.dim(skillDisplay),
66
+ chalk.dim(turns),
67
+ chalk.dim(tokStr),
68
+ ...(costTurnStr ? [chalk.dim(costTurnStr)] : []),
69
+ ...(cachedStr ? [chalk.dim(cachedStr)] : []),
70
+ ...(costSessStr ? [chalk.dim(costSessStr)] : []),
71
+ ...(elapsed ? [chalk.dim(elapsed)] : []),
72
+ ];
63
73
  const lines = ['', RULE, ' ' + parts.join(chalk.dim(' │ '))];
64
74
  if (sessionInputTokens != null && sessionInputTokens >= LARGE_SESSION_THRESHOLD) {
65
75
  lines.push(' ' + chalk.yellow(`⚠ session getting large (${sessionInputTokens.toLocaleString()} input tokens)`
@@ -48,24 +48,67 @@ const GENERIC_SUFFIXES = [
48
48
  { at: 120_000, suffix: ' (2m — still running; Ctrl+C aborts the turn)' },
49
49
  { at: 300_000, suffix: ' (5m — likely stuck; Ctrl+C and check the VPN)' },
50
50
  ];
51
+ /**
52
+ * 2026-07-06 (audit-timeout) — the plan-chain audit's own liveness schedule.
53
+ *
54
+ * The audit is a PROXY model call (api.cspeach.dev over the internet), NOT a
55
+ * SAP tool call, so the generic schedule's "check the VPN" 5m copy is a WRONG
56
+ * diagnosis (the VPN is only for SAP). It is also now bounded: runPhaseAudit
57
+ * enforces a 120s hard deadline PER attempt × 3 attempts, so the whole audit
58
+ * can never exceed ~6m before it returns infra_failed — no infinite hang. This
59
+ * schedule reflects that reality honestly: past 2m the audit is necessarily in
60
+ * a retry (a single attempt hard-stops at 120s), and there is nothing to check
61
+ * — it either recovers on the next attempt or returns infra_failed. The
62
+ * per-attempt transition ("[audit] attempt N of 3 (previous timed out)") is
63
+ * printed separately by plan-chain via runPhaseAudit's onRetry hook.
64
+ */
65
+ export const AUDIT_HEARTBEAT_THRESHOLDS = [
66
+ { at: 3_000, label: '[audit] …running' },
67
+ { at: 10_000, label: '[audit] …running (10s)' },
68
+ { at: 30_000, label: '[audit] …running (30s)' },
69
+ { at: 60_000, label: '[audit] …running (60s — still running, large evidence can take a while)' },
70
+ { at: 120_000, label: '[audit] …running (2m — still running; large evidence can take a while · Ctrl+C aborts the turn)' },
71
+ { at: 240_000, label: '[audit] …running (4m — a prior attempt timed out; retrying · Ctrl+C aborts the turn)' },
72
+ { at: 300_000, label: '[audit] …running (5m — nearly at the retry ceiling; will stop and re-audit on resume if it can\'t finish)' },
73
+ ];
51
74
  export function startThinkingHeartbeat(opts) {
52
75
  const startedAt = Date.now();
53
76
  let nextIdx = 0;
54
77
  let stopped = false;
55
- const THRESHOLDS_MS = opts?.label
56
- ? GENERIC_SUFFIXES.map((t) => ({ at: t.at, label: `${opts.label}${t.suffix}` }))
57
- : LLM_THRESHOLDS;
78
+ // Precedence: an explicit `thresholds` schedule (audit) wins over the
79
+ // `label` generic-suffix path, which wins over the default LLM schedule.
80
+ const THRESHOLDS_MS = opts?.thresholds
81
+ ? opts.thresholds
82
+ : opts?.label
83
+ ? GENERIC_SUFFIXES.map((t) => ({ at: t.at, label: `${opts.label}${t.suffix}` }))
84
+ : LLM_THRESHOLDS;
85
+ /**
86
+ * Receives the styled label WITHOUT a newline. Fable review (2026-07-05,
87
+ * audit-reverify item 1): the newline used to be baked INSIDE the chalk
88
+ * argument — with color enabled (FORCE_COLOR / real terminals) chalk
89
+ * appends its closing style codes AFTER the embedded `\n`, so the sink
90
+ * branch's trailing-`\n` strip was a no-op and every tick rendered an
91
+ * extra blank line. The label is now styled alone and each branch owns
92
+ * its own termination: sink = none (the caller terminates), chunkEmitter /
93
+ * stdout = a plain `\n` appended after the styled text (byte-identical to
94
+ * before under no-color; under color the closing style code now correctly
95
+ * precedes the newline).
96
+ */
58
97
  const emitLine = (line) => {
59
- if (opts?.chunkEmitter) {
98
+ if (opts?.emit) {
99
+ // Custom sink (e.g. plan-chain's args.log) owns line termination.
100
+ opts.emit(line);
101
+ }
102
+ else if (opts?.chunkEmitter) {
60
103
  // Phase A #4 — route through Ink's chunk pipeline. The Body
61
104
  // component's reducer treats this like any other model chunk and
62
105
  // appends it as a node, so the heartbeat lives inside the React
63
106
  // render tree and survives subsequent renders.
64
- opts.chunkEmitter.emit('chunk', line);
107
+ opts.chunkEmitter.emit('chunk', line + '\n');
65
108
  }
66
109
  else {
67
110
  // Classic mode — direct stdout, plain newline-terminated line.
68
- process.stdout.write(line);
111
+ process.stdout.write(line + '\n');
69
112
  }
70
113
  };
71
114
  const tick = () => {
@@ -74,8 +117,21 @@ export function startThinkingHeartbeat(opts) {
74
117
  const elapsed = Date.now() - startedAt;
75
118
  while (nextIdx < THRESHOLDS_MS.length && THRESHOLDS_MS[nextIdx].at <= elapsed) {
76
119
  const t = THRESHOLDS_MS[nextIdx];
77
- // Indent by 2 spaces to align with tool-call rows.
78
- emitLine(chalk.dim(` ${t.label}\n`));
120
+ // audit-lean FIX #2 — a dynamic suffix (e.g. the live token count),
121
+ // evaluated NOW so a stall shows a different number from progress.
122
+ // Defensive: observability must never break the liveness tick.
123
+ let suffix = '';
124
+ if (opts?.suffix) {
125
+ try {
126
+ suffix = opts.suffix();
127
+ }
128
+ catch {
129
+ suffix = '';
130
+ }
131
+ }
132
+ // Indent by 2 spaces to align with tool-call rows. Style ONLY the
133
+ // label (+ suffix) — never a newline inside the chalk call (see emitLine).
134
+ emitLine(chalk.dim(` ${t.label}${suffix}`));
79
135
  nextIdx++;
80
136
  }
81
137
  };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * todo_set transcript block — UX Wave 2 follow-up (CC-style checklist).
3
+ *
4
+ * Owner feedback (2026-07): "it's not like we have in Claude Code — only
5
+ * shows the phase it's working on with an arrow." The turn-status strip
6
+ * (single ▶ row) and the Ctrl+T panel are both by-design; what was missing
7
+ * was Claude Code's behaviour of dropping the FULL checklist into the
8
+ * scrollback transcript on every todo_set, so the user sees the whole plan
9
+ * advance in place rather than a raw `⏺ todo_set({...})` JSON line.
10
+ *
11
+ * This module is the pure formatter for that block. todo.ts emits the
12
+ * returned string as a `chunk` on ctx.chunkEmitter after a successful set
13
+ * (routes to Ink Static scrollback AND the classic 'chunk'→stdout path);
14
+ * the raw ⏺/⎿ todo_set rows are widget-suppressed (tool-widget.ts) so the
15
+ * block is the sole visible representation — exactly mirroring ask_question.
16
+ *
17
+ * House style follows todo-panel.tsx glyphs precisely so the transcript
18
+ * block and the Ctrl+T panel read as the same list:
19
+ * ▣ completed (dim)
20
+ * ▶ in_progress (peach, bold)
21
+ * □ pending (default)
22
+ * Every row is truncate-end (Wave-1 rule — no soft-wrap), preceded by a
23
+ * slim `Tasks (done/total done)` header.
24
+ */
25
+ import chalk from 'chalk';
26
+ import { PEACH } from './banners.js';
27
+ /** Max visible width of a row's TEXT (after the 2-char glyph+space prefix).
28
+ * 72-col total matches the Ctrl+T panel width; 70 leaves room for `▶ `. */
29
+ export const TODO_BLOCK_TEXT_MAX = 70;
30
+ function truncateEnd(s, max) {
31
+ return s.length > max ? s.substring(0, max - 1) + '…' : s;
32
+ }
33
+ /**
34
+ * Render the checklist as a scrollback block (leading + trailing newline for
35
+ * separation from surrounding transcript content). Returns ANSI-coloured
36
+ * text ready to emit as a `chunk`.
37
+ */
38
+ export function formatTodoChecklistBlock(todos) {
39
+ const done = todos.filter((t) => t.status === 'completed').length;
40
+ const total = todos.length;
41
+ const header = chalk.dim(`Tasks (${done}/${total} done)`);
42
+ const rows = todos.map((t) => {
43
+ const text = truncateEnd(t.text, TODO_BLOCK_TEXT_MAX);
44
+ if (t.status === 'completed')
45
+ return chalk.dim(`▣ ${text}`);
46
+ if (t.status === 'in_progress')
47
+ return chalk.hex(PEACH).bold(`▶ ${text}`);
48
+ return `□ ${text}`;
49
+ });
50
+ return '\n' + [header, ...rows].join('\n') + '\n';
51
+ }
@@ -21,6 +21,31 @@ import { PEACH } from './banners.js';
21
21
  * - No cyan, no rainbow, no boxes.
22
22
  */
23
23
  const peach = (s) => chalk.hex(PEACH)(s);
24
+ /**
25
+ * Widget-representation de-clutter (2026-07): tools whose OWN rendering IS
26
+ * the visible representation, making the generic ⏺ dispatch line (with its
27
+ * raw JSON args) and the SUCCESS ⎿ result line pure scrollback noise. For
28
+ * these the ⏺ top line and the success ⎿ row are suppressed; ERROR ⎿ rows
29
+ * still render (the ✗ row is the only trace of a failed call). The agent
30
+ * loop also skips the tool spinner for these (it would orphan without its
31
+ * ⏺ line).
32
+ *
33
+ * Members:
34
+ * - ask_question: the Ink form / modal (or the classic console.log
35
+ * question text) is the representation. Classic mode is unaffected in
36
+ * the ways that matter — the v1 path prints its question via
37
+ * console.log and the v2 classic path prints its `form i/n · header`
38
+ * blocks, neither of which goes through these renderers.
39
+ * - todo_set: the handler emits a CC-style checklist block into the
40
+ * transcript (renderer/todo-block.ts) on every successful set — that
41
+ * block is the representation, so the raw `⏺ todo_set({"todos":[…])`
42
+ * line + ⎿ JSON result are suppressed. Owner feedback drove this: the
43
+ * transcript should show the full checklist, not a JSON tool line.
44
+ */
45
+ const WIDGET_SUPPRESSED_TOOLS = new Set(['ask_question', 'todo_set']);
46
+ export function isWidgetSuppressedTool(name) {
47
+ return WIDGET_SUPPRESSED_TOOLS.has(name);
48
+ }
24
49
  const ARG_SUMMARY_MAX = 64;
25
50
  // 2026-05-15 (bug 1): widened from 96 → 240 so a parsed ADT exception
26
51
  // message ("CTS_WBO_API/047: Request S4HK903388 is not a local request"
@@ -55,6 +80,10 @@ export function clearActiveSpinner() {
55
80
  }
56
81
  export function renderToolCallTop(params) {
57
82
  const { name, args, chunkEmitter } = params;
83
+ // Ask-form de-clutter: the form/modal IS the visible representation —
84
+ // no ⏺ dispatch line (with its raw JSON args) belongs in scrollback.
85
+ if (isWidgetSuppressedTool(name))
86
+ return;
58
87
  const write = (s) => {
59
88
  if (chunkEmitter)
60
89
  chunkEmitter.emit('chunk', s);
@@ -137,6 +166,14 @@ function startSpinnerWithVerbs(params, verbs, defaultVerb) {
137
166
  }
138
167
  export function renderToolCallBottom(params) {
139
168
  const { durationMs, isError, resultSummary, chunkEmitter, name, args } = params;
169
+ // Ask-form de-clutter: paired with the top-line suppression — on SUCCESS
170
+ // the answer already came back through the form; a ⎿ row would be noise.
171
+ // ERRORS still render (review fix): an ask_form_invalid /
172
+ // ask_question_invalid round never mounts a form, so without the ✗ row
173
+ // the turn would stall silently with nothing in scrollback. D29 made the
174
+ // bottom row self-identifying (name + args), so no orphan risk.
175
+ if (name && isWidgetSuppressedTool(name) && !isError)
176
+ return;
140
177
  const write = (s) => {
141
178
  if (chunkEmitter)
142
179
  chunkEmitter.emit('chunk', s);
@@ -59,18 +59,6 @@ export function disableBracketedPaste(stream = process.stdout) {
59
59
  if (stream.isTTY)
60
60
  stream.write(BRACKETED_PASTE_DISABLE);
61
61
  }
62
- /**
63
- * Transform stream that collapses the content between `\x1b[200~` and
64
- * `\x1b[201~` into a single line (newlines replaced with spaces). The
65
- * markers themselves are stripped. All other bytes pass through
66
- * unchanged, including keystrokes, arrow-key escape sequences, and
67
- * terminal responses — these are not affected because they never contain
68
- * the paste markers.
69
- *
70
- * The implementation uses Buffer operations (not string concat) to avoid
71
- * splitting multi-byte UTF-8 sequences at chunk boundaries when a paste
72
- * spans multiple `data` events.
73
- */
74
62
  export class BracketedPasteDecoder extends Transform {
75
63
  /**
76
64
  * Readline with `terminal: true` inspects `input.isTTY` and calls
@@ -81,9 +69,17 @@ export class BracketedPasteDecoder extends Transform {
81
69
  *
82
70
  * We present as a TTY and forward the raw-mode toggle and the size
83
71
  * getters to the real `process.stdin` / `process.stdout`.
72
+ *
73
+ * (Typed `boolean`, not the literal `true`, so InkStdinPasteGuard can
74
+ * mirror the real process.stdin.isTTY instead.)
84
75
  */
85
76
  // eslint-disable-next-line @typescript-eslint/naming-convention
86
77
  isTTY = true;
78
+ emitVerbatim;
79
+ constructor(options = {}) {
80
+ super();
81
+ this.emitVerbatim = options.emitVerbatim ?? false;
82
+ }
87
83
  setRawMode(mode) {
88
84
  if (typeof process.stdin.setRawMode === 'function')
89
85
  process.stdin.setRawMode(mode);
@@ -166,9 +162,9 @@ export class BracketedPasteDecoder extends Transform {
166
162
  this.carryTimer = null;
167
163
  }
168
164
  // End of stream. If we were still inside a paste (unterminated),
169
- // flush the buffered content as a single line so it's not lost.
165
+ // flush the buffered content so it's not lost.
170
166
  if (this.inPaste && this.pasteBuf.length > 0) {
171
- this.push(this.collapseNewlines(this.pasteBuf));
167
+ this.push(this.emitVerbatim ? this.pasteBuf : this.collapseNewlines(this.pasteBuf));
172
168
  this.pasteBuf = Buffer.alloc(0);
173
169
  this.inPaste = false;
174
170
  }
@@ -192,11 +188,24 @@ export class BracketedPasteDecoder extends Transform {
192
188
  return;
193
189
  }
194
190
  this.pasteBuf = Buffer.concat([this.pasteBuf, data.subarray(i, endIdx)]);
195
- this.push(this.collapseNewlines(this.pasteBuf));
196
- // Append a newline so readline commits the pasted line as a
197
- // single input — otherwise the user would still have to press
198
- // Enter after the paste.
199
- this.push(Buffer.from('\n'));
191
+ if (this.emitVerbatim) {
192
+ // Ink mode: ONE chunk, newlines intact, markers stripped, no
193
+ // synthetic Enter. Downstream (Ink useInput → text-input paste
194
+ // branch) buffers the complete lines and keeps the fragment —
195
+ // nothing submits until the user presses Enter. Crucially a
196
+ // `\r` that arrived as its own stdin chunk INSIDE the span was
197
+ // accumulated here instead of reaching Ink's parseKeypress,
198
+ // which would have flagged it key.return and submitted.
199
+ if (this.pasteBuf.length > 0)
200
+ this.push(this.pasteBuf);
201
+ }
202
+ else {
203
+ this.push(this.collapseNewlines(this.pasteBuf));
204
+ // Append a newline so readline commits the pasted line as a
205
+ // single input — otherwise the user would still have to press
206
+ // Enter after the paste.
207
+ this.push(Buffer.from('\n'));
208
+ }
200
209
  this.pasteBuf = Buffer.alloc(0);
201
210
  this.inPaste = false;
202
211
  i = endIdx + END_MARKER.length;
@@ -11,12 +11,17 @@ export const BUILTIN_ENTRIES = [
11
11
  { name: '/cancel', description: 'Cancel the current pre-filled command, return to a clean prompt' },
12
12
  { name: '/cost', description: 'Show this session\'s API spend so far ($ + token breakdown)' },
13
13
  { name: '/compact', description: 'Summarise older turns into a compact context block — cuts subsequent turn cost by 80-90%' },
14
+ { name: '/export', description: 'Export the signable session audit record — /export audit [filename]' },
15
+ { name: '/context', description: 'Show what fills the model context window — a composition grid of bars' },
14
16
  { name: '/exit', description: 'Exit CSPeach' },
15
17
  { name: '/quit', description: 'Exit CSPeach (alias)' },
16
18
  { name: '/help', description: 'Show all skills and commands' },
17
19
  { name: '/skills', description: 'Show all skills (alias)' },
20
+ { name: '/mode', description: 'Cycle or set the session write mode (advisory / gated / auto) — config unchanged' },
18
21
  { name: '/new', description: 'Reset routing — next prompt is classified afresh' },
22
+ { name: '/recap', description: 'Show the session recap (last session · open tasks · plan progress)' },
19
23
  { name: '/reset', description: 'Reset routing (alias)' },
24
+ { name: '/rewind', description: 'Undo a write from this session — restore an object to a pre-write snapshot (Esc Esc)' },
20
25
  { name: '/ui', description: 'View or change rendering mode (auto / ink / classic)' },
21
26
  { name: '/reroute', description: 'Re-dispatch the previous prompt to a different skill' },
22
27
  { name: '/transport', description: 'View or set the active transport for this session' },