@cspeach/cli 1.1.48 → 1.1.49

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 (101) hide show
  1. package/dist/agent/level.js +1 -1
  2. package/dist/agent/loop.js +7 -6
  3. package/dist/agent/sap-connection-adapter.js +2 -0
  4. package/dist/agent/system-prompt.js +2 -2
  5. package/dist/agent/tool-dispatch.js +13 -1
  6. package/dist/approvals/op-labels.js +49 -10
  7. package/dist/approvals/render.js +14 -23
  8. package/dist/approvals/risk-floor.js +196 -19
  9. package/dist/approvals/visible-changes.js +35 -0
  10. package/dist/commands/help.js +1 -1
  11. package/dist/commands/project.js +301 -0
  12. package/dist/commands/team-plan.js +16 -6
  13. package/dist/commands/team.js +295 -68
  14. package/dist/doctor/checks/zcspeach.js +96 -0
  15. package/dist/journey/chain.js +489 -61
  16. package/dist/journey/clean-core.js +61 -36
  17. package/dist/journey/flow.js +75 -2
  18. package/dist/journey/people.js +37 -0
  19. package/dist/journey/rebuild.js +7 -6
  20. package/dist/journey/run.js +129 -42
  21. package/dist/journey/trigger.js +16 -10
  22. package/dist/one-shot.js +1 -1
  23. package/dist/project-context/render.js +7 -0
  24. package/dist/projects/clean-core-confirm.js +166 -44
  25. package/dist/projects/email-template.js +56 -0
  26. package/dist/projects/extract-cca.js +8 -7
  27. package/dist/projects/index.js +0 -1
  28. package/dist/projects/merge-cca.js +0 -19
  29. package/dist/projects/merge-upgrade.js +2 -2
  30. package/dist/projects/project-folder.js +2 -1
  31. package/dist/projects/save-command.js +51 -14
  32. package/dist/register/decision-rules.js +2 -1
  33. package/dist/register/export-files.js +18 -0
  34. package/dist/register/picture.js +3 -3
  35. package/dist/register/project-transport.js +125 -0
  36. package/dist/register/records.js +25 -13
  37. package/dist/register/rules-edit.js +5 -0
  38. package/dist/register/status-file/progress-chart.js +17 -6
  39. package/dist/register/status-file/register-map.js +8 -5
  40. package/dist/register/status-file/register-model.js +55 -36
  41. package/dist/register/status-file/register-parts.js +97 -82
  42. package/dist/register/status-file/register-status-file.js +16 -7
  43. package/dist/register/status-file/report-model.js +293 -205
  44. package/dist/register/status-file/team-css.js +1 -1
  45. package/dist/register/status-html.js +11 -4
  46. package/dist/register/store.js +2 -2
  47. package/dist/register/workbooks.js +24 -13
  48. package/dist/renderer/footer-line.js +10 -2
  49. package/dist/renderer/tool-labels.js +2 -2
  50. package/dist/repl/builtin-commands.js +1 -1
  51. package/dist/repl/continue-skill.js +5 -0
  52. package/dist/repl/level-command.js +6 -15
  53. package/dist/repl.js +21 -8
  54. package/dist/router/router-core.js +5 -3
  55. package/dist/skill-catalog.js +4 -7
  56. package/dist/tools/_team-work-path.js +53 -0
  57. package/dist/tools/approval.js +97 -19
  58. package/dist/tools/filesystem/file-edit.js +8 -1
  59. package/dist/tools/filesystem/file-write.js +8 -1
  60. package/dist/tools/local-files.js +31 -0
  61. package/dist/tools/project/_merge-shared.js +2 -2
  62. package/dist/tools/project/_team-tool-shared.js +5 -0
  63. package/dist/tools/project/cca_merge.js +1 -11
  64. package/dist/tools/project/rules_propose.js +2 -1
  65. package/dist/tools/project/team_findings.js +81 -28
  66. package/dist/tools/project/team_lane_move.js +127 -0
  67. package/dist/tools/project/upgrade_merge_progress.js +8 -4
  68. package/dist/tools/subagent/schedule_create.js +131 -0
  69. package/dist/tools/text-elements/tools.js +7 -1
  70. package/dist/tools/tool-loading.js +1 -1
  71. package/dist/tools/update-method-intercept.js +18 -1
  72. package/dist/tools/write-pipeline.js +19 -5
  73. package/dist/ui/approval-modal.js +6 -3
  74. package/dist/ui/error-emitter.js +21 -0
  75. package/dist/ui/footer-line.js +19 -1
  76. package/dist/ui/header.js +41 -0
  77. package/dist/ui/ink-classifier-route.js +19 -0
  78. package/dist/ui/rich-error-box.js +9 -0
  79. package/dist/ui/sidebar.js +12 -0
  80. package/dist/ui/status-emitter.js +19 -0
  81. package/dist/ui/status-line.js +62 -0
  82. package/dist/ui/status-row.js +38 -0
  83. package/dist/ui/todo-panel.js +85 -0
  84. package/dist/ui/widgets/ask-form.js +250 -0
  85. package/dist/ui/widgets/ask-question-modal.js +178 -0
  86. package/fiori/zlas-inspection-lr/package.json +26 -0
  87. package/fiori/zlas-inspection-lr/ui5-local.yaml +45 -0
  88. package/fiori/zlas-inspection-lr/ui5.yaml +41 -0
  89. package/fiori/zlas-inspection-lr/webapp/Component.js +12 -0
  90. package/fiori/zlas-inspection-lr/webapp/i18n/i18n.properties +9 -0
  91. package/fiori/zlas-inspection-lr/webapp/index.html +35 -0
  92. package/fiori/zlas-inspection-lr/webapp/manifest.json +145 -0
  93. package/live-check-2026-10-17-1343.txt +41 -0
  94. package/package.json +121 -121
  95. package/test-harness/fe-draft/app.ts +0 -188
  96. package/test-harness/fe-draft/fe-draft.e2e.test.ts +0 -461
  97. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +0 -51
  98. package/test-harness/fe-draft/routes.ts +0 -224
  99. package/test-harness/fe-draft/routes.unit.test.ts +0 -45
  100. package/test-harness/fe-draft/server.ts +0 -225
  101. package/test-harness/fe-draft/variants.ts +0 -196
