@cspeach/cli 1.1.27 → 1.1.28

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 (65) hide show
  1. package/dist/agent/loop.js +33 -4
  2. package/dist/agent/provider-shape.js +9 -2
  3. package/dist/agent/providers/ai-hub-provider.js +2 -2
  4. package/dist/agent/providers/byok-provider.js +2 -2
  5. package/dist/agent/providers/local-provider.js +4 -4
  6. package/dist/agent/system-prompt.js +34 -7
  7. package/dist/agent/tool-dispatch.js +20 -1
  8. package/dist/approvals/grant-claims.js +70 -0
  9. package/dist/approvals/jwt.js +21 -1
  10. package/dist/approvals/plan-grant.js +297 -0
  11. package/dist/commands/config-set.js +12 -0
  12. package/dist/commands/deploy-card.js +10 -5
  13. package/dist/commands/export-audit.js +1 -1
  14. package/dist/commands/plan-audit-evidence.js +47 -1
  15. package/dist/commands/plan-audit.js +23 -8
  16. package/dist/commands/plan-batch-audit.js +349 -0
  17. package/dist/commands/plan-boundary.js +88 -0
  18. package/dist/commands/plan-chain.js +409 -566
  19. package/dist/commands/plan-continue.js +21 -7
  20. package/dist/commands/plan-gate.js +28 -49
  21. package/dist/commands/plan-reground.js +174 -0
  22. package/dist/commands/plan-resume.js +227 -103
  23. package/dist/commands/plan-sap-pause.js +105 -0
  24. package/dist/commands/plan-startup.js +218 -0
  25. package/dist/config/loader.js +9 -0
  26. package/dist/doctor/checks/forge-rules.js +67 -85
  27. package/dist/doctor/run.js +4 -4
  28. package/dist/lane/app-chain.js +88 -9
  29. package/dist/one-shot.js +1 -0
  30. package/dist/projects/extract-plan.js +34 -1
  31. package/dist/projects/handover-md.js +1 -1
  32. package/dist/projects/plan-objects.js +75 -0
  33. package/dist/projects/plan-run.js +74 -18
  34. package/dist/projects/plan-schema.js +6 -0
  35. package/dist/projects/plan-sources.js +199 -0
  36. package/dist/projects/promote-command.js +1 -1
  37. package/dist/projects/save-command.js +11 -13
  38. package/dist/projects/saved-lines.js +36 -0
  39. package/dist/renderer/checklist-format.js +5 -5
  40. package/dist/renderer/machine-blocks.js +141 -0
  41. package/dist/renderer/tool-labels.js +3 -0
  42. package/dist/repl/plan-hop.js +22 -0
  43. package/dist/repl/rule8-detector.js +16 -1
  44. package/dist/repl.js +320 -55
  45. package/dist/router/router-core.js +2 -2
  46. package/dist/sap/reachability-state.js +25 -0
  47. package/dist/session/audit-export.js +10 -16
  48. package/dist/session/recap.js +16 -26
  49. package/dist/skills/bundled-skills.js +1 -1
  50. package/dist/tools/_filesystem-shared.js +37 -0
  51. package/dist/tools/approval.js +48 -2
  52. package/dist/tools/dispatch-skill.js +3 -2
  53. package/dist/tools/filesystem/file-read.js +5 -3
  54. package/dist/tools/filesystem/glob.js +27 -4
  55. package/dist/tools/fiori/tools.js +6 -0
  56. package/dist/tools/lane/app-lane-resolve.js +1 -1
  57. package/dist/tools/sap-read.js +37 -3
  58. package/dist/tools/sap-write.js +65 -0
  59. package/dist/tools/text-elements/pool.js +250 -0
  60. package/dist/tools/text-elements/tools.js +433 -0
  61. package/dist/tools/tool-loading.js +3 -0
  62. package/dist/tools/transport.js +26 -5
  63. package/dist/tools/write-mode.js +4 -3
  64. package/package.json +120 -121
  65. package/dist/projects/email-template.js +0 -56
