@cspeach/cli 1.1.51 → 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 +208 -41
- 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 +10 -2
- package/dist/commands/compact.js +6 -1
- package/dist/commands/config-set.js +13 -0
- package/dist/commands/config-show.js +16 -4
- package/dist/commands/export-conversation.js +7 -4
- package/dist/commands/finish-check-guard.js +124 -0
- package/dist/commands/plan-reground.js +27 -4
- package/dist/commands/plan-resume.js +111 -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/models/server-config.js +1 -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/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/turn-stop.js +64 -0
- package/dist/repl.js +144 -22
- 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/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/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
package/dist/cli.js
CHANGED
|
@@ -157,12 +157,20 @@ export async function main(argv) {
|
|
|
157
157
|
// Task 12 — the full dump ends with "# model set by your account admin"
|
|
158
158
|
// when /v1/me/model-config says the admin's model is enforced. Capped at
|
|
159
159
|
// 1.5 s and fail-open: offline / signed out ⇒ the output is unchanged.
|
|
160
|
-
const [{ loadConfig }, { getStore }, { loadCustomerModelConfig }] = await Promise.all([
|
|
160
|
+
const [{ loadConfig }, { getStore }, { loadCustomerModelConfig, loadServerModelConfig }] = await Promise.all([
|
|
161
161
|
import('./config/loader.js'),
|
|
162
162
|
import('./auth/api-key.js'),
|
|
163
163
|
import('./models/server-config.js'),
|
|
164
164
|
]);
|
|
165
|
-
|
|
165
|
+
const showCfg = await loadConfig();
|
|
166
|
+
await Promise.all([
|
|
167
|
+
loadCustomerModelConfig(showCfg, async () => (await (await getStore()).get()) ?? '', { timeoutMs: 1500 }).catch(() => { }),
|
|
168
|
+
// Stage 1A — only a stored `assistant.skills = inline` needs the served
|
|
169
|
+
// flag (the "not active" comment). Managed only, same cap, fail-open.
|
|
170
|
+
showCfg.assistant?.skills === 'inline'
|
|
171
|
+
? loadServerModelConfig(showCfg, { timeoutMs: 1500 }).catch(() => { })
|
|
172
|
+
: Promise.resolve(),
|
|
173
|
+
]);
|
|
166
174
|
}
|
|
167
175
|
await runConfigShow(args.slice(2));
|
|
168
176
|
return 0;
|
package/dist/commands/compact.js
CHANGED
|
@@ -33,6 +33,7 @@ import { paint } from '../renderer/theme.js';
|
|
|
33
33
|
import { getGlyphs } from '../renderer/glyphs.js';
|
|
34
34
|
import { saveSession } from '../session/store.js';
|
|
35
35
|
import { isUserPrompt } from '../session/user-prompt.js';
|
|
36
|
+
import { stripSkillBlocksInMessage } from '../agent/inline-skill-block.js';
|
|
36
37
|
import { rebuildCliMarksAfterCompact, COMPACT_LINE } from '../session/cli-record.js';
|
|
37
38
|
import { getActiveDeployments } from '../models/model-identity.js';
|
|
38
39
|
/** Keep this many recent user-prompt anchored turns verbatim. */
|
|
@@ -131,7 +132,11 @@ export function buildSummarisationPrompt() {
|
|
|
131
132
|
*/
|
|
132
133
|
export function serialiseForSummariser(messages) {
|
|
133
134
|
const out = [];
|
|
134
|
-
for (const
|
|
135
|
+
for (const original of messages) {
|
|
136
|
+
// Stage 1A (S-A, M-3) — a playbook body never reaches the summariser: a
|
|
137
|
+
// hash-passing <skill> block in user text becomes [skill block: <name>].
|
|
138
|
+
// A message without one is the same object (byte-identical output).
|
|
139
|
+
const msg = stripSkillBlocksInMessage(original);
|
|
135
140
|
if (typeof msg.content === 'string') {
|
|
136
141
|
out.push({ role: msg.role, content: msg.content });
|
|
137
142
|
}
|
|
@@ -19,6 +19,7 @@ const MUTABLE_KEYS = new Set([
|
|
|
19
19
|
'assistant.lane',
|
|
20
20
|
'assistant.style',
|
|
21
21
|
'assistant.level',
|
|
22
|
+
'assistant.skills',
|
|
22
23
|
'local_build',
|
|
23
24
|
'local_files', // deprecated alias for local_build (2026-06-13) — still accepted
|
|
24
25
|
'show_cogs',
|
|
@@ -83,6 +84,7 @@ const PLAN_MODE_VALUES = ['step', 'guarded'];
|
|
|
83
84
|
const PLAN_AUDIT_VALUES = ['on', 'off'];
|
|
84
85
|
const USAGE_REPORTING_VALUES = ['on', 'off'];
|
|
85
86
|
const ASSISTANT_LANE_VALUES = ['chat', 'skills'];
|
|
87
|
+
const ASSISTANT_SKILLS_VALUES = ['system', 'inline'];
|
|
86
88
|
const APPROVALS_VALUES = ['plan', 'per-write'];
|
|
87
89
|
/**
|
|
88
90
|
* Thrown on validation failure. The outer CLI dispatcher converts to
|
|
@@ -547,6 +549,15 @@ export async function runConfigSet(args) {
|
|
|
547
549
|
cfg.assistant.lane = lane;
|
|
548
550
|
break;
|
|
549
551
|
}
|
|
552
|
+
// Stage 1A — explicit, like the lane. markExplicit below keeps the table.
|
|
553
|
+
case 'assistant.skills': {
|
|
554
|
+
const skills = value.trim().toLowerCase();
|
|
555
|
+
if (skills !== 'system' && skills !== 'inline') {
|
|
556
|
+
err(`assistant.skills must be one of: ${ASSISTANT_SKILLS_VALUES.join(', ')}`);
|
|
557
|
+
}
|
|
558
|
+
cfg.assistant.skills = skills;
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
550
561
|
case 'assistant.style': {
|
|
551
562
|
const style = parseStyle(value);
|
|
552
563
|
if (!style)
|
|
@@ -800,6 +811,8 @@ export function validateValue(key, value) {
|
|
|
800
811
|
return value === 'true' || value === 'false';
|
|
801
812
|
case 'assistant.lane':
|
|
802
813
|
return ASSISTANT_LANE_VALUES.includes(value.trim().toLowerCase());
|
|
814
|
+
case 'assistant.skills':
|
|
815
|
+
return ASSISTANT_SKILLS_VALUES.includes(value.trim().toLowerCase());
|
|
803
816
|
case 'assistant.style':
|
|
804
817
|
return parseStyle(value) !== null;
|
|
805
818
|
case 'assistant.level':
|
|
@@ -3,6 +3,7 @@ import { loadConfig } from '../config/loader.js';
|
|
|
3
3
|
import { paint } from '../renderer/theme.js';
|
|
4
4
|
import { formatAttention } from '../renderer/notice-shape.js';
|
|
5
5
|
import { getCustomerModelConfig } from '../models/server-config.js';
|
|
6
|
+
import { inlineInactiveReason, inlineServed } from '../repl/inline-mode.js';
|
|
6
7
|
/**
|
|
7
8
|
* Dotted paths of keys that must never appear in plaintext config output.
|
|
8
9
|
* SAP passwords live in the keychain and should never land in config.toml,
|
|
@@ -74,7 +75,11 @@ function printLlmMissingHints(llm) {
|
|
|
74
75
|
console.log(`ai_hub_router_model = ${paint('dim', '<not set — skill routing uses ai_hub_model_alias>')}`);
|
|
75
76
|
}
|
|
76
77
|
}
|
|
77
|
-
|
|
78
|
+
/**
|
|
79
|
+
* `notes` maps a dotted key to a trailing comment (Stage 1A: a stored
|
|
80
|
+
* `assistant.skills = inline` that would not be active).
|
|
81
|
+
*/
|
|
82
|
+
function printConfigAsToml(cfg, prefix = '', notes = {}) {
|
|
78
83
|
// Emit scalars before nested objects — otherwise a top-level scalar that
|
|
79
84
|
// follows a nested table (e.g. `write_mode` declared after `ui` in the
|
|
80
85
|
// config shape) would render underneath `[ui]` in TOML, which reparses as
|
|
@@ -91,7 +96,8 @@ function printConfigAsToml(cfg, prefix = '') {
|
|
|
91
96
|
console.log(`${k} = [REDACTED]`);
|
|
92
97
|
}
|
|
93
98
|
else {
|
|
94
|
-
|
|
99
|
+
const note = notes[fullKey];
|
|
100
|
+
console.log(`${k} = ${formatTomlValue(v)}` + (note ? chalk.gray(` # ${note}`) : ''));
|
|
95
101
|
// Surface an explanatory hint for advisory-only mode so users understand
|
|
96
102
|
// the implication (writes proposed, never executed) without having to
|
|
97
103
|
// read the docs.
|
|
@@ -104,7 +110,7 @@ function printConfigAsToml(cfg, prefix = '') {
|
|
|
104
110
|
const fullKey = prefix ? `${prefix}.${k}` : k;
|
|
105
111
|
console.log('');
|
|
106
112
|
console.log(chalk.bold(`[${fullKey}]`));
|
|
107
|
-
printConfigAsToml(v, fullKey);
|
|
113
|
+
printConfigAsToml(v, fullKey, notes);
|
|
108
114
|
// Mode-aware extras for the `[llm]` section — print required keys that
|
|
109
115
|
// are empty as `<not set>` so a user who ran `cspeach config set llm.mode
|
|
110
116
|
// ai-hub` without also setting the base URL can see what is still
|
|
@@ -165,7 +171,13 @@ export async function runConfigShow(args) {
|
|
|
165
171
|
// Full config dump
|
|
166
172
|
console.log('');
|
|
167
173
|
console.log(chalk.gray('# ~/.cspeach/config.toml'));
|
|
168
|
-
|
|
174
|
+
// Stage 1A — say when a stored `inline` would not be active in an
|
|
175
|
+
// interactive session (the served flag is loaded by cli.ts).
|
|
176
|
+
// Read defensively: a config without an [assistant] table means the defaults.
|
|
177
|
+
const inlineNote = cfg.assistant?.skills === 'inline'
|
|
178
|
+
? inlineInactiveReason({ skills: 'inline', lane: cfg.assistant.lane, served: inlineServed(cfg.llm?.mode) })
|
|
179
|
+
: null;
|
|
180
|
+
printConfigAsToml(cfg, '', inlineNote ? { 'assistant.skills': inlineNote } : {});
|
|
169
181
|
console.log('');
|
|
170
182
|
// Task 12 — one trailing line when the account admin's model is enforced
|
|
171
183
|
// by the proxy (/v1/me/model-config). No snapshot ⇒ output unchanged.
|
|
@@ -20,7 +20,7 @@ import path from 'node:path';
|
|
|
20
20
|
import { metaFromMarkdown } from '../docx/doc-meta.js';
|
|
21
21
|
import { writeDocxNextTo, writeNoClobber } from '../docx/write-docx.js';
|
|
22
22
|
import { normalizeAskInput } from '../tools/ask-question.js';
|
|
23
|
-
import { isUserPrompt, userPromptText } from '../session/user-prompt.js';
|
|
23
|
+
import { INJECTED_CONTEXT_TAGS, isUserPrompt, userPromptText } from '../session/user-prompt.js';
|
|
24
24
|
import { resolveCliMarks, startsTurn } from '../session/cli-record.js';
|
|
25
25
|
import { docxFileFromReplArgs, runExportDocxCommand } from './export-docx.js';
|
|
26
26
|
// The usage line lives in the light export-audit module (final-review I-2); re-exported here.
|
|
@@ -30,11 +30,14 @@ const OUT_DIR = 'cspeach-out';
|
|
|
30
30
|
function readerText(text) {
|
|
31
31
|
return text.replace(/<!--[\s\S]*?-->/g, '').replace(/\n{3,}/g, '\n\n').trim();
|
|
32
32
|
}
|
|
33
|
-
/**
|
|
34
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Context blocks the CLI adds to a prompt (the developer never typed them) are
|
|
35
|
+
* dropped with the shared INJECTED_CONTEXT_TAGS list (session/user-prompt.ts),
|
|
36
|
+
* which carries Stage 1A's inline `<skill>` playbook blocks too.
|
|
37
|
+
*/
|
|
35
38
|
function typedText(text) {
|
|
36
39
|
let out = text;
|
|
37
|
-
for (const tag of
|
|
40
|
+
for (const tag of INJECTED_CONTEXT_TAGS) {
|
|
38
41
|
out = out.replace(new RegExp(`<${tag}(?:\\s[^>]*)?>[\\s\\S]*?</${tag}>`, 'g'), '');
|
|
39
42
|
}
|
|
40
43
|
out = out.replace(/<attached file="([^"]*)">[\s\S]*?<\/attached>/g, (_m, f) => `@${f}`);
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// cspeach-cli/src/commands/finish-check-guard.ts
|
|
2
|
+
//
|
|
3
|
+
// Finish-check guard (2026-10-23, inline-waiver findings §6 Option A).
|
|
4
|
+
// A plan step may not be saved "validated" while that turn's LAST
|
|
5
|
+
// rap_finish_check said fewer than 5 of 5 passed. The write-back
|
|
6
|
+
// downgrades it to "blocked" (never refuses: a refusal would cost another
|
|
7
|
+
// model turn). "validated-with-waiver" is kept only when the step's notes
|
|
8
|
+
// name every failed item id.
|
|
9
|
+
//
|
|
10
|
+
// The guard acts only when it reads the check confidently. No check this
|
|
11
|
+
// turn, an unreadable last check, a 5 of 5, or any other status → null
|
|
12
|
+
// (today's behaviour).
|
|
13
|
+
const TOOL_NAME = 'rap_finish_check';
|
|
14
|
+
// The real handler's shape (tools/rap/finish-check.ts formatFinish):
|
|
15
|
+
// Finish check: <n> of 5 passed
|
|
16
|
+
// F<k> <title> — passed | needs work: <problems>
|
|
17
|
+
const HEADER_RE = /^Finish check: (\d+) of (\d+) passed$/;
|
|
18
|
+
const ITEM_RE = /^(F[1-5]) .*? — (passed|needs work:.*)$/;
|
|
19
|
+
const ITEM_IDS = ['F1', 'F2', 'F3', 'F4', 'F5'];
|
|
20
|
+
function resultText(content) {
|
|
21
|
+
if (typeof content === 'string')
|
|
22
|
+
return content;
|
|
23
|
+
if (!Array.isArray(content))
|
|
24
|
+
return null;
|
|
25
|
+
const parts = [];
|
|
26
|
+
for (const b of content) {
|
|
27
|
+
if (b?.type === 'text' && typeof b.text === 'string')
|
|
28
|
+
parts.push(b.text);
|
|
29
|
+
}
|
|
30
|
+
return parts.length > 0 ? parts.join('\n') : null;
|
|
31
|
+
}
|
|
32
|
+
/** The text of this turn's LAST rap_finish_check tool result, or null when there is none. */
|
|
33
|
+
export function lastFinishCheckText(messages, messagesStart) {
|
|
34
|
+
const checkIds = new Set();
|
|
35
|
+
let last = null;
|
|
36
|
+
for (let i = Math.max(0, messagesStart); i < messages.length; i++) {
|
|
37
|
+
const m = messages[i];
|
|
38
|
+
if (!Array.isArray(m.content))
|
|
39
|
+
continue;
|
|
40
|
+
for (const b of m.content) {
|
|
41
|
+
if (!b || typeof b !== 'object')
|
|
42
|
+
continue;
|
|
43
|
+
if (m.role === 'assistant' && b.type === 'tool_use' && b.name === TOOL_NAME && typeof b.id === 'string') {
|
|
44
|
+
checkIds.add(b.id);
|
|
45
|
+
}
|
|
46
|
+
else if (m.role === 'user' && b.type === 'tool_result' && typeof b.tool_use_id === 'string' && checkIds.has(b.tool_use_id)) {
|
|
47
|
+
// An unreadable last result still counts as the last one: it makes the guard stand down.
|
|
48
|
+
last = resultText(b.content) ?? '';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return last;
|
|
53
|
+
}
|
|
54
|
+
/** Parses a rap_finish_check result; null unless every line agrees with the handler's shape. */
|
|
55
|
+
export function parseFinishCheck(text) {
|
|
56
|
+
const lines = text.split(/\r?\n/).map((l) => l.trimEnd());
|
|
57
|
+
const head = HEADER_RE.exec(lines[0] ?? '');
|
|
58
|
+
if (!head)
|
|
59
|
+
return null;
|
|
60
|
+
const passed = Number(head[1]);
|
|
61
|
+
const total = Number(head[2]);
|
|
62
|
+
if (total !== ITEM_IDS.length || passed > total)
|
|
63
|
+
return null;
|
|
64
|
+
const seen = [];
|
|
65
|
+
const failed = [];
|
|
66
|
+
for (const line of lines.slice(1)) {
|
|
67
|
+
const m = ITEM_RE.exec(line);
|
|
68
|
+
if (!m)
|
|
69
|
+
continue;
|
|
70
|
+
seen.push(m[1]);
|
|
71
|
+
if (m[2] !== 'passed')
|
|
72
|
+
failed.push(m[1]);
|
|
73
|
+
}
|
|
74
|
+
if (seen.join(',') !== ITEM_IDS.join(','))
|
|
75
|
+
return null;
|
|
76
|
+
if (failed.length !== total - passed)
|
|
77
|
+
return null;
|
|
78
|
+
return { passed, total, failed };
|
|
79
|
+
}
|
|
80
|
+
const namesId = (notes, id) => new RegExp(`\\b${id}\\b`).test(notes);
|
|
81
|
+
/**
|
|
82
|
+
* The guard's note: one place for its wording. It goes into the step's
|
|
83
|
+
* work.notes and is shown as the run line.
|
|
84
|
+
* <step>: finish check 3 of 5, F1, F4 failed. Saved as blocked.
|
|
85
|
+
* <step>: finish check 3 of 5, F1, F4 failed. The notes do not waive F4. Saved as blocked.
|
|
86
|
+
*/
|
|
87
|
+
const GUARD_NOTE_TAIL = 'Saved as blocked.';
|
|
88
|
+
const GUARD_NOTE_HEAD = 'finish check';
|
|
89
|
+
const GUARD_NOTE_RE = new RegExp(`: ${GUARD_NOTE_HEAD} \\d+ of \\d+, .* ${GUARD_NOTE_TAIL.replace(/\./g, '\\.')}$`);
|
|
90
|
+
export function formatFinishGuardNote(a) {
|
|
91
|
+
const waiver = a.notWaived && a.notWaived.length > 0 ? ` The notes do not waive ${a.notWaived.join(', ')}.` : '';
|
|
92
|
+
return `${a.step}: ${GUARD_NOTE_HEAD} ${a.score.passed} of ${a.score.total}, ${a.score.failed.join(', ')} failed.${waiver} ${GUARD_NOTE_TAIL}`;
|
|
93
|
+
}
|
|
94
|
+
/** True for a line the guard wrote itself (it never counts as a waiver). */
|
|
95
|
+
export function isFinishGuardNoteLine(line) {
|
|
96
|
+
return GUARD_NOTE_RE.test(line.trim());
|
|
97
|
+
}
|
|
98
|
+
/** The notes without the guard's own lines: only what the model or developer wrote. */
|
|
99
|
+
function notesWithoutGuardLines(notes) {
|
|
100
|
+
return notes.split(/\r?\n/).filter((l) => !isFinishGuardNoteLine(l)).join('\n');
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The guard's ruling for the executed step: `{ status: 'blocked', note }` when
|
|
104
|
+
* the claimed status must be downgraded, otherwise null (save as written).
|
|
105
|
+
* A waiver counts from any turn's notes, but never from the guard's own lines.
|
|
106
|
+
*/
|
|
107
|
+
export function finishCheckGuard(args) {
|
|
108
|
+
if (args.status !== 'validated' && args.status !== 'validated-with-waiver')
|
|
109
|
+
return null;
|
|
110
|
+
const text = lastFinishCheckText(args.messages, args.messagesStart);
|
|
111
|
+
if (text === null)
|
|
112
|
+
return null;
|
|
113
|
+
const score = parseFinishCheck(text);
|
|
114
|
+
if (!score || score.failed.length === 0)
|
|
115
|
+
return null;
|
|
116
|
+
let notWaived;
|
|
117
|
+
if (args.status === 'validated-with-waiver') {
|
|
118
|
+
const notes = notesWithoutGuardLines(args.notes ?? '');
|
|
119
|
+
notWaived = score.failed.filter((id) => !namesId(notes, id));
|
|
120
|
+
if (notWaived.length === 0)
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
return { status: 'blocked', note: formatFinishGuardNote({ step: args.step, score, notWaived }) };
|
|
124
|
+
}
|
|
@@ -99,10 +99,28 @@ export async function regroundPlan(a) {
|
|
|
99
99
|
stillLocked.push(o.name);
|
|
100
100
|
}
|
|
101
101
|
let transportClosed = false;
|
|
102
|
+
let listed = null;
|
|
103
|
+
const isListed = (t, tr) => t.list.some((x) => x.trim().toUpperCase() === tr.trim().toUpperCase());
|
|
102
104
|
if (transport) {
|
|
103
105
|
const t = await a.deps.openTransports();
|
|
104
|
-
|
|
106
|
+
listed = t;
|
|
107
|
+
transportClosed = t.complete && !isListed(t, transport);
|
|
105
108
|
}
|
|
109
|
+
// The step transport counts as open only when SAP lists it. Any failure
|
|
110
|
+
// here keeps the old text and a "not open" verdict.
|
|
111
|
+
const step = a.stepTransport?.trim();
|
|
112
|
+
let stepOpen = false;
|
|
113
|
+
if (step) {
|
|
114
|
+
try {
|
|
115
|
+
listed = listed ?? await a.deps.openTransports();
|
|
116
|
+
stepOpen = isListed(listed, step);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
recordFullNotice('plan', `Transport check before a plan step failed: ${errText(err)}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const openStepTransport = !transport && stepOpen ? step : null;
|
|
123
|
+
const verdict = step ? { stepTransportOpen: stepOpen } : {};
|
|
106
124
|
// An object left inactive gets the inactive instruction only, never the
|
|
107
125
|
// "do not rewrite" one as well.
|
|
108
126
|
const inactiveNames = new Set(inactive.map((o) => o.name));
|
|
@@ -124,7 +142,9 @@ export async function regroundPlan(a) {
|
|
|
124
142
|
? (transportClosed
|
|
125
143
|
? `- The plan's transport ${transport} is no longer open. Do not write to it. Creating a new transport needs the developer's approval (it is not on the approved list).`
|
|
126
144
|
: `- Transport ${transport} is still open: use it.`)
|
|
127
|
-
:
|
|
145
|
+
: openStepTransport
|
|
146
|
+
? `- Transport ${openStepTransport} is open: use it.`
|
|
147
|
+
: '- No transport is recorded yet: the first writing step creates one.',
|
|
128
148
|
'</sap_state>',
|
|
129
149
|
];
|
|
130
150
|
if (stillLocked.length > 0) {
|
|
@@ -133,12 +153,14 @@ export async function regroundPlan(a) {
|
|
|
133
153
|
ok: false, reason: 'locked',
|
|
134
154
|
lines: [`${stillLocked.join(', ')} ${one ? 'is' : 'are'} still locked by another session. SAP frees ${one ? 'it' : 'them'} when that session ends or times out, usually within a few minutes.`],
|
|
135
155
|
sapStateBlock: block,
|
|
156
|
+
...verdict,
|
|
136
157
|
};
|
|
137
158
|
}
|
|
138
159
|
const n = inactive.length;
|
|
139
160
|
const summary = `Checked SAP before continuing: ${n} object${n === 1 ? '' : 's'} left inactive`
|
|
140
|
-
+ (transport ? `, transport ${transport} ${transportClosed ? 'no longer open' : 'still open'}` : '')
|
|
141
|
-
|
|
161
|
+
+ (transport ? `, transport ${transport} ${transportClosed ? 'no longer open' : 'still open'}` : '')
|
|
162
|
+
+ (openStepTransport ? `, transport ${openStepTransport} open` : '') + '.';
|
|
163
|
+
return { ok: true, summary, quiet: n === 0 && !transportClosed, sapStateBlock: block, ...verdict };
|
|
142
164
|
}
|
|
143
165
|
catch (err) {
|
|
144
166
|
const code = unreachableOf(err);
|
|
@@ -158,6 +180,7 @@ export async function regroundPlan(a) {
|
|
|
158
180
|
built.length > 0 ? `- Already built by earlier steps of this plan: ${built.join(', ')}.` : '- No earlier step of this plan has built SAP objects yet.',
|
|
159
181
|
'</sap_state>',
|
|
160
182
|
],
|
|
183
|
+
...(a.stepTransport?.trim() ? { stepTransportOpen: false } : {}),
|
|
161
184
|
};
|
|
162
185
|
}
|
|
163
186
|
}
|
|
@@ -45,6 +45,7 @@ import { resolveAtTokenAsync, formatProjectFileList, ensureWorkspace } from '../
|
|
|
45
45
|
import { parsePlanContent, phaseWrites } from '../projects/plan-schema.js';
|
|
46
46
|
import { statusesFromItems, computeNextPhase, computeNextPhaseForRun, pendingAuditPhaseIds, renderPlanTracker, renderPlanProgressLine, buildPlanRevision, isPhaseSatisfied, isAuditResolved, planCompletionLines, findServiceBinding, phaseDisplayTitle, } from '../projects/plan-run.js';
|
|
47
47
|
import { extractPlan, NO_PLAN_MANIFEST_MESSAGE } from '../projects/extract-plan.js';
|
|
48
|
+
import { finishCheckGuard } from './finish-check-guard.js';
|
|
48
49
|
import { recordFullNotice } from '../renderer/notice-log.js';
|
|
49
50
|
import { isSapUnreachableNoted } from '../sap/reachability-state.js';
|
|
50
51
|
import { saveProject } from '../projects/save.js';
|
|
@@ -66,6 +67,8 @@ import { isCredentialHandoverApproved } from '../repl/credential-handover-gate.j
|
|
|
66
67
|
import { FIORI_DEPLOY_HANDOVER_SCOPE } from '../tools/fiori/deploy/scope.js';
|
|
67
68
|
import { resolvePlanSources, renderPlanSourcesBlock } from '../projects/plan-sources.js';
|
|
68
69
|
import { regroundPlan } from './plan-reground.js';
|
|
70
|
+
import { getGrantTransport, isPlanGrantInForceFor } from '../approvals/plan-grant.js';
|
|
71
|
+
import { projectTransport, transportForWrite } from '../register/project-transport.js';
|
|
69
72
|
import { PAUSED_LINE, makeSapProbe, pauseUntilSapReachable, promptLockedChoice, resumeCommandFor } from './plan-sap-pause.js';
|
|
70
73
|
/**
|
|
71
74
|
* Parse and STRIP the per-run chaining-mode (`--guarded` / `--step`) and
|
|
@@ -166,6 +169,51 @@ export function buildUiPhaseContext(phases, next) {
|
|
|
166
169
|
}
|
|
167
170
|
return lines;
|
|
168
171
|
}
|
|
172
|
+
const sameTransport = (a, b) => !!a && !!b && a.trim().toUpperCase() === b.trim().toUpperCase();
|
|
173
|
+
/**
|
|
174
|
+
* 2026-10-23 (owner-approved fix, review round 1) — the transport a create in
|
|
175
|
+
* this step would go to, by the write path's own order, so the step prompt can
|
|
176
|
+
* name it. Never the step's own work.transport ahead of that order.
|
|
177
|
+
* - A plan grant of THIS plan is in force: its transport (writes under the
|
|
178
|
+
* grant are held to it; a grant whose new transport is not bound yet names
|
|
179
|
+
* none).
|
|
180
|
+
* - Guarded mode, no grant yet: the grant offered for this step would bind
|
|
181
|
+
* the plan's recorded transport, else the session one; if it is not
|
|
182
|
+
* installed, writes take transportForWrite's order. Named only when the
|
|
183
|
+
* two agree.
|
|
184
|
+
* - Otherwise (step mode): transportForWrite for a create: the joined team
|
|
185
|
+
* project's request, else the session transport (an explicit /transport).
|
|
186
|
+
* Resolve-mode writes (set_source, update_method, delete) never fall back to
|
|
187
|
+
* the session transport, and an object's owning lock still wins over any
|
|
188
|
+
* transport passed. Pure.
|
|
189
|
+
*/
|
|
190
|
+
export function resolveStepWriteTransport(i) {
|
|
191
|
+
if (i.grantInstalled)
|
|
192
|
+
return i.grantTransport?.trim() || undefined;
|
|
193
|
+
const perWrite = transportForWrite({ op: 'create', project: i.project, session: i.session }).request;
|
|
194
|
+
if (i.mode !== 'guarded')
|
|
195
|
+
return perWrite || undefined;
|
|
196
|
+
const planned = recordedPlanTransport(i.phases) ?? i.session ?? undefined;
|
|
197
|
+
return sameTransport(planned, perWrite) ? perWrite : undefined;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* The step-prompt line naming the transport. `transport` is passed only when
|
|
201
|
+
* it is the one the write path uses AND the pre-step SAP check listed it as
|
|
202
|
+
* open. Returns [] (prompt unchanged) with no transport, for read-only steps
|
|
203
|
+
* (writes:false), design steps, and UI/CAP steps (local files; a UI deploy
|
|
204
|
+
* names its own).
|
|
205
|
+
*/
|
|
206
|
+
export function buildStepTransportLine(next, transport) {
|
|
207
|
+
if (!transport)
|
|
208
|
+
return [];
|
|
209
|
+
if (!phaseWrites(next))
|
|
210
|
+
return [];
|
|
211
|
+
if (next.delegateTo === 'abap-design')
|
|
212
|
+
return [];
|
|
213
|
+
if (next.layer === 'ui' || next.layer.startsWith('cap.'))
|
|
214
|
+
return [];
|
|
215
|
+
return [`Transport for this step: ${transport} (already settled: use it, do not ask the developer which transport).`];
|
|
216
|
+
}
|
|
169
217
|
/** Compact-example work keys for ui phases — the write-back contract
|
|
170
218
|
* (review C3). Returns an OBJECT fragment because compactExample renders
|
|
171
219
|
* via JSON.stringify: it is spread into the `work` object exactly like the
|
|
@@ -528,17 +576,31 @@ export async function preparePlanResume(args) {
|
|
|
528
576
|
// what this plan built, what a dead run left inactive (a stale lock is
|
|
529
577
|
// waited out, then reported), and whether the transport is still open.
|
|
530
578
|
let sapStateBlock = [];
|
|
579
|
+
// 2026-10-23 — the transport this step's writes would use (write-path
|
|
580
|
+
// order). It is named in the prompt only when the re-check lists it open.
|
|
581
|
+
// A grant counts only while it is in force (this plan, not expired, approvals
|
|
582
|
+
// = plan); otherwise writes take the per-write order, and so does the line.
|
|
583
|
+
const stepWriteTransport = resolveStepWriteTransport({
|
|
584
|
+
phases: content.phases,
|
|
585
|
+
mode: effectiveMode,
|
|
586
|
+
grantInstalled: isPlanGrantInForceFor(envelope.id),
|
|
587
|
+
grantTransport: getGrantTransport(),
|
|
588
|
+
project: projectTransport(args.cwd),
|
|
589
|
+
session: getCurrentTransport(),
|
|
590
|
+
});
|
|
591
|
+
let stepTransportOpen = false;
|
|
531
592
|
if (args.reground) {
|
|
532
593
|
const regroundDeps = args.reground;
|
|
533
594
|
const canAsk = args.interactive === true && !!args.prompt;
|
|
534
595
|
const resumeCommand = resumeCommandFor({ path, mode: flags.mode, auditFlag: flags.audit });
|
|
535
596
|
for (;;) {
|
|
536
|
-
const r = await regroundPlan({ content, statuses, deps: regroundDeps, log: args.log });
|
|
597
|
+
const r = await regroundPlan({ content, statuses, deps: regroundDeps, log: args.log, ...(stepWriteTransport ? { stepTransport: stepWriteTransport } : {}) });
|
|
537
598
|
if (r.ok) {
|
|
538
599
|
// An in-run hop stays quiet unless something needs attention.
|
|
539
600
|
if (!r.quiet || args.boardStyle !== 'line')
|
|
540
601
|
args.log(formatInfo(r.summary).trimEnd());
|
|
541
602
|
sapStateBlock = r.sapStateBlock;
|
|
603
|
+
stepTransportOpen = r.stepTransportOpen === true;
|
|
542
604
|
break;
|
|
543
605
|
}
|
|
544
606
|
if (r.reason === 'unreachable') {
|
|
@@ -562,6 +624,7 @@ export async function preparePlanResume(args) {
|
|
|
562
624
|
continue;
|
|
563
625
|
if (choice === 'continue') {
|
|
564
626
|
sapStateBlock = r.sapStateBlock;
|
|
627
|
+
stepTransportOpen = r.stepTransportOpen === true;
|
|
565
628
|
break;
|
|
566
629
|
}
|
|
567
630
|
args.log(PAUSED_LINE);
|
|
@@ -631,6 +694,10 @@ export async function preparePlanResume(args) {
|
|
|
631
694
|
// '' already ends the prior element). Spread the blank AFTER the block so
|
|
632
695
|
// context keeps single-blank separation on both sides.
|
|
633
696
|
const uiContext = buildUiPhaseContext(content.phases, next);
|
|
697
|
+
// Named only when it is the transport the write uses, it is listed open, and
|
|
698
|
+
// it does not contradict the plan's recorded transport in <sap_state>.
|
|
699
|
+
const recordedTr = recordedPlanTransport(content.phases);
|
|
700
|
+
const stepTransport = buildStepTransportLine(next, stepTransportOpen && (!recordedTr || sameTransport(recordedTr, stepWriteTransport)) ? stepWriteTransport : undefined);
|
|
634
701
|
// 2026-10-04 (plan sources) — the spec-gap items this phase reads, in full,
|
|
635
702
|
// from the file the plan was made from (or the plan's creation snapshot).
|
|
636
703
|
// A phase must never have to hunt the folder for its gap answers.
|
|
@@ -671,6 +738,7 @@ export async function preparePlanResume(args) {
|
|
|
671
738
|
'Only if the plan itself must change structurally (a phase added, removed, or re-sequenced) fall back to the full shape with "content" — CSPeach accepts both. The manifest block is how the result is persisted; a turn that ends without it LOSES the phase.',
|
|
672
739
|
'',
|
|
673
740
|
...(uiContext.length ? [...uiContext, ''] : []),
|
|
741
|
+
...(stepTransport.length ? [...stepTransport, ''] : []),
|
|
674
742
|
PLAN_RESUME_HARNESS_OVERRIDE,
|
|
675
743
|
].join('\n');
|
|
676
744
|
return {
|
|
@@ -935,6 +1003,12 @@ export async function finishPlanResume(args) {
|
|
|
935
1003
|
return null;
|
|
936
1004
|
}
|
|
937
1005
|
}
|
|
1006
|
+
// 2026-10-23 — finish-check guard (inline-waiver findings §6 Option A): a
|
|
1007
|
+
// step claimed "validated" while this turn's LAST rap_finish_check is below
|
|
1008
|
+
// 5 of 5 is saved "blocked" (a waiver only when its notes name every failed
|
|
1009
|
+
// item). Downgrade, never refuse. No check this turn, a 5 of 5, an
|
|
1010
|
+
// unreadable result or any other status → saved exactly as written.
|
|
1011
|
+
applyFinishCheckGuard(extract, args);
|
|
938
1012
|
const nowIso = new Date().toISOString();
|
|
939
1013
|
const statuses = extract.statuses;
|
|
940
1014
|
const executedId = args.prepared.nextPhaseId;
|
|
@@ -1056,6 +1130,42 @@ export async function finishPlanResume(args) {
|
|
|
1056
1130
|
nextTitle: next ? phaseDisplayTitle(next, extract.content.phases) : null,
|
|
1057
1131
|
};
|
|
1058
1132
|
}
|
|
1133
|
+
/**
|
|
1134
|
+
* The finish-check guard's write: when finishCheckGuard rules the executed
|
|
1135
|
+
* step down, set it to 'blocked' in the status map, the item list and the
|
|
1136
|
+
* summary, append the note to its work.notes and log the same line.
|
|
1137
|
+
*/
|
|
1138
|
+
function applyFinishCheckGuard(extract, args) {
|
|
1139
|
+
const executedId = args.prepared.nextPhaseId;
|
|
1140
|
+
const executed = extract.content.phases.find((p) => p.id === executedId);
|
|
1141
|
+
if (!executed)
|
|
1142
|
+
return;
|
|
1143
|
+
const ruling = finishCheckGuard({
|
|
1144
|
+
messages: args.messages,
|
|
1145
|
+
messagesStart: args.messagesStart,
|
|
1146
|
+
status: extract.statuses[executedId],
|
|
1147
|
+
step: phaseDisplayTitle(executed, extract.content.phases),
|
|
1148
|
+
notes: executed.work?.notes,
|
|
1149
|
+
});
|
|
1150
|
+
if (!ruling)
|
|
1151
|
+
return;
|
|
1152
|
+
extract.statuses[executedId] = ruling.status;
|
|
1153
|
+
for (const it of extract.items)
|
|
1154
|
+
if (it.id === executedId)
|
|
1155
|
+
it.status = ruling.status;
|
|
1156
|
+
const prevNotes = executed.work?.notes;
|
|
1157
|
+
executed.work = { ...(executed.work ?? {}), notes: prevNotes ? `${prevNotes}\n${ruling.note}` : ruling.note };
|
|
1158
|
+
const phases = extract.content.phases;
|
|
1159
|
+
const next = computeNextPhase(phases, extract.statuses);
|
|
1160
|
+
extract.content.summary = {
|
|
1161
|
+
...extract.content.summary,
|
|
1162
|
+
validated: phases.filter((p) => isPhaseSatisfied(extract.statuses[p.id], p.audit)).length,
|
|
1163
|
+
blocked: phases.filter((p) => extract.statuses[p.id] === 'blocked').length,
|
|
1164
|
+
next: next?.id ?? null,
|
|
1165
|
+
};
|
|
1166
|
+
args.log('');
|
|
1167
|
+
args.log(ruling.note);
|
|
1168
|
+
}
|
|
1059
1169
|
/**
|
|
1060
1170
|
* Final fix wave, item 5 — save a new plan version beside the plan file. When
|
|
1061
1171
|
* that write fails (a read-only or missing folder), save it in the working
|
package/dist/config/loader.js
CHANGED
|
@@ -45,7 +45,8 @@ export const DEFAULT_CONFIG = {
|
|
|
45
45
|
// (staged rollout, owner 2026-10-17; users opt in with /chat on, a later
|
|
46
46
|
// release flips this); 'professional' sends no style; 'developer' sends no
|
|
47
47
|
// level (§10 D13).
|
|
48
|
-
|
|
48
|
+
// Stage 1A — 'system' is today's request; 'inline' is opt-in (config set).
|
|
49
|
+
assistant: { lane: 'skills', style: DEFAULT_STYLE, level: DEFAULT_LEVEL, skills: 'system' },
|
|
49
50
|
// Default `approval-gated` — preserves current UX (user approves each write).
|
|
50
51
|
write_mode: 'approval-gated',
|
|
51
52
|
// Default `managed` — lowest-friction entry point; uses the cspeach.dev proxy.
|
|
@@ -321,6 +322,9 @@ async function readConfig() {
|
|
|
321
322
|
merged.assistant.lane = DEFAULT_CONFIG.assistant.lane;
|
|
322
323
|
merged.assistant.style = parseStyle(merged.assistant.style) ?? DEFAULT_CONFIG.assistant.style;
|
|
323
324
|
merged.assistant.level = parseLevel(merged.assistant.level) ?? DEFAULT_CONFIG.assistant.level;
|
|
325
|
+
// Stage 1A — unknown values fall back, like the lane.
|
|
326
|
+
if (merged.assistant.skills !== 'system' && merged.assistant.skills !== 'inline')
|
|
327
|
+
merged.assistant.skills = DEFAULT_CONFIG.assistant.skills;
|
|
324
328
|
if (merged.ui.background !== undefined && merged.ui.background !== 'dark' && merged.ui.background !== 'light') {
|
|
325
329
|
delete merged.ui.background;
|
|
326
330
|
}
|
package/dist/cost/cost-log.js
CHANGED
|
@@ -87,6 +87,12 @@ export function buildEntry(opts) {
|
|
|
87
87
|
entry.skill = opts.skill;
|
|
88
88
|
if (opts.system_block !== undefined)
|
|
89
89
|
entry.system_block = opts.system_block;
|
|
90
|
+
if (opts.system_mode !== undefined)
|
|
91
|
+
entry.system_mode = opts.system_mode;
|
|
92
|
+
if (opts.skill_appended !== undefined && opts.skill_appended.length > 0)
|
|
93
|
+
entry.skill_appended = opts.skill_appended;
|
|
94
|
+
if (opts.tools_surfaced !== undefined && opts.tools_surfaced.length > 0)
|
|
95
|
+
entry.tools_surfaced = opts.tools_surfaced;
|
|
90
96
|
return entry;
|
|
91
97
|
}
|
|
92
98
|
/**
|
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* Kept in a module WeakMap, never in the session file (no schema change). A
|
|
12
12
|
* resumed session therefore starts at 'first' again.
|
|
13
13
|
*/
|
|
14
|
+
/**
|
|
15
|
+
* Stage 1A, Task 1A.2 (M2) — the skill part of the key on an inline call: the
|
|
16
|
+
* system block is the one conversation prompt whatever the skill header says,
|
|
17
|
+
* and no delegate is composed into it (the delegate rides in the conversation).
|
|
18
|
+
*/
|
|
19
|
+
export const INLINE_SYSTEM_BLOCK_SKILL = '_inline';
|
|
14
20
|
export function systemBlockKey(p) {
|
|
15
21
|
return `${p.skill}|${p.delegateSkill ?? ''}|${p.style}|${p.level}`;
|
|
16
22
|
}
|
|
@@ -284,7 +284,7 @@ export function managedProxyServesNewShapes() {
|
|
|
284
284
|
* conversation prompt? True only when its served config says
|
|
285
285
|
* `inline_skills: true`; false with no snapshot or an older proxy. Managed
|
|
286
286
|
* snapshot only: BYOK builds its own system prompt and never needs it.
|
|
287
|
-
*
|
|
287
|
+
* Read only by repl/inline-mode.ts `inlineServed` (Stage 1A; pinned by
|
|
288
288
|
* __tests__/served-inline-skills-pin.test.ts). Pure sync read.
|
|
289
289
|
*/
|
|
290
290
|
export function servedInlineSkills() {
|
|
@@ -398,7 +398,11 @@ export async function runSaveCommand(args) {
|
|
|
398
398
|
// maybeOfferSave never does headless — so the blanket 'y' never reaches it.)
|
|
399
399
|
// Inside a journey a triage is saved without the question: the journey needs the rows.
|
|
400
400
|
const journey = spec.artefactType === 'clean-core-decision' ? args.journey : undefined;
|
|
401
|
-
const answer = journey ? 'y' : await askYesNo({
|
|
401
|
+
const answer = journey ? 'y' : await askYesNo({
|
|
402
|
+
question: 'Save as project file?', confirm: args.confirm, prompt: args.prompt,
|
|
403
|
+
// F-A (live check 2026-10-26) — a plan is only built from its saved file.
|
|
404
|
+
...(spec.artefactType === 'plan' ? { defaultYes: true } : {}),
|
|
405
|
+
});
|
|
402
406
|
if (!isYes(answer)) {
|
|
403
407
|
args.onNotSaved?.('declined');
|
|
404
408
|
return null;
|
package/dist/projects/yes-no.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
export async function askYesNo(a) {
|
|
2
2
|
if (a.confirm)
|
|
3
|
-
return a.confirm(a.question, { default: 'n' });
|
|
3
|
+
return a.confirm(a.question, { default: a.defaultYes ? 'y' : 'n' });
|
|
4
4
|
if (!a.prompt)
|
|
5
5
|
return '';
|
|
6
|
+
if (a.defaultYes) {
|
|
7
|
+
const reply = (await a.prompt(`${a.question} [Y/n]: `)).trim().toLowerCase();
|
|
8
|
+
return reply === '' ? 'y' : reply;
|
|
9
|
+
}
|
|
6
10
|
return (await a.prompt(`${a.question} [y/N]: `)).trim().toLowerCase();
|
|
7
11
|
}
|
|
8
12
|
/** Today's decision rule at all six sites. */
|
|
@@ -14,7 +14,9 @@ const DIFF_LINES = 6;
|
|
|
14
14
|
/** Myers diff is O(N·D) and synchronous: past this budget we show no diff
|
|
15
15
|
* at all (object + footer only), never a partial one. */
|
|
16
16
|
const DIFF_TIMEOUT_MS = 200;
|
|
17
|
-
|
|
17
|
+
// LT-7: a last line that only gains a newline (because lines follow it) is
|
|
18
|
+
// the same line, never a -/+ pair.
|
|
19
|
+
const defaultDiffer = (a, b) => diffLines(a, b, { timeout: DIFF_TIMEOUT_MS, ignoreNewlineAtEof: true });
|
|
18
20
|
// eslint-disable-next-line no-control-regex
|
|
19
21
|
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
20
22
|
const visibleLen = (s) => s.replace(ANSI, '').length;
|