@@ -0,0 +1,127 @@
1
+ /**
2
+ * team_lane_move — the lead moves packages between people (Task 4, 2026-10-20;
3
+ * B2's tool part).
4
+ *
5
+ * On a skill turn the person asks to move packages; the model calls this,
6
+ * never file_write (lanes.json is CSPeach's: the file tools refuse it in a
7
+ * team folder). Code checks everything first — joined, the lead, package
8
+ * names in full, each a package of the baseline, a reason — then the person
9
+ * answers ONE card. Yes runs the same body as `cspeach team lane move`
10
+ * (moveLanes): lanes.json plus one plan record, always. No writes nothing.
11
+ * Headless: refused, nobody is there to answer the card.
12
+ *
13
+ * Chat refuses this tool at dispatch (CHAT_LANE_MOVE_REASON). Rule 8 does not
14
+ * count it (project category): its own card is the one card.
15
+ */
16
+ import { registerTool } from '../index.js';
17
+ import { activeProject, readLanes, readProjectFile, RegisterError } from '../../register/store.js';
18
+ import { isLead, isPlainName, MAX_PERSON_NAME, moveLanes, planLaneMove } from '../../commands/team.js';
19
+ import { key as folderKey } from '../../projects/project-folder.js';
20
+ import { NOT_JOINED_LINE } from './_team-tool-shared.js';
21
+ import { shown, shownUpTo } from '../../register/atc-file.js';
22
+ export { NOT_JOINED_LINE };
23
+ /** The session runs in another folder than the joined project's. */
24
+ export const notInProjectLine = (folder) => `Open CSPeach in the project folder to move work: ${folder}.`;
25
+ export const NAME_LINE = `Give the person's name as plain text, at most ${MAX_PERSON_NAME} characters.`;
26
+ export const NEEDS_REASON_LINE = "A move needs the person's reason.";
27
+ export const HEADLESS_LINE = 'This needs a person at the keyboard.';
28
+ /** Live re-run L1: one sentence so the model never reads to-fix as "no decision yet". */
29
+ export const AFTER_MOVE_NEXT = "Tell the person what moved: the moved packages' own counts. "
30
+ + 'to-fix objects are decided as fix and not started; decided counts every recorded decision. Do not edit lanes.json.';
31
+ const MAX_REASON = 300;
32
+ const fail = (content) => ({ content, is_error: true });
33
+ const sentence = (s) => (/[.!?]$/.test(s) ? s : `${s}.`);
34
+ async function defaultConfirm(req) {
35
+ const { cardSlotEmitter } = await import('../../ui/card-slot.js');
36
+ if (cardSlotEmitter.listenerCount('confirm') > 0) {
37
+ const { askConfirm } = await import('../../ui/confirm-request.js');
38
+ return askConfirm(req);
39
+ }
40
+ const [{ lineConfirm }, { inkAwarePrompt }] = await Promise.all([import('../../journey/chain.js'), import('../../agent/loop.js')]);
41
+ return lineConfirm(inkAwarePrompt, (line) => console.log(line))(req);
42
+ }
43
+ async function defaultIdentity() {
44
+ const { getAuthorIdentity } = await import('../../agent/loop.js');
45
+ return getAuthorIdentity;
46
+ }
47
+ export async function teamLaneMoveHandler(args, ctx, hooks = {}) {
48
+ const joined = activeProject();
49
+ if (!joined)
50
+ return fail(NOT_JOINED_LINE);
51
+ // Review round 1 — the move is made from the project's own folder only.
52
+ if (folderKey(ctx.cwd) !== folderKey(joined.projectDir))
53
+ return fail(notInProjectLine(shownUpTo(joined.projectDir, 260)));
54
+ try {
55
+ const said = [];
56
+ const deps = {
57
+ identity: hooks.deps?.identity ?? (await defaultIdentity()),
58
+ ...hooks.deps,
59
+ print: (line) => { said.push(line); },
60
+ };
61
+ const project = readProjectFile(joined.projectDir);
62
+ if (!isLead(project, deps))
63
+ return fail(`Only the lead, ${shown(project.lead.name)}, moves packages.`);
64
+ const packages = Array.isArray(args.packages) ? args.packages.filter((p) => typeof p === 'string') : [];
65
+ const to = typeof args.to === 'string' ? args.to : '';
66
+ if (!packages.some((p) => p.trim()))
67
+ return fail('Name the packages to move in full, for example ZSD_ORDERS.');
68
+ if (!to.trim())
69
+ return fail('Say who the packages go to: a person\'s name or a lane id.');
70
+ if (!isPlainName(to))
71
+ return fail(NAME_LINE);
72
+ const lanes = readLanes(joined.projectDir);
73
+ const plan = planLaneMove(packages, to, joined, lanes);
74
+ const reason = typeof args.reason === 'string' ? args.reason.trim() : '';
75
+ if (!reason)
76
+ return fail(NEEDS_REASON_LINE);
77
+ if (reason.length > MAX_REASON)
78
+ return fail(`Keep the reason under ${MAX_REASON} characters.`);
79
+ if (plan.kind === 'nothing')
80
+ return { content: JSON.stringify({ moved: [], reason: plan.line }) };
81
+ const headless = hooks.headless ?? (await import('../../renderer/tty.js')).isHeadless();
82
+ if (headless)
83
+ return fail(HEADLESS_LINE);
84
+ const from = plan.holders.length ? plan.holders.map((l) => `${shown(l.owner)} (${l.id})`).join(', ') : 'nobody yet';
85
+ const res = await (hooks.confirm ?? defaultConfirm)({
86
+ id: 'team-lane-move',
87
+ tag: 'QUESTION',
88
+ title: `Move ${plan.names.join(', ')} to ${shown(plan.into.owner)}?`,
89
+ facts: [`From: ${from}.`, `Why: ${sentence(reason)}`, 'Recorded in the project history.'],
90
+ choices: [{ value: 'yes', label: 'Yes' }, { value: 'no', label: 'No' }],
91
+ initial: 0,
92
+ aliases: { y: 'yes', n: 'no' },
93
+ });
94
+ if (res.cancelled || res.value !== 'yes')
95
+ return { content: JSON.stringify({ moved: [], reason: 'declined' }) };
96
+ // The card may have waited: the move works on the lanes as they are now.
97
+ // Task 6 (R5): the result is the view's after the move — each moved package's own
98
+ // objects and parts, and the lane's total apart from them.
99
+ const done = moveLanes(plan.names, to, reason, joined, readProjectFile(joined.projectDir), readLanes(joined.projectDir), deps);
100
+ return { content: JSON.stringify({ ...done, next: AFTER_MOVE_NEXT }) };
101
+ }
102
+ catch (e) {
103
+ if (e instanceof RegisterError)
104
+ return fail(e.message);
105
+ throw e;
106
+ }
107
+ }
108
+ registerTool({
109
+ name: 'team_lane_move',
110
+ description: 'Move packages from one person to another in the team project this laptop joined (the lead only). '
111
+ + 'Give package names in full (no patterns), the person\'s name or lane id, and the person\'s reason in their words. '
112
+ + 'CSPeach checks it and asks the person on one card; on Yes it changes the lanes and records the move in the project history. '
113
+ + 'Never edit lanes.json yourself. No SAP call.',
114
+ isMutating: true,
115
+ category: 'project',
116
+ requiresSap: false,
117
+ input_schema: {
118
+ type: 'object',
119
+ properties: {
120
+ packages: { type: 'array', items: { type: 'string' }, description: 'Package names in full, for example ["ZSD_ORDERS", "ZMM_STOCK"].' },
121
+ to: { type: 'string', description: 'The person the packages go to (their name), or their lane id.' },
122
+ reason: { type: 'string', description: 'Why, in the person\'s words. Shown back on the card and kept in the history.' },
123
+ },
124
+ required: ['packages', 'to', 'reason'],
125
+ },
126
+ handler: (args, ctx) => teamLaneMoveHandler(args, ctx),
127
+ });
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * upgrade_merge_progress — deterministic reconciliation of N parallel
3
3
  * /abap-upgrade-fix progress envelopes (the /abap-upgrade-merge skill's
4
- * execution seam). See cca_merge.ts for the design rationale: the tool
5
- * does the arithmetic (battery S4 D6/D7), the model presents; the
6
- * pre-rendered manifest block is pasted verbatim and persisted by the
7
- * normal save hook.
4
+ * execution seam). The tool does the arithmetic (battery S4 D6/D7: models
5
+ * cannot keep counts consistent with rows by hand), the model presents;
6
+ * the pre-rendered manifest block is pasted verbatim and persisted by the
7
+ * normal save hook — persistence stays on the one existing save path
8
+ * instead of the tool minting envelopes through a second door, and the
9
+ * extractor's counts-vs-rows validation is a tamper backstop if the model
10
+ * edits the pasted block. (The twin cca_merge tool was cut 2026-10-20,
11
+ * drift spec §3.6.)
8
12
  *