@@ -0,0 +1,199 @@
1
+ // Plan sources — the files a plan was made from, carried by the plan itself.
2
+ //
3
+ // 2026-10-04 (live run, FI Document Aging, CLI 1.1.27): phase c1.design said
4
+ // "the spec-gap answers file is not in the project folder, so I can't read
5
+ // gap-003/004/006/010/013/016". The file WAS in `.cspeach/`, and the plan
6
+ // envelope even held a snapshot of every gap item (`promotedFrom`). But the
7
+ // resume prompt carried only `content` (+ statuses): the phase got gap ids in
8
+ // `manifest.reads` and nothing behind them, then hunted with glob (which never
9
+ // walks `.cspeach/`) and gave up.
10
+ //
11
+ // The fix is one reusable mechanism, CLI-owned end to end:
12
+ // 1. At creation the plan records WHERE its spec-gap lives:
13
+ // `content.from.specGap = { path, sha256 }` (specGapRefForSave, imposed by
14
+ // extractPlan — never model-authored, like `from.lane`).
15
+ // 2. Every phase run resolves the plan's sources (resolvePlanSources: the
16
+ // live spec-gap file when it is still there and is the same envelope,
17
+ // else the snapshot the plan took at creation) and the phase prompt gets a
18
+ // <plan_sources> block with the gap items it reads, in full
19
+ // (renderPlanSourcesBlock). Phases never have to search for a file.
20
+ //
21
+ // Anything that runs a plan phase (today's per-phase resume, a future
22
+ // continuous run) calls resolvePlanSources + renderPlanSourcesBlock.
23
+ import { promises as fs } from 'node:fs';
24
+ import path from 'node:path';
25
+ import { readProjectFile } from './status.js';
26
+ import { buildPromotedFromSnapshot } from './promote.js';
27
+ import { assertRealPathContained } from '../tools/_filesystem-shared.js';
28
+ import { laneRootFor, workspaceFolderFor } from '../lane/roots.js';
29
+ /** A spec-gap envelope file name (`...spec-gap-<4hex>-v<N>[-n].cspeach.json`). */
30
+ const SPEC_GAP_FILE_RE = /-spec-gap-[0-9a-f]{4}-v\d+(?:-\d+)?\.cspeach\.json$/i;
31
+ /** Path relative to `root` ('/'-separated) when inside it, else absolute. */
32
+ function relOrAbs(root, abs) {
33
+ const rel = path.relative(root, abs);
34
+ if (rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel))
35
+ return rel.split(path.sep).join('/');
36
+ return abs;
37
+ }
38
+ /**
39
+ * The `from.specGap` ref a plan SAVE records, from the CLI's promotion state
40
+ * (the resolved `--from` file + its promotedFrom snapshot). Undefined unless
41
+ * the plan really was promoted from a spec-gap file. The path is stored
42
+ * relative to the project folder (the folder holding `.cspeach`, the same root
43
+ * `from.lane.path` uses) so a team-shared folder stays portable.
44
+ */
45
+ export async function specGapRefForSave(cwd, promotedFrom, sourcePath) {
46
+ if (!promotedFrom || promotedFrom.artefactType !== 'spec-gap' || !sourcePath)
47
+ return undefined;
48
+ if (!/^[0-9a-f]{64}$/.test(promotedFrom.sha256))
49
+ return undefined;
50
+ const abs = path.resolve(cwd, sourcePath);
51
+ const root = await laneRootFor(cwd).catch(() => path.resolve(cwd));
52
+ return { path: relOrAbs(root, abs), sha256: promotedFrom.sha256 };
53
+ }
54
+ async function containedIn(abs, root) {
55
+ if (!root)
56
+ return false;
57
+ try {
58
+ await assertRealPathContained(abs, await fs.realpath(root));
59
+ return true;
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ }
65
+ /**
66
+ * Find and read the plan's spec-gap file. Candidates: `from.specGap.path`,
67
+ * then the lane decision's `specGap.path`; each resolved against the project
68
+ * folder and cwd. A candidate is used only when it is a spec-gap file name,
69
+ * sits (real path) inside cwd or the workspace folder, parses as a spec-gap
70
+ * envelope, and — when the plan holds a promotedFrom snapshot — is the SAME
71
+ * envelope (id match). Returns undefined when none qualifies.
72
+ */
73
+ async function readLiveSpecGap(cwd, content, promotedFrom, laneRecord) {
74
+ const recorded = [content.from.specGap?.path, laneRecord?.specGap?.path]
75
+ .filter((p) => typeof p === 'string' && p.trim() !== '');
76
+ if (recorded.length === 0)
77
+ return undefined;
78
+ const root = await laneRootFor(cwd).catch(() => path.resolve(cwd));
79
+ const ws = await workspaceFolderFor(cwd).catch(() => undefined);
80
+ for (const rec of recorded) {
81
+ if (!SPEC_GAP_FILE_RE.test(path.basename(rec)))
82
+ continue;
83
+ for (const base of [root, path.resolve(cwd)]) {
84
+ const abs = path.resolve(base, rec);
85
+ if (!(await containedIn(abs, cwd)) && !(await containedIn(abs, ws)) && !(await containedIn(abs, root)))
86
+ continue;
87
+ let env;
88
+ try {
89
+ env = readProjectFile(abs);
90
+ }
91
+ catch {
92
+ continue;
93
+ }
94
+ if (env.artefactType !== 'spec-gap')
95
+ continue;
96
+ if (promotedFrom && promotedFrom.id !== env.id)
97
+ continue;
98
+ return { env, shownPath: relOrAbs(root, abs) };
99
+ }
100
+ }
101
+ return undefined;
102
+ }
103
+ /**
104
+ * Resolve the sources a plan was made from. Prefers the live spec-gap file
105
+ * (answers given after the plan was made reach the phase), falls back to the
106
+ * plan's own creation snapshot (`promotedFrom`). Never throws.
107
+ */
108
+ export async function resolvePlanSources(args) {
109
+ const pf = args.envelope.promotedFrom && args.envelope.promotedFrom.artefactType === 'spec-gap'
110
+ ? args.envelope.promotedFrom : null;
111
+ try {
112
+ const live = await readLiveSpecGap(args.cwd, args.content, pf, args.laneRecord);
113
+ if (live) {
114
+ const snap = await buildPromotedFromSnapshot(live.env);
115
+ const createdSha = args.content.from.specGap?.sha256 ?? pf?.sha256;
116
+ const sourceDocument = live.env.artefactType === 'spec-gap' ? live.env.content.sourceDocument : undefined;
117
+ return {
118
+ specGap: {
119
+ path: live.shownPath,
120
+ title: live.env.title,
121
+ version: live.env.version,
122
+ origin: 'file',
123
+ changedSinceCreate: createdSha !== undefined && createdSha !== snap.sha256,
124
+ ...(sourceDocument ? { sourceDocument } : {}),
125
+ items: snap.snapshot.items,
126
+ },
127
+ };
128
+ }
129
+ }
130
+ catch {
131
+ // fall through to the snapshot — a read problem must never cost the phase its gaps
132
+ }
133
+ if (pf) {
134
+ const recordedPath = args.content.from.specGap?.path ?? args.laneRecord?.specGap?.path;
135
+ return {
136
+ specGap: {
137
+ ...(recordedPath ? { path: recordedPath } : {}),
138
+ title: pf.title,
139
+ version: pf.version,
140
+ origin: 'snapshot',
141
+ changedSinceCreate: false,
142
+ ...(args.laneRecord?.spec.path ? { sourceDocument: args.laneRecord.spec.path } : {}),
143
+ items: pf.snapshot.items,
144
+ },
145
+ };
146
+ }
147
+ return {};
148
+ }
149
+ const oneLine = (s) => s.replace(/\s+/g, ' ').trim();
150
+ function renderItem(it) {
151
+ const sev = it.severity ? ` [${it.severity}]` : '';
152
+ const state = it.answer
153
+ ? `ANSWERED: "${oneLine(it.answer)}"`
154
+ : it.status === 'open' ? 'OPEN (no answer yet)' : it.status.toUpperCase();
155
+ return `- ${it.id}${sev} ${state} — ${oneLine(it.text)}`;
156
+ }
157
+ /**
158
+ * The <plan_sources> block for one phase prompt: the gap items the phase's
159
+ * `manifest.reads` names, in full; every other ANSWERED item as a short line
160
+ * (an answer is a decision — it must never be lost to a narrow reads list).
161
+ * Returns [] when the plan has no spec-gap source (block omitted entirely, so
162
+ * goal-only plans keep a byte-identical prompt).
163
+ */
164
+ export function renderPlanSourcesBlock(sources, phase) {
165
+ const sg = sources.specGap;
166
+ if (!sg || sg.items.length === 0)
167
+ return [];
168
+ const reads = new Set(phase.manifest.reads);
169
+ const read = sg.items.filter((it) => reads.has(it.id));
170
+ const otherAnswered = sg.items.filter((it) => !reads.has(it.id) && it.answer);
171
+ const openOthers = sg.items.filter((it) => !reads.has(it.id) && !it.answer).length;
172
+ const where = sg.path ? ` (${sg.path})` : '';
173
+ const lines = [
174
+ '<plan_sources>',
175
+ `Spec-gap file this plan was made from: "${sg.title}" v${sg.version}${where}.`,
176
+ sg.origin === 'file'
177
+ ? 'CSPeach read it for this phase. The items below ARE its content: use them, do not search the folder for the file.'
178
+ : 'The file could not be read now, so these are the items as they were when the plan was made. Use them, do not search the folder for the file.',
179
+ ];
180
+ if (sg.changedSinceCreate) {
181
+ lines.push('Note: the spec-gap answers changed after the plan was made. The answers below are the current ones.');
182
+ }
183
+ if (sg.sourceDocument)
184
+ lines.push(`Spec document: ${sg.sourceDocument}`);
185
+ if (read.length > 0) {
186
+ lines.push('', `Gap items this phase reads (manifest.reads):`, ...read.map(renderItem));
187
+ }
188
+ if (otherAnswered.length > 0) {
189
+ lines.push('', 'Other answered gap items (decisions already made):', ...otherAnswered.map(renderItem));
190
+ }
191
+ if (openOthers > 0)
192
+ lines.push('', `${openOthers} other gap item${openOthers === 1 ? ' is' : 's are'} still open and not read by this phase.`);
193
+ const openRead = read.filter((it) => !it.answer).map((it) => it.id);
194
+ if (openRead.length > 0) {
195
+ lines.push('', `Still open among this phase's items: ${openRead.join(', ')}. Decide them from the spec, ask the developer where a choice matters, and record each decision in work.notes.`);
196
+ }
197
+ lines.push('</plan_sources>');
198
+ return lines;
199
+ }
@@ -131,5 +131,5 @@ export async function runPromoteCommand(args) {
131
131
  if (env.artefactType === 'spec-gap' && env.content.sourceDocument) {
132
132
  extendedSkillInput = `${extendedSkillInput}\n\nSource spec document: ${env.content.sourceDocument}`;
133
133
  }
134
- return { promotedFrom, extendedSkillInput };
134
+ return { promotedFrom, extendedSkillInput, sourcePath: resolvedPath };
135
135
  }
@@ -14,11 +14,12 @@ import { parsePlanContent } from './plan-schema.js';
14
14
  import { validateEnvelope } from './validate.js';
15
15
  import { readProjectFile } from './status.js';
16
16
  import { saveProject } from './save.js';
17
- import { renderEmailTemplate } from './email-template.js';
17
+ import { formatSavedLines } from './saved-lines.js';
18
18
  import { writePlanHandover } from './handover-md.js';
19
19
  import { ensureWorkspace, resolveWorkspacePath } from './workspace.js';
20
20
  import { askYesNo, isYes } from './yes-no.js';
21
21
  import { checkLaneConsistency, checkRequiredDeploy, LANE_CHANGED_AT_CREATE, readPlanLane } from './plan-lane.js';
22
+ import { specGapRefForSave } from './plan-sources.js';
22
23
  const SKILL_REGISTRY = {
23
24
  'abap-spec-gap': {
24
25
  artefactType: 'spec-gap',
@@ -282,7 +283,11 @@ export async function runSaveCommand(args) {
282
283
  let planExtract = null;
283
284
  try {
284
285
  if (spec.artefactType === 'plan') {
285
- planExtract = extractPlan(args.skillOutput, priorContent, args.lane ? { lane: { path: args.lane.path, sha256: args.lane.sha256 } } : {});
286
+ const specGap = await specGapRefForSave(args.cwd, args.promotedFrom, args.promotedFromPath);
287
+ planExtract = extractPlan(args.skillOutput, priorContent, {
288
+ ...(args.lane ? { lane: { path: args.lane.path, sha256: args.lane.sha256 } } : {}),
289
+ ...(specGap ? { specGap } : {}),
290
+ });
286
291
  extract = planExtract;
287
292
  }
288
293
  else {
@@ -421,7 +426,9 @@ export async function runSaveCommand(args) {
421
426
  // Best-effort by design (writePlanHandover logs one dim line on failure).
422
427
  if (spec.artefactType === 'plan')
423
428
  writePlanHandover(path, args.log);
424
- args.log(`Saved: ${path}`);
429
+ // 1.1.28 — two plain lines; the old suggested-email block is gone (saved-lines.ts).
430
+ const [savedLine, reviewLine] = formatSavedLines(path, args.cwd);
431
+ args.log(savedLine);
425
432
  // Project Register (2026-09-18): when this laptop has joined a project, a
426
433
  // decision / fix record is written to the LOCAL outbox as a by-product of the
427
434
  // save. No project joined → `activeProject()` is one missing-file read and
@@ -445,15 +452,6 @@ export async function runSaveCommand(args) {
445
452
  if (planExtract && prior) {
446
453
  args.log(`(revision v${env.version} of the existing plan — version chain preserved, no new id)`);
447
454
  }
448
- args.log(`Share at: https://cspeach.dev/edit`);
449
- args.log('');
450
- args.log(renderEmailTemplate({
451
- title: env.title,
452
- artefactType: spec.artefactType,
453
- filename: path.split(/[\\\/]/).pop(),
454
- itemCount: env.interaction.items.length,
455
- itemNoun: spec.itemNoun,
456
- author: args.author.name,
457
- }));
455
+ args.log(reviewLine);
458
456
  return path;
459
457
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The two lines printed after a project file is saved (every artefact type).
3
+ *
4
+ * 1.1.28 (owner decision): replaces the old "Suggested email — copy from
5
+ * below the line" block (subject, body, review steps, sign-off). It looked
6
+ * cluttered and signed itself with the OS account name ("Admin"). Now:
7
+ *
8
+ * Saved: <file name> (in this project's .cspeach folder)
9
+ * To review or share: open cspeach.dev/edit and drop the file in.
10
+ *
11
+ * Never prints a user name.
12
+ */
13
+ import { basename, dirname, join, resolve } from 'node:path';
14
+ function editorLabel() {
15
+ const url = process.env.CSPEACH_EDITOR_URL?.trim();
16
+ return url ? url : 'cspeach.dev/edit';
17
+ }
18
+ function sameDir(a, b) {
19
+ const norm = (p) => {
20
+ const r = resolve(p);
21
+ return process.platform === 'win32' ? r.toLowerCase() : r;
22
+ };
23
+ return norm(a) === norm(b);
24
+ }
25
+ /** `<file name> (in this project's .cspeach folder)` — or the folder, when elsewhere. */
26
+ export function savedLocation(savedPath, cwd) {
27
+ const dir = dirname(savedPath);
28
+ const where = sameDir(dir, join(cwd, '.cspeach')) ? "this project's .cspeach folder" : dir;
29
+ return `${basename(savedPath)} (in ${where})`;
30
+ }
31
+ export function formatSavedLines(savedPath, cwd) {
32
+ return [
33
+ `Saved: ${savedLocation(savedPath, cwd)}`,
34
+ `To review or share: open ${editorLabel()} and drop the file in.`,
35
+ ];
36
+ }
@@ -78,7 +78,7 @@ function renderRow(r, width) {
78
78
  }
79
79
  }
80
80
  /**
81
- * The checklist as display lines: `Plan · N of M` header, then rows.
81
+ * The checklist as display lines: `Tasks · N of M` header, then rows.
82
82
  * - maxRows: cap on ITEM rows (header excluded). Not expanded → also capped
83
83
  * at CHECKLIST_MAX_ROWS; expanded → only maxRows (the §2 height cap) applies.
84
84
  * - foldedToOne: exactly one line (used while a card is open).
@@ -92,7 +92,7 @@ export function formatChecklist(list, o) {
92
92
  const todos = list.map((t) => ({ ...t, text: sanitizeControlChars(t.text) }));
93
93
  const width = Math.max(1, Math.floor(o.width));
94
94
  const cur = currentTodoIndex(todos);
95
- const count = `Plan · ${countLabel(doneCount(todos), todos.length)}`;
95
+ const count = `Tasks · ${countLabel(doneCount(todos), todos.length)}`;
96
96
  if (o.foldedToOne) {
97
97
  const tail = cur >= 0 ? ` · ${getGlyphs().current} ${todos[cur].text}` : '';
98
98
  return [chalk.bold(truncateToWidth(count + tail, width))];
@@ -103,15 +103,15 @@ export function formatChecklist(list, o) {
103
103
  }
104
104
  /**
105
105
  * The one line a turn leaves in scrollback when its plan changed (spec §4):
106
- * `✓ Plan · M of M done` (ok) when finished, else `Plan · N of M done` (dim).
106
+ * `✓ Tasks · M of M done` (ok) when finished, else `Tasks · N of M done` (dim).
107
107
  */
108
108
  export function formatPlanClose(todos) {
109
109
  const done = doneCount(todos);
110
110
  const total = todos.length;
111
111
  if (total > 0 && done === total) {
112
- return paint('ok', `${getGlyphs().done} Plan · ${countLabel(done, total)} done`);
112
+ return paint('ok', `${getGlyphs().done} Tasks · ${countLabel(done, total)} done`);
113
113
  }
114
- return paint('dim', `Plan · ${countLabel(done, total)} done`);
114
+ return paint('dim', `Tasks · ${countLabel(done, total)} done`);
115
115
  }
116
116
  let modeOverride;
117
117
  /** Test seam — force a mode; `undefined` restores detection. */
@@ -0,0 +1,141 @@
1
+ // src/renderer/machine-blocks.ts
2
+ //
3
+ // 1.1.28 — machine-readable blocks the skills emit for the CLI
4
+ // (`<!-- cspeach:plan-manifest {...} -->`, the legacy `<!-- csforge:... -->`
5
+ // prefix, and the cca / upgrade / modernize / test-coverage manifests) are
6
+ // parsed from the RAW assistant content (extract-plan.ts, plan-continue.ts,
7
+ // loop.ts onPlanManifest — untouched by this file) but must never reach the
8
+ // screen: a plan manifest is a multi-KB JSON blob.
9
+ //
10
+ // This is a streaming filter that sits in front of renderChunk (loop.ts), so
11
+ // it covers the Ink and classic renderers and one-shot stdout alike. It only
12
+ // changes what is DISPLAYED; the assistant content, the session file and the
13
+ // turn stream log keep the block byte-for-byte.
14
+ //
15
+ // Rule: a block starts with `<!--`, optional blanks, `cspeach:` or `csforge:`
16
+ // at the START of a line. It ends at a `-->` on the opener's own line, or
17
+ // else at a `-->` that starts a later line (one newline straight after it is
18
+ // dropped too). An inline mention mid-line is prose and stays.
19
+ // A chunk boundary can fall anywhere: a line that could still become an
20
+ // opener is held until it can be decided; inside a block a tail that could
21
+ // still become the closing `-->` is held the same way.
22
+ export function initialMachineBlockState() {
23
+ return { held: '', inBlock: false, firstLine: false, eatNewline: false, lineStart: true };
24
+ }
25
+ // A leading `\n` marks a line start (the probe prepends one when the text
26
+ // begins at a line start) — no `^`, which would match a mid-line chunk start.
27
+ const OPENER_RE = /\n[ \t]*<!--[ \t]*(?:cspeach|csforge):/;
28
+ const PREFIXES = ['cspeach:', 'csforge:'];
29
+ const CLOSER_RE = /\n[ \t]*-->/;
30
+ const PARTIAL_CLOSER_RE = /^\n[ \t]*-{0,2}$/;
31
+ /** Could `frag` (one incomplete line) still grow into a block opener? */
32
+ function couldBeOpener(frag) {
33
+ const s = frag.replace(/^[ \t]*/, '');
34
+ if (s.length <= 4)
35
+ return '<!--'.startsWith(s);
36
+ if (!s.startsWith('<!--'))
37
+ return false;
38
+ const r = s.slice(4).replace(/^[ \t]*/, '');
39
+ return PREFIXES.some((p) => p.startsWith(r));
40
+ }
41
+ export function filterMachineBlocks(chunk, prev) {
42
+ const st = { ...prev };
43
+ let work = st.held + chunk;
44
+ st.held = '';
45
+ let output = '';
46
+ for (;;) {
47
+ if (st.eatNewline) {
48
+ if (work === '')
49
+ break;
50
+ if (work.startsWith('\r\n')) {
51
+ work = work.slice(2);
52
+ st.lineStart = true;
53
+ }
54
+ else if (work === '\r') {
55
+ st.held = work;
56
+ work = '';
57
+ break;
58
+ }
59
+ else if (work[0] === '\n') {
60
+ work = work.slice(1);
61
+ st.lineStart = true;
62
+ }
63
+ else
64
+ st.lineStart = false;
65
+ st.eatNewline = false;
66
+ }
67
+ if (st.inBlock && st.firstLine) {
68
+ // Still on the opener line: any `-->` closes (`<!-- cspeach:x -->`).
69
+ const close = work.indexOf('-->');
70
+ const nl = work.indexOf('\n');
71
+ if (close >= 0 && (nl < 0 || close < nl)) {
72
+ work = work.slice(close + 3);
73
+ st.inBlock = false;
74
+ st.eatNewline = true;
75
+ continue;
76
+ }
77
+ if (nl < 0) {
78
+ st.held = work.slice(-2);
79
+ break;
80
+ }
81
+ st.firstLine = false;
82
+ work = work.slice(nl); // keep the newline: it marks the next line start
83
+ continue;
84
+ }
85
+ if (st.inBlock) {
86
+ // Body lines: only a `-->` at the start of a line closes, the same rule
87
+ // as the extractors (`\n\s*-->`), so a note like "A --> B" inside the
88
+ // JSON does not end the block early.
89
+ const m = CLOSER_RE.exec(work);
90
+ if (!m) {
91
+ const lastNl = work.lastIndexOf('\n');
92
+ const tail = lastNl >= 0 ? work.slice(lastNl) : '';
93
+ st.held = PARTIAL_CLOSER_RE.test(tail) ? tail : '';
94
+ break;
95
+ }
96
+ work = work.slice(m.index + m[0].length);
97
+ st.inBlock = false;
98
+ st.eatNewline = true;
99
+ continue;
100
+ }
101
+ const probe = (st.lineStart ? '\n' : '') + work;
102
+ const m = OPENER_RE.exec(probe);
103
+ if (m) {
104
+ const offset = st.lineStart ? 1 : 0;
105
+ const lineBegin = m.index + 1 - offset; // index in `work`, just after the newline
106
+ output += work.slice(0, lineBegin);
107
+ work = work.slice(m.index + m[0].length - offset);
108
+ st.inBlock = true;
109
+ st.firstLine = true;
110
+ continue;
111
+ }
112
+ // No opener yet: hold the last incomplete line if it could become one.
113
+ const lastNl = work.lastIndexOf('\n');
114
+ const fragStart = lastNl >= 0 ? lastNl + 1 : st.lineStart ? 0 : -1;
115
+ if (fragStart >= 0 && fragStart < work.length && couldBeOpener(work.slice(fragStart))) {
116
+ output += work.slice(0, fragStart);
117
+ st.held = work.slice(fragStart);
118
+ st.lineStart = true;
119
+ }
120
+ else {
121
+ output += work;
122
+ if (work.length > 0)
123
+ st.lineStart = work.endsWith('\n');
124
+ }
125
+ break;
126
+ }
127
+ return { output, state: st };
128
+ }
129
+ /** End of stream: release held prose; an unterminated block shows nothing. */
130
+ export function flushMachineBlocks(state) {
131
+ if (state.inBlock)
132
+ return '';
133
+ if (state.eatNewline)
134
+ return state.held === '\r' ? '' : state.held;
135
+ return state.held;
136
+ }
137
+ /** Whole-text form (same rule) for output that is not streamed. */
138
+ export function stripMachineBlocks(text) {
139
+ const r = filterMachineBlocks(text, initialMachineBlockState());
140
+ return r.output + flushMachineBlocks(r.state);
141
+ }
@@ -122,6 +122,8 @@ const RULES = {
122
122
  const incName = inc !== undefined ? (Object.hasOwn(INCLUDE_NAMES, inc) ? INCLUDE_NAMES[inc] : inc) : '';
123
123
  return { verb: 'Wrote', object: shortRight(`${str(a['className']) ?? ''} ${incName}`.trim()) };
124
124
  },
125
+ sap_get_text_elements: (a) => ({ verb: 'Read texts of', object: shortRight(`${str(a['name']) ?? ''} ${str(a['kind']) ?? ''}`.trim()) }),
126
+ sap_set_text_elements: (a) => ({ verb: 'Wrote texts', object: shortRight(`${str(a['name']) ?? ''} ${str(a['kind']) ?? ''}`.trim()) }),
125
127
  sap_search_object: (a, r) => {
126
128
  // "*AGING*" · PROG — the type filter tells otherwise identical searches apart.
127
129
  const type = str(a['type']);
@@ -263,6 +265,7 @@ const PRESENT = {
263
265
  Preview: 'Preview', 'Searched samples': 'Searching samples', 'Read sample': 'Reading sample',
264
266
  'Audited metadata': 'Auditing metadata', 'Extended model': 'Extending model',
265
267
  'Checked capability': 'Checking capability',
268
+ 'Read texts of': 'Reading texts of', 'Wrote texts': 'Writing texts',
266
269
  };
267
270
  export function toolActivityLabel(name, args) {
268
271
  const l = toolLabel(name, args);
@@ -0,0 +1,22 @@
1
+ // src/repl/plan-hop.ts
2
+ //
3
+ // Task 13 fix round (2026-10-04) — an "in-run hop" is a plan-resume the plan
4
+ // chain ITSELF queued (runPostPhaseChain / runReauditPass /
5
+ // runContinuationChain: the next step, the re-run after a failed check).
6
+ // Marked at the source, never inferred from the command shape: the first step
7
+ // of an /abap-app run (lane/app-chain.ts) and a typed resume are NOT hops, so
8
+ // they show the board and the guarded notice once.
9
+ export function createPlanHopTracker(slot) {
10
+ let chainQueued = null;
11
+ return {
12
+ queue(cmd) {
13
+ chainQueued = cmd;
14
+ slot.set(cmd);
15
+ },
16
+ take(cmd) {
17
+ const hop = chainQueued !== null && chainQueued === cmd;
18
+ chainQueued = null;
19
+ return hop;
20
+ },
21
+ };
22
+ }
@@ -26,6 +26,19 @@ let planGateApprovedThisTurn = false;
26
26
  export function recordWriteOp(tool, args) {
27
27
  writeOpsThisTurn.push({ tool, args, ts: new Date().toISOString() });
28
28
  }
29
+ let batchApprovedProvider = null;
30
+ export function setRule8BatchApprovedProvider(fn) {
31
+ batchApprovedProvider = fn;
32
+ }
33
+ /**
34
+ * True for a tool that cannot run without an approval id from request_approval
35
+ * (its schema requires `approval_id`): each such call is already approved one
36
+ * by one. Shell, file and other tools without an id are never in this set.
37
+ */
38
+ export function toolSpendsApprovalId(tool) {
39
+ const req = tool.input_schema?.required;
40
+ return Array.isArray(req) && req.includes('approval_id');
41
+ }
29
42
  /**
30
43
  * Returns true on the 2nd (and later) mutating call of the turn — the gate
31
44
  * fires here. The 1st mutating call proceeds without the prompt; only its
@@ -38,9 +51,11 @@ export function recordWriteOp(tool, args) {
38
51
  * batches the model issues OUTSIDE the approval flow (rare but possible —
39
52
  * e.g. recovery paths, ad-hoc tool calls), so the safety net stays.
40
53
  */
41
- export function shouldGateRule8() {
54
+ export function shouldGateRule8(tool) {
42
55
  if (planGateApprovedThisTurn)
43
56
  return false;
57
+ if (tool && batchApprovedProvider?.(tool))
58
+ return false;
44
59
  return writeOpsThisTurn.length >= 1;
45
60
  }
46
61
  /**