@cspeach/cli 1.1.50 → 1.1.52
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/chat-prompt.js +4 -1
- package/dist/agent/conversation-prompt.js +30 -13
- package/dist/agent/inline-skill-block.js +341 -0
- package/dist/agent/loop.js +314 -44
- package/dist/agent/provider-shape.js +8 -4
- package/dist/agent/providers/ai-hub-provider.js +2 -2
- package/dist/agent/providers/azure-provider.js +2 -2
- package/dist/agent/providers/byok-provider.js +2 -2
- package/dist/agent/providers/local-provider.js +2 -2
- package/dist/agent/repair-partial.js +12 -0
- package/dist/agent/skill-block-key.js +134 -0
- package/dist/agent/steering-queue.js +50 -14
- package/dist/agent/system-prompt.js +22 -0
- package/dist/agent/tool-dispatch.js +19 -7
- package/dist/agent/turn-watchdog.js +1 -1
- package/dist/approvals/advisory-render.js +6 -3
- package/dist/approvals/approval-prompt.js +9 -4
- package/dist/approvals/op-labels.js +14 -0
- package/dist/approvals/plan-grant.js +8 -0
- package/dist/approvals/render.js +19 -8
- package/dist/approvals/risk-floor.js +32 -4
- package/dist/approvals/visible-changes.js +50 -0
- package/dist/cli.js +11 -3
- package/dist/commands/compact.js +12 -1
- package/dist/commands/config-set.js +14 -1
- package/dist/commands/config-show.js +16 -4
- package/dist/commands/export-audit.js +26 -3
- package/dist/commands/export-conversation.js +346 -0
- package/dist/commands/finish-check-guard.js +124 -0
- package/dist/commands/help.js +3 -0
- package/dist/commands/plan-boundary.js +5 -0
- package/dist/commands/plan-continue.js +11 -0
- package/dist/commands/plan-reground.js +27 -4
- package/dist/commands/plan-resume.js +111 -1
- package/dist/commands/plan-startup.js +5 -1
- package/dist/config/loader.js +5 -1
- package/dist/cost/cost-log.js +6 -0
- package/dist/cost/system-block.js +6 -0
- package/dist/docx/doc-meta.js +9 -1
- package/dist/docx/markdown-to-docx.js +14 -1
- package/dist/lane/app-chain.js +24 -1
- package/dist/models/server-config.js +1 -1
- package/dist/projects/document-save.js +65 -8
- package/dist/projects/document-skills.js +32 -1
- package/dist/projects/save-command.js +5 -1
- package/dist/projects/yes-no.js +5 -1
- package/dist/renderer/change-card.js +3 -1
- package/dist/renderer/steering-echo.js +53 -8
- package/dist/renderer/tool-labels.js +3 -1
- package/dist/repl/builtin-commands.js +1 -1
- package/dist/repl/chat-lane.js +5 -2
- package/dist/repl/inline-mode.js +51 -0
- package/dist/repl/leftover.js +90 -0
- package/dist/repl/reroute-turn.js +7 -1
- package/dist/repl/slash-completer.js +4 -0
- package/dist/repl/turn-stop.js +64 -0
- package/dist/repl.js +178 -32
- package/dist/router/classifier.js +1 -1
- package/dist/sap/reachability-state.js +7 -0
- package/dist/sap/unreachable.js +2 -2
- package/dist/session/cli-record.js +165 -0
- package/dist/session/fresh-start.js +4 -0
- package/dist/session/queued-step.js +3 -1
- package/dist/session/user-prompt.js +10 -1
- package/dist/skills/manifest-client.js +3 -2
- package/dist/skills/preamble.js +6 -4
- package/dist/skills/source-authed.js +3 -2
- package/dist/tools/approval.js +37 -7
- package/dist/tools/dispatch-skill.js +1 -1
- package/dist/tools/hand-off.js +49 -7
- package/dist/tools/lane/app-lane-resolve.js +9 -0
- package/dist/tools/method-only-change.js +241 -0
- package/dist/tools/rap/skeleton-resolve.js +4 -0
- package/dist/tools/sap-write.js +78 -3
- package/dist/tools/snapshot.js +7 -2
- package/dist/tools/subagent/agent_run.js +14 -0
- package/dist/tools/tool-loading.js +16 -8
- package/dist/tools/transport-fit-check.js +70 -0
- package/dist/tools/transport.js +17 -8
- package/dist/tools/write-pipeline.js +46 -5
- package/dist/ui/app.js +112 -13
- package/dist/ui/approval-modal.js +11 -6
- package/dist/ui/ascii-chars.js +21 -3
- package/dist/ui/card-slot.js +18 -1
- package/dist/ui/footer.js +128 -7
- package/dist/ui/interrupt.js +35 -1
- package/dist/ui/live-area.js +21 -4
- package/dist/ui/queue-box-emitter.js +14 -0
- package/dist/ui/rewind-panel.js +2 -1
- package/dist/ui/session-timeline.js +2 -1
- package/dist/ui/text-input.js +31 -2
- package/dist/ui/widgets/bar-chart.js +2 -1
- package/dist/ui/widgets/confirm-card.js +2 -1
- package/dist/ui/widgets/dep-graph.js +2 -1
- package/dist/ui/widgets/diff-viewer.js +2 -1
- package/dist/ui/widgets/question-card.js +2 -1
- package/dist/ui/widgets/shortcuts-sheet.js +2 -2
- package/dist/ui/widgets/stack-frames.js +2 -1
- package/dist/util/connect-retry.js +65 -0
- package/package.json +1 -1
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// cspeach-cli/src/session/cli-record.ts
|
|
2
|
+
//
|
|
3
|
+
// /export docx conversation, fix round 1 (2026-10-23). Two small records kept
|
|
4
|
+
// on the session for the conversation export, NEVER sent to the model:
|
|
5
|
+
//
|
|
6
|
+
// - cli_messages: which user-role messages CSPeach wrote itself (a plan step
|
|
7
|
+
// prompt, the output-limit continuation, the watchdog note, a /compact or
|
|
8
|
+
// plan-boundary summary, a queued next step, a mid-turn steer). Each entry is
|
|
9
|
+
// the sha of the message content, not an index: compaction drops and moves
|
|
10
|
+
// messages, a content hash still finds the same message. The messages
|
|
11
|
+
// themselves are untouched, so every model request stays byte-identical.
|
|
12
|
+
// - cli_cards: the CLI's own cards that matter for a change ticket (the plan
|
|
13
|
+
// gate "Build this now?", the plan startup "Continue", the lane card, the
|
|
14
|
+
// journey cards), anchored by turn_seq. Never the save or Word cards.
|
|
15
|
+
//
|
|
16
|
+
// Both are optional session fields. A session file is JSON.parse'd and written
|
|
17
|
+
// whole (store.ts), so an older CLI that resumes the file keeps them as they are.
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
export const CLI_RECORD_CAP = 500;
|
|
20
|
+
export const CONTINUE_LINE = 'CSPeach asked the model to continue a long answer.';
|
|
21
|
+
export const WATCHDOG_LINE = 'CSPeach asked the model to wrap up a long turn.';
|
|
22
|
+
export const COMPACT_LINE = 'Earlier turns were summarised to keep the session small.';
|
|
23
|
+
export const PLAN_BOUNDARY_LINE = 'CSPeach summarised the earlier plan steps.';
|
|
24
|
+
function shaOf(content) {
|
|
25
|
+
return crypto.createHash('sha256').update(typeof content === 'string' ? content : JSON.stringify(content ?? null)).digest('hex').slice(0, 32);
|
|
26
|
+
}
|
|
27
|
+
function capped(list) {
|
|
28
|
+
return list.length > CLI_RECORD_CAP ? list.slice(list.length - CLI_RECORD_CAP) : list;
|
|
29
|
+
}
|
|
30
|
+
/** Note that CSPeach wrote this user-role message. Never throws. */
|
|
31
|
+
export function markCliMessage(session, content, kind, line) {
|
|
32
|
+
if (!session)
|
|
33
|
+
return;
|
|
34
|
+
try {
|
|
35
|
+
const s = session;
|
|
36
|
+
s.cli_messages = capped([...(s.cli_messages ?? []), { sha: shaOf(content), kind, line }]);
|
|
37
|
+
}
|
|
38
|
+
catch { /* the record is best-effort */ }
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Fixed-shape CLI texts, for sessions written before the record existed (or
|
|
42
|
+
* a mark the cap dropped). Prefixes only; the bodies are CSPeach's own words.
|
|
43
|
+
*/
|
|
44
|
+
const KNOWN_PREFIXES = [
|
|
45
|
+
{ prefix: 'Resume execution of the project plan', kind: 'plan-step', line: 'CSPeach ran the next plan step.' },
|
|
46
|
+
{ prefix: 'Your previous response was cut off at the output-token limit.', kind: 'continue', line: CONTINUE_LINE },
|
|
47
|
+
{ prefix: '[CSPeach watchdog]', kind: 'watchdog', line: WATCHDOG_LINE },
|
|
48
|
+
{ prefix: '[Context summary auto-generated', kind: 'compact', line: COMPACT_LINE },
|
|
49
|
+
{ prefix: '[Summary of the earlier steps of this plan', kind: 'plan-boundary', line: PLAN_BOUNDARY_LINE },
|
|
50
|
+
];
|
|
51
|
+
function prefixMark(message) {
|
|
52
|
+
const text = typeof message.content === 'string' ? message.content
|
|
53
|
+
: Array.isArray(message.content) ? message.content.filter((b) => b?.type === 'text').map((b) => String(b.text ?? '')).join('\n') : '';
|
|
54
|
+
const t = text.trimStart();
|
|
55
|
+
const known = KNOWN_PREFIXES.find((k) => t.startsWith(k.prefix));
|
|
56
|
+
return known ? { sha: '', kind: known.kind, line: known.line } : undefined;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The mark of each message (undefined for the developer's own words), in
|
|
60
|
+
* message order. Fix round 2 (m-8): each mark is used once, in order — a
|
|
61
|
+
* message takes the first unused mark at or after the last one used that has
|
|
62
|
+
* its sha. So a command the developer retypes after CSPeach queued it, or a
|
|
63
|
+
* typed "continue" equal to an earlier steer, stays the developer's: that mark
|
|
64
|
+
* is already used. Marks of messages a compaction dropped are simply passed
|
|
65
|
+
* over. The prefix fallback is only for sessions with no records at all.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveCliMarks(session, messages) {
|
|
68
|
+
const marks = session?.cli_messages;
|
|
69
|
+
if (!marks || marks.length === 0)
|
|
70
|
+
return messages.map((m) => (m.role === 'user' ? prefixMark(m) : undefined));
|
|
71
|
+
// Final-review fix (I-1): used once, any order. Look from the last mark used
|
|
72
|
+
// on; when nothing matches there, look again from the start (a file an older
|
|
73
|
+
// CLI compacted has the summary's mark last). A used mark is never reused.
|
|
74
|
+
const used = new Set();
|
|
75
|
+
let next = 0;
|
|
76
|
+
return messages.map((m) => {
|
|
77
|
+
if (m.role !== 'user')
|
|
78
|
+
return undefined;
|
|
79
|
+
const sha = shaOf(m.content);
|
|
80
|
+
const at = (from, to) => {
|
|
81
|
+
for (let j = from; j < to; j++)
|
|
82
|
+
if (!used.has(j) && marks[j].sha === sha)
|
|
83
|
+
return j;
|
|
84
|
+
return -1;
|
|
85
|
+
};
|
|
86
|
+
let j = at(next, marks.length);
|
|
87
|
+
if (j < 0)
|
|
88
|
+
j = at(0, Math.min(next, marks.length));
|
|
89
|
+
if (j < 0)
|
|
90
|
+
return undefined;
|
|
91
|
+
used.add(j);
|
|
92
|
+
next = j + 1;
|
|
93
|
+
return marks[j];
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Final-review fix (I-1): after a compaction keeps `kept` (the tail of the old
|
|
98
|
+
* messages, from index `keepFrom`) behind a new summary message, rebuild the
|
|
99
|
+
* record in message order: the summary's mark first, then the marks of the
|
|
100
|
+
* kept messages (recomputed from their content, so a mark found by the prefix
|
|
101
|
+
* fallback in an older file becomes a real one). Marks of dropped messages go.
|
|
102
|
+
*/
|
|
103
|
+
export function rebuildCliMarksAfterCompact(session, oldMessages, keepFrom, summaryContent, summary) {
|
|
104
|
+
try {
|
|
105
|
+
const resolved = resolveCliMarks(session, oldMessages);
|
|
106
|
+
const kept = [];
|
|
107
|
+
for (let i = keepFrom; i < oldMessages.length; i++) {
|
|
108
|
+
const m = resolved[i];
|
|
109
|
+
if (m)
|
|
110
|
+
kept.push({ sha: shaOf(oldMessages[i].content), kind: m.kind, line: m.line });
|
|
111
|
+
}
|
|
112
|
+
session.cli_messages = capped([{ sha: shaOf(summaryContent), kind: summary.kind, line: summary.line }, ...kept]);
|
|
113
|
+
}
|
|
114
|
+
catch { /* the record is best-effort */ }
|
|
115
|
+
}
|
|
116
|
+
/** A turn of its own (the developer's prompt, a plan step, a queued step) vs. a message inside a turn. */
|
|
117
|
+
export function startsTurn(mark) {
|
|
118
|
+
return !mark || mark.kind === 'plan-step' || mark.kind === 'queued';
|
|
119
|
+
}
|
|
120
|
+
/* ── cards ────────────────────────────────────────────────────────────── */
|
|
121
|
+
let bound = null;
|
|
122
|
+
/** The REPL (and each top-level turn) binds the session the cards belong to. */
|
|
123
|
+
export function bindCliCardSession(session) { bound = session; }
|
|
124
|
+
/**
|
|
125
|
+
* The card ids the card slot records for the export: plan startup, lane,
|
|
126
|
+
* journey. Never save or Word cards. "Build this now?" (app-start-build) is
|
|
127
|
+
* recorded by afterPlanSave itself, which knows the plan file and whether the
|
|
128
|
+
* object-list approval was installed (fix round 2, N-1).
|
|
129
|
+
*/
|
|
130
|
+
export function isRecordedCard(id) {
|
|
131
|
+
return id === 'plan-continue' || id === 'app-lane' || id === 'app-lane-runtime' || id.startsWith('journey-');
|
|
132
|
+
}
|
|
133
|
+
/** Record one card on the bound session (or the one given), with the time. Never throws. */
|
|
134
|
+
export function recordCliCard(card, session = bound) {
|
|
135
|
+
if (!session)
|
|
136
|
+
return;
|
|
137
|
+
try {
|
|
138
|
+
const s = session;
|
|
139
|
+
const entry = { turn_seq: s.turn_seq ?? 0, at: new Date().toISOString(), ...card };
|
|
140
|
+
s.cli_cards = capped([...(s.cli_cards ?? []), entry]);
|
|
141
|
+
}
|
|
142
|
+
catch { /* the record is best-effort */ }
|
|
143
|
+
}
|
|
144
|
+
/* ── once-per-object document offers ──────────────────────────────────── */
|
|
145
|
+
/**
|
|
146
|
+
* Per session (in memory): the (skill, object) keys the newer document skills
|
|
147
|
+
* already offered "Save…?" for. Kept here, beside the records, so /new can
|
|
148
|
+
* clear it (final-review m-4).
|
|
149
|
+
*/
|
|
150
|
+
const documentOffers = new WeakMap();
|
|
151
|
+
export function documentOffersFor(session) {
|
|
152
|
+
let s = documentOffers.get(session);
|
|
153
|
+
if (!s) {
|
|
154
|
+
s = new Set();
|
|
155
|
+
documentOffers.set(session, s);
|
|
156
|
+
}
|
|
157
|
+
return s;
|
|
158
|
+
}
|
|
159
|
+
/** /new: the once-per-object offers and the records of the cleared conversation go too. */
|
|
160
|
+
export function clearConversationRecords(session) {
|
|
161
|
+
documentOffers.delete(session);
|
|
162
|
+
const s = session;
|
|
163
|
+
delete s.cli_messages;
|
|
164
|
+
delete s.cli_cards;
|
|
165
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { clearConversationRecords } from './cli-record.js';
|
|
1
2
|
export function startFreshConversation(session) {
|
|
2
3
|
session.messages = [];
|
|
3
4
|
session.turnInterrupted = false;
|
|
@@ -6,6 +7,9 @@ export function startFreshConversation(session) {
|
|
|
6
7
|
delete session.lastAutoCompactTurn;
|
|
7
8
|
// An open plan step does not survive: "continue" must not resume it.
|
|
8
9
|
session.plan_in_flight = null;
|
|
10
|
+
// Word export (final-review m-4): the once-per-object save offers and the
|
|
11
|
+
// export records describe the cleared conversation; they go with it.
|
|
12
|
+
clearConversationRecords(session);
|
|
9
13
|
}
|
|
10
14
|
/** The one line /new prints. */
|
|
11
15
|
export function freshStartText(lane) {
|
|
@@ -45,5 +45,7 @@ export function queuedStepView(origin) {
|
|
|
45
45
|
if (!line)
|
|
46
46
|
return { echo: false, line: null };
|
|
47
47
|
// D11 — the chat-fallback line is dim; painted after shownUpTo flattened it.
|
|
48
|
-
|
|
48
|
+
// LT-4 (1A fix wave, owner ruling): the hand-off arrow line is dim too, kept in scrollback.
|
|
49
|
+
const dim = origin.by === 'chat_fallback' || origin.by === 'hand_off';
|
|
50
|
+
return { echo: false, line: dim ? paint('dim', line) : line };
|
|
49
51
|
}
|
|
@@ -26,8 +26,17 @@ export function isUserPrompt(m) {
|
|
|
26
26
|
* (`<session_context>` from the intent extractor / SAP system info,
|
|
27
27
|
* `<project_context>` from CSPEACH_PROJECT_CONTEXT). The user never typed
|
|
28
28
|
* them, so a prompt preview must never show them (fix round 3, item 2).
|
|
29
|
+
* Stage 1A (M5) — `<skill>`, the playbook block an inline turn appends. The
|
|
30
|
+
* order matters: `<session_context>` first, so the bare `<skill>` word in a
|
|
31
|
+
* `<playbook>` line goes with the block it sits in — `skill` stays last.
|
|
32
|
+
* Shared with the conversation export (commands/export-conversation.ts).
|
|
29
33
|
*/
|
|
30
|
-
const INJECTED_CONTEXT_TAGS = [
|
|
34
|
+
export const INJECTED_CONTEXT_TAGS = [
|
|
35
|
+
'session_context', 'project_context',
|
|
36
|
+
// The /export docx conversation list (main, 2026-10-23): one list for both readers.
|
|
37
|
+
'journey', 'lane_decision', 'system_switch', 'sap_system', 'sap_connection',
|
|
38
|
+
'skill',
|
|
39
|
+
];
|
|
31
40
|
/**
|
|
32
41
|
* One-line preview of what the user typed: injected context blocks removed,
|
|
33
42
|
* `<attached file="x">…</attached>` expansions shown as the original `@x`
|
|
@@ -4,6 +4,7 @@ import { sha512 } from '@noble/hashes/sha512';
|
|
|
4
4
|
import { bytesToHex, concatBytes } from '@noble/hashes/utils';
|
|
5
5
|
import { MANIFEST_PUBLIC_KEYS } from './signing-public-key.js';
|
|
6
6
|
import { canonicalise } from './canonical.js';
|
|
7
|
+
import { fetchWithConnectRetry } from '../util/connect-retry.js';
|
|
7
8
|
// @noble/ed25519 v2 requires an ed25519 sha512 hook.
|
|
8
9
|
ed.etc.sha512Sync = (...m) => sha512(concatBytes(...m));
|
|
9
10
|
/**
|
|
@@ -39,7 +40,7 @@ export class ManifestClient {
|
|
|
39
40
|
async fetchManifest() {
|
|
40
41
|
let r;
|
|
41
42
|
try {
|
|
42
|
-
r = await
|
|
43
|
+
r = await fetchWithConnectRetry(this.manifestUrl, () => ({ signal: AbortSignal.timeout(10_000) }));
|
|
43
44
|
}
|
|
44
45
|
catch (e) {
|
|
45
46
|
throw new ManifestUnavailableError(`manifest fetch failed (${this.manifestUrl}): ${e.message}`);
|
|
@@ -99,7 +100,7 @@ export class ManifestClient {
|
|
|
99
100
|
async fetchSkillBody(name, bodyUrl, opts) {
|
|
100
101
|
let r;
|
|
101
102
|
try {
|
|
102
|
-
r = await
|
|
103
|
+
r = await fetchWithConnectRetry(bodyUrl, () => ({ signal: AbortSignal.timeout(10_000) }));
|
|
103
104
|
}
|
|
104
105
|
catch (e) {
|
|
105
106
|
throw new Error(`skill body fetch failed (${bodyUrl}): ${e.message}`);
|
package/dist/skills/preamble.js
CHANGED
|
@@ -52,8 +52,10 @@ take precedence if a skill's instruction conflicts with them.
|
|
|
52
52
|
revise and re-submit.
|
|
53
53
|
|
|
54
54
|
## Transport discipline
|
|
55
|
-
- All writes go into the
|
|
56
|
-
for a transport number per change.
|
|
55
|
+
- All writes go into the session transport once one is set. Do not ask
|
|
56
|
+
the user for a transport number per change. When a create says a
|
|
57
|
+
transport is needed, ask the user once: a new session transport
|
|
58
|
+
(recommended) or one of the open requests for the package.
|
|
57
59
|
- Never use \$TMP unless the user explicitly requests local-only work.
|
|
58
60
|
|
|
59
61
|
## Escalate, don't fail silently
|
|
@@ -70,6 +72,6 @@ take precedence if a skill's instruction conflicts with them.
|
|
|
70
72
|
|
|
71
73
|
## One skill per turn
|
|
72
74
|
- If the user's request is better served by a different skill, call
|
|
73
|
-
|
|
74
|
-
skill yourself.
|
|
75
|
+
dispatch_skill after the person has picked the command. Do not try to do
|
|
76
|
+
the work of another skill yourself.
|
|
75
77
|
`;
|
|
@@ -14,6 +14,7 @@ import { sha256 } from '@noble/hashes/sha256';
|
|
|
14
14
|
import { bytesToHex } from '@noble/hashes/utils';
|
|
15
15
|
import { ManifestClient, DEFAULT_MANIFEST_URL, SkillIntegrityError, ManifestUnavailableError, } from './manifest-client.js';
|
|
16
16
|
import { fileSkillsCache } from './skills-cache.js';
|
|
17
|
+
import { fetchWithConnectRetry } from '../util/connect-retry.js';
|
|
17
18
|
// Defined next to the client that throws it (Task 8 (a): typed, never string-matched).
|
|
18
19
|
export { SkillIntegrityError };
|
|
19
20
|
export class SkillNotInPlanError extends Error {
|
|
@@ -69,10 +70,10 @@ async function fetchSignedInBody(baseUrl, name, expectedSha, getBearer, doFetch)
|
|
|
69
70
|
throw new SkillSignInError();
|
|
70
71
|
let r;
|
|
71
72
|
try {
|
|
72
|
-
r = await
|
|
73
|
+
r = await fetchWithConnectRetry(`${baseUrl}/v1/skills/${encodeURIComponent(name)}`, () => ({
|
|
73
74
|
headers: { Authorization: `Bearer ${bearer}` },
|
|
74
75
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
75
|
-
});
|
|
76
|
+
}), { fetchImpl: doFetch });
|
|
76
77
|
}
|
|
77
78
|
catch {
|
|
78
79
|
throw new SkillsUnavailableError('could not reach CSPeach');
|
package/dist/tools/approval.js
CHANGED
|
@@ -6,6 +6,14 @@ import { isHeadless, shouldUseInk } from '../renderer/tty.js';
|
|
|
6
6
|
import { effectiveRisk, riskResultFields, typeCode } from '../approvals/risk-floor.js';
|
|
7
7
|
import { objectsWithSyntaxErrors } from './syntax-state.js';
|
|
8
8
|
import { noteWriteTransport } from '../repl/current-transport.js';
|
|
9
|
+
import { STOPPED_BEFORE_START_RESULT } from '../agent/repair-partial.js';
|
|
10
|
+
/**
|
|
11
|
+
* LT-15 D6 (F6) — after a stop (Esc or Ctrl+C set the same turn abort) no new
|
|
12
|
+
* card opens and no approval id is minted. The shape dispatchTool returns for
|
|
13
|
+
* a tool that never started (the tool_use stays paired), with a text that says
|
|
14
|
+
* nothing ran.
|
|
15
|
+
*/
|
|
16
|
+
const stoppedBeforeCard = (ctx) => (ctx.signal?.aborted ? { content: STOPPED_BEFORE_START_RESULT, is_error: true } : null);
|
|
9
17
|
/**
|
|
10
18
|
* Round 8 item 6 — the footer showed "no transport" while the approved card
|
|
11
19
|
* said "transport S4HK903731". An approval granted for an explicit transport
|
|
@@ -29,7 +37,7 @@ import { promptAdvisory } from '../approvals/advisory-prompt.js';
|
|
|
29
37
|
import { markPlanGateApproved } from '../repl/rule8-detector.js';
|
|
30
38
|
import { getEffectiveWriteMode } from '../repl/mode-cycle.js';
|
|
31
39
|
import { getCurrentTransport } from '../repl/current-transport.js';
|
|
32
|
-
import { displayTransport } from '../approvals/op-labels.js';
|
|
40
|
+
import { displayTransport, knownTypeWord } from '../approvals/op-labels.js';
|
|
33
41
|
import { canonicalApprovalObject as bareObjectName } from '../approvals/canonical.js';
|
|
34
42
|
import { projectTransport, SEVERAL_REQUESTS_LABEL, transportForWrite, TRANSPORT_REQUEST } from '../register/project-transport.js';
|
|
35
43
|
import { NOT_ON_THIS_RELEASE, approvalRefusedByProfile } from '../sap/profile-gate.js';
|
|
@@ -223,7 +231,8 @@ registerTool({
|
|
|
223
231
|
type: 'object',
|
|
224
232
|
properties: {
|
|
225
233
|
op: { type: 'string', enum: ['create', 'modify', 'delete', 'activate', 'release', 'run'] },
|
|
226
|
-
|
|
234
|
+
// P-2 (1A fix wave): a group word ('DCLs') is not an object the plan grant can match.
|
|
235
|
+
object: { type: 'string', description: "One real object name per row (comma-join several only in an activate row), never a group word such as 'DCLs'." },
|
|
227
236
|
type: { type: 'string' },
|
|
228
237
|
diff: { type: 'string', description: 'For a CREATE op this MUST be the exact, complete source that will be written — verbatim, character-for-character, NOT a description, summary, or paraphrase. It is shown to the developer in the approval box as the exact thing they are approving, so a one-line summary here means they approve a write they cannot inspect. For a MODIFY op, the real unified diff of the change (again verbatim, not a description).' },
|
|
229
238
|
package: { type: 'string', description: 'Target package (development class) for the object, if known — shown on the approval box' },
|
|
@@ -298,6 +307,10 @@ registerTool({
|
|
|
298
307
|
// no role configured and no session override this is exactly
|
|
299
308
|
// cfg.write_mode — byte-identical behavior.
|
|
300
309
|
const role = cfg.sap[ctx.sapAlias]?.role;
|
|
310
|
+
// LT-15 D6 — stopped during the profile read above: no advisory card, no card.
|
|
311
|
+
const stopA = stoppedBeforeCard(ctx);
|
|
312
|
+
if (stopA)
|
|
313
|
+
return stopA;
|
|
301
314
|
// Advisory-only short-circuit — never mint approval_ids.
|
|
302
315
|
// The developer applies the change manually in ADT; the AI gets an
|
|
303
316
|
// ai_directive telling it not to attempt any SAP write tools.
|
|
@@ -313,11 +326,17 @@ registerTool({
|
|
|
313
326
|
return { content: JSON.stringify(releaseRefusal), is_error: true };
|
|
314
327
|
// Task 8 (B11) — the request that already holds each modified object, before any card.
|
|
315
328
|
await attachOwningTransport(args.changes ?? [], ctx);
|
|
329
|
+
// LT-15 D6 — stopped during the reads above: no card, no nag, no id minted
|
|
330
|
+
// (the plan-grant and auto-approve paths below mint without a card).
|
|
331
|
+
const stopB = stoppedBeforeCard(ctx);
|
|
332
|
+
if (stopB)
|
|
333
|
+
return stopB;
|
|
316
334
|
let sapCfg = cfg.sap[ctx.sapAlias];
|
|
317
335
|
const riskCtx = {
|
|
318
336
|
sapAlias: ctx.sapAlias,
|
|
319
337
|
autoApprovePolicy: sapCfg?.auto_approve ?? 'never',
|
|
320
338
|
syntaxErrorObjects: objectsWithSyntaxErrors(),
|
|
339
|
+
typeWord: knownTypeWord, // LT-10: the syntax-errors reason names the kind of object
|
|
321
340
|
};
|
|
322
341
|
const changes = args.changes;
|
|
323
342
|
const declared = args.risk;
|
|
@@ -421,6 +440,10 @@ registerTool({
|
|
|
421
440
|
sapCfg = cfg.sap[ctx.sapAlias];
|
|
422
441
|
riskCtx.autoApprovePolicy = sapCfg?.auto_approve ?? 'never';
|
|
423
442
|
}
|
|
443
|
+
// LT-15 D6 (review M3) — stopped while the nag was up: no card after it, no id.
|
|
444
|
+
const stopC = stoppedBeforeCard(ctx);
|
|
445
|
+
if (stopC)
|
|
446
|
+
return stopC;
|
|
424
447
|
// 1.1.31 (owner, 2026-10-07): a package or transport always gets a person's
|
|
425
448
|
// yes. Live, auto_approve="low" let the model's own "low" create package
|
|
426
449
|
// ZCC_FIX_PROOF and request S4HK903757 with no card. The plan-grant path
|
|
@@ -478,12 +501,14 @@ registerTool({
|
|
|
478
501
|
// for one task" complaint. Review-each falls back to the per-change
|
|
479
502
|
// gate. Cancel rejects the whole plan.
|
|
480
503
|
//
|
|
481
|
-
// Task 9 (C8): only for more than one VISIBLE change
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
504
|
+
// Task 9 (C8): only for more than one VISIBLE change (an activate whose
|
|
505
|
+
// write is in the list is not a change a person sees). A modify + its
|
|
506
|
+
// activate on one class gets the per-change card only; the activate
|
|
507
|
+
// follows its approved write below. LT-12: the plan gate's LIST also
|
|
508
|
+
// hides a modify that a create of the same object covers (planRows), so
|
|
509
|
+
// it may show fewer rows than this count; this count is unchanged.
|
|
485
510
|
let skipPerChangeGates = false;
|
|
486
|
-
// Raw keys on purpose:
|
|
511
|
+
// Raw keys on purpose: activates collapsed, nothing else.
|
|
487
512
|
const visibleCount = collapseImplicitActivates(changes).length;
|
|
488
513
|
if (visibleCount > 1) {
|
|
489
514
|
const mode = await renderPlanGate(args.summary, changes, planGateTransport, eff, ctx.sapAlias);
|
|
@@ -549,6 +574,11 @@ registerTool({
|
|
|
549
574
|
outcome = { approved: false, reason: 'sibling_write_skipped' };
|
|
550
575
|
}
|
|
551
576
|
else {
|
|
577
|
+
// LT-15 D6 (review M3) — stopped during the mint after the card before:
|
|
578
|
+
// the next card does not open; ids minted so far are dropped unused.
|
|
579
|
+
const stopD = stoppedBeforeCard(ctx);
|
|
580
|
+
if (stopD)
|
|
581
|
+
return stopD;
|
|
552
582
|
outcome = await renderPerChangeApprovalV3(c, eff, displayTransport(c.op, args.transport, sessionTransport, c.owningTransport, project));
|
|
553
583
|
}
|
|
554
584
|
if (outcome.approved) {
|
|
@@ -44,7 +44,7 @@ registerTool({
|
|
|
44
44
|
properties: {
|
|
45
45
|
command: {
|
|
46
46
|
type: 'string',
|
|
47
|
-
description: 'Full slash command including arguments. Must start with `/`. Example: `/abap-rap --from @order-approval-system-design-2924-v1.cspeach.json`.',
|
|
47
|
+
description: 'Full slash command including arguments. Must start with `/`. Example: `/abap-rap --from @order-approval-system-design-2924-v1.cspeach.json`. Never put a transport description in it unless the person typed it.',
|
|
48
48
|
},
|
|
49
49
|
},
|
|
50
50
|
required: ['command'],
|
package/dist/tools/hand-off.js
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* chat turn then ends and the workflow runs with its own plan + approvals.
|
|
6
6
|
* No extra consent card (D5): the workflow asks before any write.
|
|
7
7
|
* Refused outside the chat lane; dispatch_skill stays the skills' own switch.
|
|
8
|
+
* Stage 1A (T1A.4): in an inline session (`ctx.inlineSkills`) a playbook turn
|
|
9
|
+
* may hand off too — still only queues — except while a plan is in charge or
|
|
10
|
+
* the slot already holds a step (the harness's next-phase hop wins).
|
|
8
11
|
*
|
|
9
12
|
* Flag-gated: only a chat-lane process enables it (Task 11), so a skills-lane
|
|
10
13
|
* session never lists it and its tools array stays byte-identical to today.
|
|
@@ -14,7 +17,37 @@ import { isPlanResumeCommand } from '../commands/plan-resume.js';
|
|
|
14
17
|
import { SKILL_CATALOG, resolveSkillAlias } from '../skill-catalog.js';
|
|
15
18
|
/** Longest request hand_off queues (after whitespace is collapsed). */
|
|
16
19
|
export const HAND_OFF_REQUEST_MAX = 500;
|
|
20
|
+
/**
|
|
21
|
+
* I3 (owner, 2026-10-22) — one hand-off per person message. Refusal code when
|
|
22
|
+
* the running turn was itself started by a hand_off: no workflow-to-workflow
|
|
23
|
+
* chain of reads and credits without a new person message. A new person
|
|
24
|
+
* message resets it (the REPL passes who started each turn as `ctx.turnBy`).
|
|
25
|
+
* dispatch_skill after the person's pick is not capped.
|
|
26
|
+
*/
|
|
27
|
+
export const HAND_OFF_CHAIN_CAPPED = 'HAND_OFF_CHAIN_CAPPED';
|
|
17
28
|
export const HAND_OFF_LINE = (skill) => `→ /${skill}`;
|
|
29
|
+
/** The success note when the model wrote nothing before the call in this turn. */
|
|
30
|
+
export const HAND_OFF_NOTE = 'The workflow runs next and asks before it writes. End the turn now. If you wrote a line before the call, add nothing. If you wrote nothing, add one short line saying the workflow takes it from here. Do not print the command.';
|
|
31
|
+
/**
|
|
32
|
+
* LT-3 (live check 2026-10-26) — the model often skipped the "if you wrote a
|
|
33
|
+
* line" test and announced the hand-off twice. The loop knows whether text
|
|
34
|
+
* came first in this turn and then gives this plain note instead.
|
|
35
|
+
*/
|
|
36
|
+
export const HAND_OFF_NOTE_ALREADY_SAID = 'The workflow runs next and asks before it writes. You already told the developer in this turn, so end the turn now and write nothing more. Do not print the command.';
|
|
37
|
+
/** The hand_off success content with the note for a turn that already said its line. Anything else unchanged. */
|
|
38
|
+
export function settleHandOffNote(content, saidBefore) {
|
|
39
|
+
if (!saidBefore)
|
|
40
|
+
return content;
|
|
41
|
+
try {
|
|
42
|
+
const r = JSON.parse(content);
|
|
43
|
+
if (r.ok !== true || r.note !== HAND_OFF_NOTE)
|
|
44
|
+
return content;
|
|
45
|
+
return JSON.stringify({ ...r, note: HAND_OFF_NOTE_ALREADY_SAID });
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return content;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
18
51
|
export const buildHandOffCommand = (skill, request) => `/${skill} ${request}`;
|
|
19
52
|
const CATALOG_NAMES = new Set(SKILL_CATALOG.map((s) => s.name));
|
|
20
53
|
/**
|
|
@@ -45,15 +78,25 @@ registerTool({
|
|
|
45
78
|
type: 'object',
|
|
46
79
|
properties: {
|
|
47
80
|
skill: { type: 'string', description: 'Workflow name without the slash, e.g. "abap-refactor".' },
|
|
48
|
-
request: { type: 'string', description: 'What the workflow should do, with object names. One sentence, at most 500 characters, no flags.' },
|
|
81
|
+
request: { type: 'string', description: 'What the workflow should do, with object names. One sentence, at most 500 characters, no flags. Never put a transport description in it unless the person typed it.' },
|
|
49
82
|
},
|
|
50
83
|
required: ['skill', 'request'],
|
|
51
84
|
},
|
|
52
85
|
handler: async (args, ctx) => {
|
|
53
86
|
const bad = (error, reason) => ({ is_error: true, content: JSON.stringify({ error, reason }) });
|
|
54
|
-
|
|
87
|
+
// Stage 1A (T1A.4) — in an inline session a playbook turn may hand off too.
|
|
88
|
+
const playbookTurn = ctx.session?.lane !== 'chat';
|
|
89
|
+
if (playbookTurn && !ctx.inlineSkills) {
|
|
55
90
|
return bad('HAND_OFF_OUTSIDE_CHAT', 'hand_off is only for the chat; in a workflow, continue with the workflow (dispatch_skill switches workflows after the person agreed).');
|
|
56
91
|
}
|
|
92
|
+
// The plan harness queues its next phase through the same slot; never race it.
|
|
93
|
+
if (playbookTurn && (ctx.session?.plan_in_flight || ctx.session?.skill === 'abap-plan')) {
|
|
94
|
+
return bad('HAND_OFF_PLAN_IN_FLIGHT', 'finish this step; after the plan, the person can ask for that in chat');
|
|
95
|
+
}
|
|
96
|
+
// I3 — one hand-off per person message.
|
|
97
|
+
if (ctx.turnBy === 'hand_off') {
|
|
98
|
+
return bad(HAND_OFF_CHAIN_CAPPED, "this workflow was already handed off from the person's message; one hand-off per message. Finish this workflow's answer, then let the person decide the next step: offer that workflow as a suggestion in your answer instead of calling hand_off. Do not call dispatch_skill either unless the person picks it.");
|
|
99
|
+
}
|
|
57
100
|
const skill = normaliseSkill(args.skill);
|
|
58
101
|
if (!skill) {
|
|
59
102
|
return bad('HAND_OFF_UNKNOWN_SKILL', `unknown workflow "${String(args.skill ?? '')}". Use one of: ${SKILL_CATALOG.map((s) => s.name).join(', ')}`);
|
|
@@ -74,13 +117,12 @@ registerTool({
|
|
|
74
117
|
if (!ctx.pendingDispatch) {
|
|
75
118
|
return bad('DISPATCH_UNSUPPORTED', 'no dispatch slot in this context (one-shot or test); tell the developer which /abap-… command to run instead');
|
|
76
119
|
}
|
|
120
|
+
if (playbookTurn && ctx.pendingDispatch.get() !== null) {
|
|
121
|
+
return bad('HAND_OFF_SLOT_TAKEN', 'a step is already queued; end the turn');
|
|
122
|
+
}
|
|
77
123
|
ctx.pendingDispatch.set(command, { by: 'hand_off', line: HAND_OFF_LINE(skill) });
|
|
78
124
|
return {
|
|
79
|
-
content: JSON.stringify({
|
|
80
|
-
ok: true,
|
|
81
|
-
queued: command,
|
|
82
|
-
note: 'The workflow runs next and asks before it writes. End the turn now with at most one short line; do not print the command.',
|
|
83
|
-
}),
|
|
125
|
+
content: JSON.stringify({ ok: true, queued: command, note: HAND_OFF_NOTE }),
|
|
84
126
|
};
|
|
85
127
|
},
|
|
86
128
|
});
|
|
@@ -36,6 +36,9 @@ import { askConfirm } from '../../ui/confirm-request.js';
|
|
|
36
36
|
import { isHeadless } from '../../renderer/tty.js';
|
|
37
37
|
import { loadConfig } from '../../config/loader.js';
|
|
38
38
|
import { getSapSystemInfo } from '../../sap/system-info.js';
|
|
39
|
+
import { STOPPED_BEFORE_START_RESULT } from '../../agent/repair-partial.js';
|
|
40
|
+
/** LT-15 D6 (review M2) — after a stop (Esc / Ctrl+C) no card opens and nothing is saved. */
|
|
41
|
+
const stopped = (ctx) => (ctx.signal?.aborted ? { content: STOPPED_BEFORE_START_RESULT, is_error: true } : null);
|
|
39
42
|
const fail = (message) => ({ content: `error: ${message}`, is_error: true });
|
|
40
43
|
const NOT_CHOSEN = 'Lane not chosen. Nothing was saved.';
|
|
41
44
|
const STOP = 'STOP — lane needs your choice.';
|
|
@@ -200,6 +203,9 @@ export async function appLaneResolveHandler(args, ctx, deps = {}) {
|
|
|
200
203
|
}
|
|
201
204
|
else {
|
|
202
205
|
// 7. The lane card.
|
|
206
|
+
const stopLane = stopped(ctx);
|
|
207
|
+
if (stopLane)
|
|
208
|
+
return stopLane;
|
|
203
209
|
const confirm = deps.confirm ?? ((req) => askConfirm(req));
|
|
204
210
|
const card = laneCardRequest(decision);
|
|
205
211
|
const res = await confirm({ ...card, facts: [...(card.facts ?? []), ...cardFacts] });
|
|
@@ -224,6 +230,9 @@ export async function appLaneResolveHandler(args, ctx, deps = {}) {
|
|
|
224
230
|
}
|
|
225
231
|
// 8. A CAP lane needs a runtime.
|
|
226
232
|
if (finalLane.backend === 'cap' && !runtime) {
|
|
233
|
+
const stopRuntime = stopped(ctx);
|
|
234
|
+
if (stopRuntime)
|
|
235
|
+
return stopRuntime;
|
|
227
236
|
const rr = await confirm(runtimeCardRequest());
|
|
228
237
|
const answer = rr.value;
|
|
229
238
|
if (!answer || !RUNTIMES.includes(answer))
|