9
13
  * Baseline discipline:
10
14
  * - every input's baselineRef.sha256 must match the merge baseline —
@@ -0,0 +1,131 @@
1
+ // cspeach-cli/src/tools/subagent/schedule_create.ts
2
+ /**
3
+ * schedule_create — Phase 3 / Chunk 3E.
4
+ *
5
+ * Persists a schedule envelope to ~/.cspeach/schedules/<uuid>.json.
6
+ * Filename is ALWAYS crypto.randomUUID — never derived from user
7
+ * input. Closes the path-injection class of attack on the schedules
8
+ * directory.
9
+ *
10
+ * IMPORTANT: this tool persists schedules. It does NOT execute them.
11
+ * A future chunk (Phase 4 or later) ships the actual runner. The
12
+ * tool description and the response body both surface this caveat
13
+ * so the agent doesn't assume the schedule will fire.
14
+ *
15
+ * Flag-gated: invisible to listTools() unless
16
+ * CSPEACH_TOOL_SCHEDULE_CREATE=on. isMutating: true (writes filesystem).
17
+ */
18
+ import { promises as fs } from 'node:fs';
19
+ import * as os from 'node:os';
20
+ import * as path from 'node:path';
21
+ import { randomUUID } from 'node:crypto';
22
+ import { registerTool } from '../index.js';
23
+ const NOT_EXECUTED_NOTE = 'Persisted only — the schedule runner has not shipped yet. ' +
24
+ 'See Phase 4+ for execution.';
25
+ // Test-overridable. Default to ~/.cspeach/schedules. Set null to reset.
26
+ let schedulesDirOverride = null;
27
+ /** Test-only: redirect the schedules dir so tests don't pollute the real home dir. */
28
+ export function __setSchedulesDirForTests(dir) {
29
+ schedulesDirOverride = dir;
30
+ }
31
+ function getSchedulesDir() {
32
+ return schedulesDirOverride ?? path.join(os.homedir(), '.cspeach', 'schedules');
33
+ }
34
+ // Strict ISO-8601 UTC: YYYY-MM-DDTHH:MM:SS(.sss)?Z
35
+ const ISO_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
36
+ export async function scheduleCreateHandler(args, _ctx) {
37
+ if (args.kind !== 'oneoff' && args.kind !== 'cron') {
38
+ return { content: `error: kind must be 'oneoff' or 'cron' — got '${args.kind}'`, is_error: true };
39
+ }
40
+ if (!args.when || typeof args.when !== 'string' || args.when.trim().length === 0) {
41
+ return { content: 'error: when is required (non-empty string)', is_error: true };
42
+ }
43
+ if (!args.command || typeof args.command !== 'string' || args.command.trim().length === 0) {
44
+ return { content: 'error: command is required (non-empty string)', is_error: true };
45
+ }
46
+ if (args.kind === 'oneoff') {
47
+ if (!ISO_PATTERN.test(args.when)) {
48
+ return { content: `error: when must be an ISO-8601 UTC timestamp (e.g. 2099-06-01T10:00:00Z) — got '${args.when}'`, is_error: true };
49
+ }
50
+ const t = Date.parse(args.when);
51
+ if (Number.isNaN(t)) {
52
+ return { content: `error: when is not a parseable ISO timestamp: '${args.when}'`, is_error: true };
53
+ }
54
+ // Belt-and-braces against silent month/day rollover. Date.parse('2099-02-30T00:00:00Z')
55
+ // returns a valid timestamp that round-trips to March 2 — without this check, the
56
+ // envelope persists '2099-02-30' but the runner fires on March 2 (phantom job).
57
+ const roundTrip = new Date(t).toISOString();
58
+ // Normalise both sides: lowercase 'z', strip optional fractional seconds, compare.
59
+ const norm = (s) => s.replace(/\.\d+Z$/, 'Z').toUpperCase();
60
+ if (norm(roundTrip) !== norm(args.when)) {
61
+ return { content: `error: when is not a valid calendar date: '${args.when}' (rolls to ${roundTrip})`, is_error: true };
62
+ }
63
+ if (t <= Date.now()) {
64
+ return { content: `error: when is in the past — schedule must be in the future`, is_error: true };
65
+ }
66
+ }
67
+ else {
68
+ // cron: just check it has 5 whitespace-separated fields.
69
+ const fields = args.when.trim().split(/\s+/);
70
+ if (fields.length !== 5) {
71
+ return { content: `error: cron expression must have 5 fields (min hour dom mon dow) — got ${fields.length}`, is_error: true };
72
+ }
73
+ }
74
+ const dir = getSchedulesDir();
75
+ await fs.mkdir(dir, { recursive: true });
76
+ // Defence-in-depth: resolve the directory and verify it sits inside
77
+ // the cspeach home (or the test override). Refuses if a symlink
78
+ // points the dir at /etc/, /tmp/host-controlled/, etc.
79
+ const realDir = await fs.realpath(dir);
80
+ // In test mode the override dir IS the root (self-containment is trivial —
81
+ // mkdtemp gives us a unique dir that acts as both schedules-dir and its own
82
+ // root). In prod the schedules dir must sit UNDER ~/.cspeach/, so the root
83
+ // is the parent. Asymmetric on purpose.
84
+ const expectedRoot = schedulesDirOverride !== null
85
+ ? path.resolve(schedulesDirOverride)
86
+ : path.join(os.homedir(), '.cspeach');
87
+ const rel = path.relative(expectedRoot, realDir);
88
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
89
+ return { content: `error: schedules directory '${realDir}' resolves outside expected root`, is_error: true };
90
+ }
91
+ const id = randomUUID();
92
+ const envelope = {
93
+ id,
94
+ kind: args.kind,
95
+ when: args.when,
96
+ command: args.command,
97
+ createdAt: new Date().toISOString(),
98
+ note: NOT_EXECUTED_NOTE,
99
+ };
100
+ const target = path.join(realDir, `${id}.json`);
101
+ await fs.writeFile(target, JSON.stringify(envelope, null, 2), { encoding: 'utf-8', mode: 0o600 });
102
+ return {
103
+ content: `schedule persisted (NOT YET EXECUTED — runner ships in a later chunk).\n` +
104
+ `path: ${target}\n` +
105
+ `id: ${id}\n` +
106
+ `kind: ${args.kind}\n` +
107
+ `when: ${args.when}\n` +
108
+ `command: ${args.command}\n` +
109
+ `note: ${NOT_EXECUTED_NOTE}`,
110
+ };
111
+ }
112
+ registerTool({
113
+ name: 'schedule_create',
114
+ description: 'Persist a oneoff or cron schedule envelope to ~/.cspeach/schedules/<uuid>.json. ' +
115
+ 'Filename is a fresh UUID — never derived from input. NOTE: this tool only PERSISTS the ' +
116
+ 'schedule. No runner is shipped yet — the agent should not assume the command will fire ' +
117
+ 'automatically. A future chunk will ship the runner.',
118
+ isMutating: true,
119
+ category: 'subagent',
120
+ flagGated: true,
121
+ input_schema: {
122
+ type: 'object',
123
+ properties: {
124
+ kind: { type: 'string', enum: ['oneoff', 'cron'], description: 'oneoff = single ISO timestamp; cron = 5-field expression' },
125
+ when: { type: 'string', description: 'ISO-8601 UTC timestamp for oneoff, or 5-field cron expression' },
126
+ command: { type: 'string', description: 'The command the runner WOULD execute (free-form string for now)' },
127
+ },
128
+ required: ['kind', 'when', 'command'],
129
+ },
130
+ handler: (args, ctx) => scheduleCreateHandler(args, ctx),
131
+ });
@@ -35,6 +35,7 @@ import { appendPending, finalizeToolCall } from '../../session/pending.js';
35
35
  import { snapshots, auditLog } from '@cspeach/sap-client';
