@cspeach/cli 1.1.47 → 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.
- package/dist/agent/level.js +1 -1
- package/dist/agent/loop.js +7 -6
- package/dist/agent/sap-connection-adapter.js +2 -0
- package/dist/agent/system-prompt.js +2 -2
- package/dist/agent/tool-dispatch.js +13 -1
- package/dist/approvals/op-labels.js +49 -10
- package/dist/approvals/render.js +14 -23
- package/dist/approvals/risk-floor.js +196 -19
- package/dist/approvals/visible-changes.js +35 -0
- package/dist/commands/config-set.js +5 -5
- package/dist/commands/cost.js +2 -1
- package/dist/commands/help.js +1 -1
- package/dist/commands/project.js +301 -0
- package/dist/commands/team-plan.js +16 -6
- package/dist/commands/team.js +295 -68
- package/dist/config/loader.js +13 -0
- package/dist/cost/cost-log.js +7 -4
- package/dist/cost/credits-wire.js +11 -6
- package/dist/cost/credits.js +15 -0
- package/dist/cost/display-mode.js +16 -4
- package/dist/doctor/checks/zcspeach.js +96 -0
- package/dist/journey/chain.js +489 -61
- package/dist/journey/clean-core.js +61 -36
- package/dist/journey/flow.js +75 -2
- package/dist/journey/people.js +37 -0
- package/dist/journey/rebuild.js +7 -6
- package/dist/journey/run.js +129 -42
- package/dist/journey/trigger.js +16 -10
- package/dist/one-shot.js +1 -1
- package/dist/project-context/render.js +7 -0
- package/dist/projects/clean-core-confirm.js +166 -44
- package/dist/projects/email-template.js +56 -0
- package/dist/projects/extract-cca.js +8 -7
- package/dist/projects/index.js +0 -1
- package/dist/projects/merge-cca.js +0 -19
- package/dist/projects/merge-upgrade.js +2 -2
- package/dist/projects/project-folder.js +2 -1
- package/dist/projects/save-command.js +51 -14
- package/dist/register/decision-rules.js +2 -1
- package/dist/register/export-files.js +18 -0
- package/dist/register/picture.js +3 -3
- package/dist/register/project-transport.js +125 -0
- package/dist/register/records.js +25 -13
- package/dist/register/rules-edit.js +5 -0
- package/dist/register/status-file/progress-chart.js +17 -6
- package/dist/register/status-file/register-map.js +8 -5
- package/dist/register/status-file/register-model.js +55 -36
- package/dist/register/status-file/register-parts.js +97 -82
- package/dist/register/status-file/register-status-file.js +16 -7
- package/dist/register/status-file/report-model.js +293 -205
- package/dist/register/status-file/team-css.js +1 -1
- package/dist/register/status-html.js +11 -4
- package/dist/register/store.js +2 -2
- package/dist/register/workbooks.js +24 -13
- package/dist/renderer/footer-line.js +10 -2
- package/dist/renderer/status-footer.js +2 -1
- package/dist/renderer/tool-labels.js +2 -2
- package/dist/repl/builtin-commands.js +1 -1
- package/dist/repl/continue-skill.js +5 -0
- package/dist/repl/level-command.js +6 -15
- package/dist/repl.js +21 -8
- package/dist/router/router-core.js +5 -3
- package/dist/session/audit-export.js +4 -1
- package/dist/skill-catalog.js +4 -7
- package/dist/tools/_team-work-path.js +53 -0
- package/dist/tools/approval.js +97 -19
- package/dist/tools/filesystem/file-edit.js +8 -1
- package/dist/tools/filesystem/file-write.js +8 -1
- package/dist/tools/local-files.js +31 -0
- package/dist/tools/project/_merge-shared.js +2 -2
- package/dist/tools/project/_team-tool-shared.js +5 -0
- package/dist/tools/project/cca_merge.js +1 -11
- package/dist/tools/project/rules_propose.js +2 -1
- package/dist/tools/project/team_findings.js +81 -28
- package/dist/tools/project/team_lane_move.js +127 -0
- package/dist/tools/project/upgrade_merge_progress.js +8 -4
- package/dist/tools/subagent/schedule_create.js +131 -0
- package/dist/tools/text-elements/tools.js +7 -1
- package/dist/tools/tool-loading.js +1 -1
- package/dist/tools/update-method-intercept.js +18 -1
- package/dist/tools/write-pipeline.js +19 -5
- package/dist/ui/approval-modal.js +6 -3
- package/dist/ui/error-emitter.js +21 -0
- package/dist/ui/footer-line.js +19 -1
- package/dist/ui/header.js +41 -0
- package/dist/ui/ink-classifier-route.js +19 -0
- package/dist/ui/rich-error-box.js +9 -0
- package/dist/ui/sidebar.js +12 -0
- package/dist/ui/status-emitter.js +19 -0
- package/dist/ui/status-line.js +62 -0
- package/dist/ui/status-row.js +38 -0
- package/dist/ui/todo-panel.js +85 -0
- package/dist/ui/widgets/ask-form.js +250 -0
- package/dist/ui/widgets/ask-question-modal.js +178 -0
- package/fiori/zlas-inspection-lr/package.json +26 -0
- package/fiori/zlas-inspection-lr/ui5-local.yaml +45 -0
- package/fiori/zlas-inspection-lr/ui5.yaml +41 -0
- package/fiori/zlas-inspection-lr/webapp/Component.js +12 -0
- package/fiori/zlas-inspection-lr/webapp/i18n/i18n.properties +9 -0
- package/fiori/zlas-inspection-lr/webapp/index.html +35 -0
- package/fiori/zlas-inspection-lr/webapp/manifest.json +145 -0
- package/live-check-2026-10-17-1343.txt +41 -0
- package/package.json +121 -121
- package/test-harness/fe-draft/app.ts +0 -188
- package/test-harness/fe-draft/fe-draft.e2e.test.ts +0 -461
- package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +0 -51
- package/test-harness/fe-draft/routes.ts +0 -224
- package/test-harness/fe-draft/routes.unit.test.ts +0 -45
- package/test-harness/fe-draft/server.ts +0 -225
- 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).
|
|
5
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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 =
|
|
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
|
|
45
|
-
*
|
|
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
|
+
}
|
package/dist/ui/footer-line.js
CHANGED
|
@@ -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
|
-
|
|
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();
|