36
36
  import { ERR } from '../../errors/codes.js';
37
37
  import { resolveWriteTransport } from '../transport-resolution.js';
38
+ import { projectTransportForWrite, transportForWrite } from '../../register/project-transport.js';
38
39
  import { noteWriteTransport } from '../../repl/current-transport.js';
39
40
  import { loadConfig } from '../../config/loader.js';
40
41
  import { getEffectiveWriteMode } from '../../repl/mode-cycle.js';
@@ -246,7 +247,12 @@ registerTool({
246
247
  if (args.transport && sessionTransport && args.transport !== sessionTransport) {
247
248
  ctx.chunkEmitter?.emit('warn', `LLM passed transport=${args.transport} but session current-transport=${sessionTransport}`);
248
249
  }
249
- const resolved = await resolveWriteTransport(ctx, 'PROG', name, args.transport ?? sessionTransport ?? undefined);
250
+ // Task 8 (B11) / Task 6: the one choice (transportForWrite) — explicit ?? the project request of
251
+ // this folder ?? session; the owning lock is looked up inside resolveWriteTransport and wins.
252
+ const supplied = transportForWrite({
253
+ op: 'modify', explicit: args.transport, project: projectTransportForWrite(ctx, verify.payload), session: sessionTransport,
254
+ }).request;
255
+ const resolved = await resolveWriteTransport(ctx, 'PROG', name, supplied);
250
256
  // 2026-10-04 — a plan-grant id never writes into a transport other than the grant's.
251
257
  if (grantOwningTransportMismatch(verify.payload, resolved.owning)) {
252
258
  return errorEnvelope({ error: ERR.APPROVAL_INVALID, reason: 'transport_mismatch', detail: grantOwningTransportDetail(name, resolved.owning) });
@@ -58,7 +58,7 @@ export const SKILL_STARTERS = Object.freeze({
58
58
  'abap-app': ['app_lane_resolve', 'read_document'],
59
59
  'abap-atc-fix': ['sap_atc_run'],
60
60
  'abap-cca': ['team_estate', 'sap_atc_run', 'sap_usage_references', 'sap_api_state'],
61
- 'abap-clean-core': ['sap_api_state', 'sap_atc_run', 'sap_usage_references', 'team_findings'],
61
+ 'abap-clean-core': ['sap_api_state', 'sap_atc_run', 'sap_usage_references', 'team_findings', 'team_lane_move'],
62
62
  'abap-data-model': ['sap_create_domain', 'sap_create_data_element'],
63
63
  'abap-document': ['sap_usage_references'],
64
64
  'abap-handover': ['sap_usage_references'],
@@ -28,7 +28,9 @@ export class CancelledByUserError extends Error {
28
28
  */
29
29
  export async function interceptUpdateMethod(client, call, hook) {
30
30
  const classSource = await client.getSource({ type: 'CLAS', name: call.className });
31
- const currentBody = extractMethodBody(classSource, call.methodName);
31
+ // C9 (Task 9): the proposed source is the body only, so compare body with
32
+ // body — the METHOD / ENDMETHOD wrapper lines are not part of the change.
33
+ const currentBody = methodBodyOf(extractMethodBody(classSource, call.methodName));
32
34
  const proposedBody = call.methodSource;
33
35
  const result = await hook({
34
36
  className: call.className,
@@ -87,6 +89,21 @@ export function extractMethodBody(classSource, methodName) {
87
89
  // diff still surfaces the partial body to the user.
88
90
  return lines.slice(start).join('\n');
89
91
  }
92
+ /**
93
+ * C9 (Task 9): the lines between `METHOD x.` and `ENDMETHOD.` of a block
94
+ * from extractMethodBody. Drops the first line when it opens the method and
95
+ * the last when it closes it; anything else is kept. Empty stays empty.
96
+ */
97
+ export function methodBodyOf(block) {
98
+ if (!block)
99
+ return block;
100
+ const lines = block.split('\n');
101
+ if (lines.length > 0 && /^\s*METHOD\b/i.test(lines[0]))
102
+ lines.shift();
103
+ if (lines.length > 0 && /^\s*ENDMETHOD\s*\./i.test(lines[lines.length - 1]))
104
+ lines.pop();
105
+ return lines.join('\n');
106
+ }
90
107
  function stripCommentsAndStrings(line) {
91
108
  // Walk the line and replace string-literal contents and trailing `"` comments
92
109
  // with spaces so keyword matching ignores them. Cheap and good enough for
@@ -28,6 +28,7 @@ import { auditLog, SapError } from '@cspeach/sap-client';
28
28
  import { verifyAndSpendApprovalId } from '../approvals/jwt.js';
29
29
  import { grantClaimMismatch, grantOwningTransportMismatch, grantOwningTransportDetail } from '../approvals/grant-claims.js';
30
30
  import { typeCode } from '../approvals/risk-floor.js';
31
+ import { projectTransportForWrite, transportForWrite } from '../register/project-transport.js';
31
32
  import { autoSnapshot, autoSnapshotClassInclude, autoSnapshotMessageClass } from './snapshot.js';
32
33
  import { includeSnapshotType } from './include-snapshot.js';
33
34
  import { buildWriteChainLine } from '../renderer/verify-chain.js';
@@ -367,9 +368,17 @@ export async function runWritePipeline(args, ctx, spec) {
367
368
  if (spec.transport.mode === 'resolve' || spec.transport.mode === 'fallback-resolve') {
368
369
  // D15/D20 — an object already locked in an open request is written under
369
370
  // THAT request; any other transport is a guaranteed CTS lock conflict.
370
- const supplied = spec.transport.mode === 'fallback-resolve'
371
- ? raw.transport ?? ctx.currentTransport?.get() ?? undefined
372
- : raw.transport;
371
+ // Task 8 (B11): one order for every write — owning lock (resolve below)
372
+ // ?? explicit ?? the project request of this folder ?? the session (where
373
+ // the mode has always used it). No joined project → null → as before.
374
+ // Task 6 (project view): the choice is transportForWrite's, the card's too; the
375
+ // owning lock is looked up below. A 'resolve' write has never used the session.
376
+ const supplied = transportForWrite({
377
+ op: spec.approval.op,
378
+ explicit: raw.transport,
379
+ project: projectTransportForWrite(ctx, verify.payload),
380
+ session: spec.transport.mode === 'fallback-resolve' ? ctx.currentTransport?.get() : undefined,
381
+ }).request;
373
382
  const resolved = await resolveWriteTransport(ctx, spec.transport.type(args), spec.transport.name(args), supplied);
374
383
  pre.transport = resolved.transport;
375
384
  pre.owning = resolved.owning;
@@ -383,8 +392,13 @@ export async function runWritePipeline(args, ctx, spec) {
383
392
  }
384
393
  }
385
394
  else {
386
- // Three-tier fallback: explicit arg → session current-transport → none.
387
- pre.transport = raw.transport ?? ctx.currentTransport?.get() ?? undefined;
395
+ // Three-tier fallback: explicit arg → (Task 8) project request → session current-transport → none.
396
+ pre.transport = transportForWrite({
397
+ op: spec.approval.op,
398
+ explicit: raw.transport,
399
+ project: projectTransportForWrite(ctx, verify.payload),
400
+ session: ctx.currentTransport?.get(),
401
+ }).request;
388
402
  }
389
403
  // ── Gate 2: Snapshot (Rule 7) ─────────────────────────────────────────────
390
404
  const snapRefusal = await snapshotGate(args, ctx, spec, pre);
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useRef, useState } from 'react';
3
3
  import { Box, Text, useInput, useStdout } from 'ink';
4
4
  import { riskReasonText, shownRiskReasons } from '../approvals/risk-floor.js';
5
- import { verbLine, viewAffordance, toSourceLines, affectsLine, releaseLines } from '../approvals/op-labels.js';
5
+ import { verbLine, viewAffordance, toSourceLines, affectsLine, releaseLines, behaviourLines } from '../approvals/op-labels.js';
6
6
  // BUG-1 (2026-07-05 live smoke): the interactive approval modal mounted
7
7
  // DEAD — every keypress ignored — when it appeared after a guarded
8
8
  // /abap-plan turn. Root cause is identical to the one AskCard
@@ -41,14 +41,17 @@ export function riskRows(effectiveRisk, width) {
41
41
  export const AFFECTS_MAX_ROWS = 2;
42
42
  /**
43
43
  * The rows under the verb line: "Affects: …" (none when the change names no
44
- * process), then — Part 3 D12 — a release card's fact lines, each wrapping
45
- * onto up to AFFECTS_MAX_ROWS rows. Any other change: exactly as before.
44
+ * process), then "Behaviour: …" (Task 7, up to 3 rows, none when absent),
45
+ * then — Part 3 D12 — a release card's fact lines, each wrapping onto up to
46
+ * AFFECTS_MAX_ROWS rows. Any other change: exactly as before.
46
47
  */
47
48
  export function affectsRows(change, width) {
48
49
  const cols = Math.max(1, width - MODAL_CHROME_COLS);
49
50
  const line = affectsLine(change);
50
51
  return [
51
52
  ...(line ? wrapRows(line, cols, AFFECTS_MAX_ROWS) : []),
53
+ // Task 7 — "Behaviour: …" (at most 3 rows; none when absent or 'none').
54
+ ...behaviourLines(change, cols),
52
55
  ...releaseLines(change).flatMap((l) => wrapRows(l, cols, AFFECTS_MAX_ROWS)),
53
56
  ];
54
57
  }
@@ -0,0 +1,21 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { shouldUseInk } from '../renderer/tty.js';
3
+ import chalk from 'chalk';
4
+ export const errorEmitter = new EventEmitter();
5
+ /**
6
+ * Emit an error. In Ink mode, the main App subscribes and pushes the error
7
+ * into its errorStack state for overlay rendering. In non-TTY / one-shot,
8
+ * we fall back to chalk.red on stderr — never mount a second Ink root.
9
+ */
10
+ export function emitError(err) {
11
+ if (shouldUseInk()) {
12
+ errorEmitter.emit('error', err);
13
+ }
14
+ else {
15
+ console.error(chalk.red(`[${err.code}] ${err.title}`));
16
+ if (err.body)
17
+ console.error(chalk.red(` ${err.body}`));
18
+ if (err.hint)
19
+ console.error(chalk.dim(` ${err.hint}`));
20
+ }
21
+ }
@@ -3,6 +3,22 @@ import { Text, useStdout } from 'ink';
3
3
  import { useSapState } from './sap-state-store.js';
4
4
  import { formatFooterLine, footerStateFrom } from '../renderer/footer-line.js';
5
5
  import { inputBoxWidth } from './footer.js';
6
+ import { projectTransport } from '../register/project-transport.js';
7
+ /** How long the footer trusts its last read of the project file (the line redraws often). */
8
+ const PROJECT_READ_MS = 2_000;
9
+ let lastRead = null;
10
+ /**
11
+ * Task 6 (project view, R8): the request of the project joined in this folder,
12
+ * read at most every two seconds. Null outside a project folder (never throws:
13
+ * `projectTransport` reads null for anything it cannot use).
14
+ */
15
+ export function footerProjectTransport(cwd = process.cwd(), now = Date.now()) {
16
+ if (lastRead && lastRead.cwd === cwd && now - lastRead.at < PROJECT_READ_MS)
17
+ return lastRead.request;
18
+ const request = projectTransport(cwd);
19
+ lastRead = { cwd, at: now, request };
20
+ return request;
21
+ }
6
22
  /**
7
23
  * The one footer line under the input box (piece 2, spec §8) — replaces the
8
24
  * StatusLine. Same width as the input box so the two align. Single truncating
@@ -12,5 +28,7 @@ export function FooterLine() {
12
28
  const s = useSapState();
13
29
  const { stdout } = useStdout();
14
30
  const width = inputBoxWidth(stdout?.columns || 80);
15
- return _jsx(Text, { wrap: "truncate-end", children: formatFooterLine(footerStateFrom(s), width) });
31
+ // In a project folder with a request, the footer says it (a write that names none lands there).
32
+ const project = footerProjectTransport();
33
+ return _jsx(Text, { wrap: "truncate-end", children: formatFooterLine(footerStateFrom(s, project), width) });
16
34
  }
@@ -0,0 +1,41 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useSapState } from './sap-state-store.js';
4
+ import { formatSpendSegment } from './status-line.js';
5
+ import { CLI_VERSION } from '../lib/version.js';
6
+ const PEACH = '#FFCBA4';
7
+ /**
8
+ * ⚠ SUPERSEDED (UX Wave 1, Task 4 — 2026-07) by ui/status-line.tsx, the
9
+ * bottom-anchored borderless statusline mounted in App between TurnStatus
10
+ * and Footer. This component is no longer rendered anywhere: Phase D6
11
+ * removed the persistent top Header (inline mode can't pin the frame top),
12
+ * and the statusline took over the SapStateStore display role. Kept for
13
+ * reference, not deleted.
14
+ *
15
+ * Single-line rich header (Phase D3, 2026-05-16) — back to one line per
16
+ * Laeeq's feedback that the two-line variant felt worse. Uses tighter
17
+ * separators and shorter labels so it fits cleanly on typical terminals
18
+ * (100+ cols). On a narrower terminal Ink will wrap rather than truncate,
19
+ * accepted as a degradation case.
20
+ *
21
+ * Layout:
22
+ * 🍑 CSPeach 0.6.5 │ S4H │ S.LAEEQ │ tr none │ 28k tok │ $0.54 │ approval-gated
23
+ */
24
+ export function Header({ writeMode } = {}) {
25
+ const s = useSapState();
26
+ const isAdvisory = writeMode === 'advisory-only';
27
+ const modeLabel = isAdvisory
28
+ ? 'ADVISORY'
29
+ : writeMode === 'approval-gated'
30
+ ? 'approval-gated'
31
+ : writeMode === 'auto'
32
+ ? 'auto'
33
+ : undefined;
34
+ const modeColor = isAdvisory ? 'yellow' : 'green';
35
+ const tokDisplay = s.tokenUsageToday >= 1000
36
+ ? `${(s.tokenUsageToday / 1000).toFixed(1)}k tok`
37
+ : `${s.tokenUsageToday} tok`;
38
+ // Shared with <StatusLine /> so the credits/dollar switch has one home.
39
+ const spendDisplay = formatSpendSegment(s);
40
+ return (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { bold: true, color: PEACH, children: ["\uD83C\uDF51 CSPeach ", CLI_VERSION] }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "cyan", children: s.system || 'no system' }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "white", children: s.user || '—' }), _jsx(Text, { color: "gray", children: " \u2502 tr " }), _jsx(Text, { color: "yellow", children: s.transport ?? 'none' }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "green", children: tokDisplay }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "green", children: spendDisplay }), modeLabel && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: modeColor, bold: isAdvisory, children: modeLabel })] }))] }));
41
+ }
@@ -0,0 +1,19 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ /**
5
+ * Medium-confidence banner rendered inside the Ink frame. Auto-settles to
6
+ * the classifier's top pick after 800ms, unless the user presses Tab — in
7
+ * which case we open the CommandPalette with alternates.
8
+ */
9
+ export function InkClassifierRoute({ skill, confidence, onSettle, onOpenPalette }) {
10
+ useEffect(() => {
11
+ const t = setTimeout(onSettle, 800);
12
+ return () => clearTimeout(t);
13
+ }, [onSettle]);
14
+ useInput((_input, key) => {
15
+ if (key.tab)
16
+ onOpenPalette();
17
+ });
18
+ return (_jsx(Box, { children: _jsxs(Text, { dimColor: true, children: ["\\u2504 Suggest /", skill, " (confidence ", Math.round(confidence * 100), "%) \\u2014 Tab to pick"] }) }));
19
+ }
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ function severityColor(s) {
4
+ return s === 'error' ? 'red' : s === 'warning' ? 'yellow' : 'blue';
5
+ }
6
+ export function RichErrorBox({ code, message, hint, severity = 'error' }) {
7
+ const color = severityColor(severity);
8
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: color, paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, color: color, children: code }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: message }) }), hint && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: hint }) }))] }));
9
+ }
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useSapState } from './sap-state-store.js';
4
+ export function Sidebar() {
5
+ const s = useSapState();
6
+ // Phase A** (2026-05-16) — dropped the "/ of $100" pseudo-budget. We
7
+ // don't know the user's actual API spending limit; showing a fake
8
+ // cap trained the colour signal to be meaningless. Now just shows the
9
+ // raw spend. Colour signal removed alongside.
10
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, width: 30, children: [_jsx(Text, { bold: true, color: "cyan", children: s.system || 'no system' }), _jsxs(Text, { color: "gray", children: ["user: ", _jsx(Text, { color: "white", children: s.user || '—' })] }), _jsxs(Text, { color: "gray", children: ["transport: ", _jsx(Text, { color: "yellow", children: s.transport ?? 'none' })] }), _jsxs(Text, { color: "gray", children: ["locks: ", s.locksHeld.length === 0 ? _jsx(Text, { dimColor: true, children: "(none)" })
11
+ : s.locksHeld.map((l, i) => _jsxs(Text, { color: "red", children: [l, " "] }, i))] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "green", children: [s.tokenUsageToday.toLocaleString(), " tok / $", s.dollarSpendToday.toFixed(2)] }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "\u2500 recent turns \u2500" }), s.recentTurns.slice(-5).reverse().map((t, i) => (_jsxs(Text, { dimColor: true, children: [t.time, " ", t.command.slice(0, 20)] }, i))), s.recentTurns.length === 0 && _jsx(Text, { dimColor: true, children: "(no turns yet)" })] })] }));
12
+ }
@@ -0,0 +1,19 @@
1
+ import { EventEmitter } from 'node:events';
2
+ /**
3
+ * Singleton channel from `runTurn` (producer) to `<App>` (consumer) for
4
+ * status-row updates in Ink mode. Phase A #1 (2026-05-16) — the prior
5
+ * version of the REPL claimed Ink rendered its own `<StatusRow>` but
6
+ * never imported the component. As a result Ink users saw NO cost,
7
+ * token, or session info ever. This emitter is the missing wire.
8
+ *
9
+ * Producer (repl.tsx, end-of-turn): emits an `update` event with the
10
+ * latest StatusRowData. Consumer (App.tsx): subscribes in a useEffect,
11
+ * stores in state, renders `<StatusRow data={...} />` above `<Footer>`.
12
+ *
13
+ * Why a separate emitter (not chunkEmitter): chunkEmitter pipes strings
14
+ * into Body. StatusRow needs structured data (numbers + dollar amounts)
15
+ * and lives in a fixed region — passing it through chunks would mean
16
+ * sentinel parsing for what is naturally typed state. Matches the
17
+ * approvalEmitter / errorEmitter / coachingPickerEmitter pattern.
18
+ */
19
+ export const statusEmitter = new EventEmitter();