@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
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage 1A, Task 1A.2 fix round 1 — the per-install key that proves a
|
|
3
|
+
* `<skill>` block in the history was written by this CLI.
|
|
4
|
+
*
|
|
5
|
+
* A content hash proves only that a block agrees with itself: anyone can
|
|
6
|
+
* compute sha256(body). So each block the loop writes also carries an
|
|
7
|
+
* HMAC-SHA256 tag keyed by a random 32-byte secret kept under the CSPeach
|
|
8
|
+
* home (`<home>/skill-block.key`, 0600 on POSIX, CSPEACH_HOME respected).
|
|
9
|
+
* Only a block whose tag verifies counts as loaded.
|
|
10
|
+
*
|
|
11
|
+
* - The key is created on the first WRITE of a block (`skillBlockKeyForWrite`),
|
|
12
|
+
* never by a read: a process that never runs inline creates no file.
|
|
13
|
+
* - A missing or unreadable key means nothing verifies: the body is simply
|
|
14
|
+
* sent again (cost only, never a failed turn).
|
|
15
|
+
* - A resumed session on the same install still verifies. On another machine
|
|
16
|
+
* (another key) every block reads as not loaded, is neutralised in place,
|
|
17
|
+
* and each body is sent once more.
|
|
18
|
+
*/
|
|
19
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { cspeachRoot } from '../config/paths.js';
|
|
23
|
+
const KEY_FILE = 'skill-block.key';
|
|
24
|
+
const KEY_HEX = /^[0-9a-f]{64}$/;
|
|
25
|
+
/** Keys found or made in this process, by the file they live in. */
|
|
26
|
+
const cache = new Map();
|
|
27
|
+
/** Test seam: a fixed key (Buffer), no key (null), or the real file (undefined). */
|
|
28
|
+
let override;
|
|
29
|
+
function keyPath() {
|
|
30
|
+
return path.join(cspeachRoot(), KEY_FILE);
|
|
31
|
+
}
|
|
32
|
+
function readKeyFile(p) {
|
|
33
|
+
try {
|
|
34
|
+
const hex = fs.readFileSync(p, 'utf-8').trim();
|
|
35
|
+
return KEY_HEX.test(hex) ? Buffer.from(hex, 'hex') : null;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** The key, if this install has one. Never creates it. */
|
|
42
|
+
export function skillBlockKeyForRead() {
|
|
43
|
+
if (override !== undefined)
|
|
44
|
+
return override;
|
|
45
|
+
const p = keyPath();
|
|
46
|
+
const hit = cache.get(p);
|
|
47
|
+
if (hit)
|
|
48
|
+
return hit;
|
|
49
|
+
const k = readKeyFile(p);
|
|
50
|
+
if (k)
|
|
51
|
+
cache.set(p, k);
|
|
52
|
+
return k;
|
|
53
|
+
}
|
|
54
|
+
function chmod600(p) {
|
|
55
|
+
if (process.platform === 'win32')
|
|
56
|
+
return;
|
|
57
|
+
try {
|
|
58
|
+
fs.chmodSync(p, 0o600);
|
|
59
|
+
}
|
|
60
|
+
catch { /* best effort */ }
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The key, made on first use. A key file that exists but is not a 32-byte hex
|
|
64
|
+
* key (corrupt, truncated) is REPLACED here, atomically (temp file + rename),
|
|
65
|
+
* with one debug line, so inline turns do not re-send bodies forever. Only
|
|
66
|
+
* this write path creates or replaces the file; reads never do. null when it
|
|
67
|
+
* cannot be written (the block then never verifies: cost only).
|
|
68
|
+
*/
|
|
69
|
+
export function skillBlockKeyForWrite() {
|
|
70
|
+
if (override !== undefined)
|
|
71
|
+
return override;
|
|
72
|
+
const existing = skillBlockKeyForRead();
|
|
73
|
+
if (existing)
|
|
74
|
+
return existing;
|
|
75
|
+
const p = keyPath();
|
|
76
|
+
const fresh = `${randomBytes(32).toString('hex')}\n`;
|
|
77
|
+
try {
|
|
78
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
79
|
+
if (!fs.existsSync(p)) {
|
|
80
|
+
// First use. 'wx': another process making it at the same time wins.
|
|
81
|
+
fs.writeFileSync(p, fresh, { encoding: 'utf-8', mode: 0o600, flag: 'wx' });
|
|
82
|
+
chmod600(p);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const tmp = `${p}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
86
|
+
try {
|
|
87
|
+
fs.writeFileSync(tmp, fresh, { encoding: 'utf-8', mode: 0o600 });
|
|
88
|
+
chmod600(tmp);
|
|
89
|
+
fs.renameSync(tmp, p);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
try {
|
|
93
|
+
fs.unlinkSync(tmp);
|
|
94
|
+
}
|
|
95
|
+
catch { /* renamed */ }
|
|
96
|
+
}
|
|
97
|
+
if (process.env['CSPEACH_DEBUG'] === '1') {
|
|
98
|
+
process.stderr.write(`[inline] the skill block key at ${p} was not readable; a new key replaced it (earlier blocks are sent again once)\n`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Another process made it first (EEXIST), or the home is not writable:
|
|
104
|
+
// read whatever is there now.
|
|
105
|
+
}
|
|
106
|
+
return skillBlockKeyForRead();
|
|
107
|
+
}
|
|
108
|
+
/** HMAC-SHA256 (hex) of the parts, joined with NUL. '' when there is no key. */
|
|
109
|
+
export function skillBlockTag(key, parts) {
|
|
110
|
+
if (!key)
|
|
111
|
+
return '';
|
|
112
|
+
return createHmac('sha256', key).update(parts.join('\0'), 'utf8').digest('hex');
|
|
113
|
+
}
|
|
114
|
+
/** Constant-time comparison of two hex tags; a length difference (or a missing tag) is simply false. */
|
|
115
|
+
export function skillBlockTagsEqual(a, b) {
|
|
116
|
+
const x = Buffer.from(a, 'hex');
|
|
117
|
+
const y = Buffer.from(b, 'hex');
|
|
118
|
+
if (x.length === 0 || x.length !== y.length)
|
|
119
|
+
return false;
|
|
120
|
+
return timingSafeEqual(x, y);
|
|
121
|
+
}
|
|
122
|
+
/** A short id of the key in use ('none' without one), so a saved scan is redone when the key changes. */
|
|
123
|
+
export function skillBlockKeyId(key) {
|
|
124
|
+
return key ? createHmac('sha256', key).update('id').digest('hex').slice(0, 16) : 'none';
|
|
125
|
+
}
|
|
126
|
+
/** Test seam. */
|
|
127
|
+
export function __setSkillBlockKeyForTests(k) {
|
|
128
|
+
override = k;
|
|
129
|
+
cache.clear();
|
|
130
|
+
}
|
|
131
|
+
/** Test seam: forget keys read in this process (the file stays). */
|
|
132
|
+
export function __clearSkillBlockKeyCache() {
|
|
133
|
+
cache.clear();
|
|
134
|
+
}
|
|
@@ -1,27 +1,63 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mid-turn steering queue (2026-06-08).
|
|
3
|
-
*
|
|
4
|
-
* While a turn is in flight the user can type a correction and press Enter;
|
|
5
|
-
* the Footer enqueues it here instead of bouncing it. The agent loop drains
|
|
6
|
-
* this at each round boundary and injects the text as a user message before
|
|
7
|
-
* the next createStream, so the model reads the correction and adapts —
|
|
8
|
-
* CC-style "type any time". Module-level singleton, mirroring the
|
|
9
|
-
* pendingDispatch accessor pattern; there is exactly one interactive REPL
|
|
10
|
-
* turn loop at a time.
|
|
11
|
-
*/
|
|
12
1
|
const queue = [];
|
|
2
|
+
const listeners = new Set();
|
|
3
|
+
/** Tell every listener the queue changed. A listener that throws is ignored. */
|
|
4
|
+
function notify() {
|
|
5
|
+
if (listeners.size === 0)
|
|
6
|
+
return;
|
|
7
|
+
const snap = steeringSnapshot();
|
|
8
|
+
for (const fn of [...listeners]) {
|
|
9
|
+
try {
|
|
10
|
+
fn(snap);
|
|
11
|
+
}
|
|
12
|
+
catch { /* a UI listener must never break the queue */ }
|
|
13
|
+
}
|
|
14
|
+
}
|
|
13
15
|
/** Add a steer. Trims; ignores empty / whitespace-only input. */
|
|
14
|
-
export function enqueueSteering(text) {
|
|
16
|
+
export function enqueueSteering(text, opts) {
|
|
15
17
|
const t = text.trim();
|
|
16
18
|
if (t.length === 0)
|
|
17
19
|
return;
|
|
18
|
-
queue.push(t);
|
|
20
|
+
queue.push({ text: t, alreadyRead: opts?.alreadyRead === true });
|
|
21
|
+
notify();
|
|
19
22
|
}
|
|
20
23
|
/** Return all queued steers (FIFO) and clear the queue. */
|
|
21
24
|
export function drainSteering() {
|
|
22
|
-
return
|
|
25
|
+
return drainSteeringEntries().map((e) => e.text);
|
|
26
|
+
}
|
|
27
|
+
/** LT-15 Task 4 — drain with each message's already-read mark (the loop's round top). */
|
|
28
|
+
export function drainSteeringEntries() {
|
|
29
|
+
const out = queue.splice(0, queue.length);
|
|
30
|
+
if (out.length > 0)
|
|
31
|
+
notify();
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/** LT-15 Task 4 — drain every waiting message as one text (joined with newlines), or null. */
|
|
35
|
+
export function drainSteeringText() {
|
|
36
|
+
const all = drainSteering();
|
|
37
|
+
return all.length > 0 ? all.join('\n') : null;
|
|
23
38
|
}
|
|
24
39
|
/** True when at least one steer is queued. */
|
|
25
40
|
export function hasSteering() {
|
|
26
41
|
return queue.length > 0;
|
|
27
42
|
}
|
|
43
|
+
/** A copy of the waiting messages (FIFO). Changing it never changes the queue. */
|
|
44
|
+
export function steeringSnapshot() {
|
|
45
|
+
return queue.map((e) => e.text);
|
|
46
|
+
}
|
|
47
|
+
/** Take back the LAST waiting message, or null when nothing waits. */
|
|
48
|
+
export function withdrawLastSteering() {
|
|
49
|
+
if (queue.length === 0)
|
|
50
|
+
return null;
|
|
51
|
+
const t = queue.pop();
|
|
52
|
+
notify();
|
|
53
|
+
return t.text;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Call `fn` with a fresh snapshot after every change (enqueue, drain,
|
|
57
|
+
* withdraw). Not called for a change that changed nothing. Returns the
|
|
58
|
+
* unsubscribe function.
|
|
59
|
+
*/
|
|
60
|
+
export function subscribeSteering(fn) {
|
|
61
|
+
listeners.add(fn);
|
|
62
|
+
return () => { listeners.delete(fn); };
|
|
63
|
+
}
|
|
@@ -12,8 +12,15 @@ import { READER_SKILL, READER_SYSTEM_PROMPT } from '../tools/subagent/reader-pro
|
|
|
12
12
|
import { CHAT_SKILL, CHAT_SYSTEM_PROMPT } from './chat-prompt.js';
|
|
13
13
|
import { appendStyleText, DEFAULT_STYLE } from './style.js';
|
|
14
14
|
import { appendLevelText, DEFAULT_LEVEL } from './level.js';
|
|
15
|
+
import { CONVERSATION_SYSTEM_PROMPT } from './conversation-prompt.js';
|
|
15
16
|
/** 2026-10-04 (continuous run) — the phase's delegate skill travels as this header. */
|
|
16
17
|
export const DELEGATE_SKILL_HEADER = 'X-CSPeach-Delegate-Skill';
|
|
18
|
+
/**
|
|
19
|
+
* Stage 1A, Task 1A.2 — '1' on a main call of an inline session: the system
|
|
20
|
+
* block is the one conversation prompt and the playbook rides in the
|
|
21
|
+
* conversation as a <skill> block (agent/inline-skill-block.ts).
|
|
22
|
+
*/
|
|
23
|
+
export const SKILL_INLINE_HEADER = 'X-CSPeach-Skill-Inline';
|
|
17
24
|
/** The line that tells the model how the two bodies relate. Same text in cspeach-proxy. */
|
|
18
25
|
export function delegatePreface(name) {
|
|
19
26
|
return `This plan step is delegated to the ${name} playbook below. Follow it for HOW to build. `
|
|
@@ -54,6 +61,14 @@ export function buildChatSystemPrompt(opts, style = DEFAULT_STYLE, level = DEFAU
|
|
|
54
61
|
// D-K order: style, then level, one block.
|
|
55
62
|
return appendLevelText(appendStyleText([{ type: 'text', text: CHAT_SYSTEM_PROMPT, ...marker }], style), level);
|
|
56
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Stage 1A — the inline session's one system block: the conversation prompt,
|
|
66
|
+
* then style, then level, in one block (the same order as the chat prompt).
|
|
67
|
+
*/
|
|
68
|
+
export function buildConversationSystemPrompt(opts, style = DEFAULT_STYLE, level = DEFAULT_LEVEL) {
|
|
69
|
+
const marker = opts.cacheMarkers ? { cache_control: { type: 'ephemeral' } } : {};
|
|
70
|
+
return appendLevelText(appendStyleText([{ type: 'text', text: CONVERSATION_SYSTEM_PROMPT, ...marker }], style), level);
|
|
71
|
+
}
|
|
57
72
|
/**
|
|
58
73
|
* The system prompt for the skill named in the X-CSForge-Skill header, plus —
|
|
59
74
|
* on a plan phase turn — the delegate playbook named in X-CSPeach-Delegate-Skill.
|
|
@@ -65,10 +80,17 @@ export function buildChatSystemPrompt(opts, style = DEFAULT_STYLE, level = DEFAU
|
|
|
65
80
|
* reader (which reports facts) never gets style. A non-default `level`
|
|
66
81
|
* (§10 D14) follows the style text; developer changes nothing; a reader never
|
|
67
82
|
* gets it.
|
|
83
|
+
*
|
|
84
|
+
* Stage 1A — `opts.inline` (the call carried X-CSPeach-Skill-Inline): one
|
|
85
|
+
* block, the conversation prompt, plus style and level, for every skill name
|
|
86
|
+
* but `_reader`. No body is fetched (the loop put it in the conversation) and
|
|
87
|
+
* the delegate is not composed. Absent or false: today's three cases.
|
|
68
88
|
*/
|
|
69
89
|
export async function systemPromptForSkill(skillName, skillSource, opts, delegateName, style = DEFAULT_STYLE, level = DEFAULT_LEVEL) {
|
|
70
90
|
if (skillName === READER_SKILL)
|
|
71
91
|
return buildReaderSystemPrompt(opts);
|
|
92
|
+
if (opts.inline === true)
|
|
93
|
+
return buildConversationSystemPrompt(opts, style, level);
|
|
72
94
|
if (skillName === CHAT_SKILL)
|
|
73
95
|
return buildChatSystemPrompt(opts, style, level);
|
|
74
96
|
const skill = await skillSource.getSkillBody(skillName);
|
|
@@ -2,7 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import { getTool, isStandalone } from '../tools/index.js';
|
|
3
3
|
import { NOT_CONNECTED_MESSAGE } from '../sap/offline-adt-client.js';
|
|
4
4
|
import { unreachableCode } from '../sap/unreachable.js';
|
|
5
|
-
import { noteSapUnreachable, unreachableCodeInText, SAP_UNREACHABLE_DIRECTIVE } from '../sap/reachability-state.js';
|
|
5
|
+
import { noteSapUnreachable, unreachableCodeInText, SAP_UNREACHABLE_DIRECTIVE, SAP_UNREACHABLE_NOTE } from '../sap/reachability-state.js';
|
|
6
6
|
import { isPlanPhaseActive } from '../commands/plan-gate.js';
|
|
7
7
|
import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
|
|
8
8
|
import { shouldGateRule8, recordWriteOp, renderPlanSummary, getWriteOpsThisTurn, } from '../repl/rule8-detector.js';
|
|
@@ -10,7 +10,7 @@ import { isCredentialHandoverApproved, markCredentialHandoverApproved, } from '.
|
|
|
10
10
|
import { READER_TOOLS } from '../tools/subagent/reader-prompt.js';
|
|
11
11
|
import { riskOf } from './risk-gate.js';
|
|
12
12
|
import { NOT_ON_THIS_RELEASE, gateWithVerify } from '../sap/profile-gate.js';
|
|
13
|
-
import { INTERRUPTED_TOOL_RESULT } from './repair-partial.js';
|
|
13
|
+
import { INTERRUPTED_TOOL_RESULT, STOPPED_BEFORE_START_RESULT } from './repair-partial.js';
|
|
14
14
|
import { WRITE_TOOLS } from '../tools/write-mode.js';
|
|
15
15
|
const READER_TOOL_SET = new Set(READER_TOOLS);
|
|
16
16
|
/** Chat lane (spec 2026-10-17 D4) — the model-facing reason; names hand_off. */
|
|
@@ -133,6 +133,11 @@ export async function dispatchTool(name, args, ctx) {
|
|
|
133
133
|
is_error: true,
|
|
134
134
|
};
|
|
135
135
|
}
|
|
136
|
+
// LT-15 D6 (review M1) — the handover check above can await file reads; a
|
|
137
|
+
// stop there opens no handover, risky-command or Rule 8 card, and the
|
|
138
|
+
// handler never runs.
|
|
139
|
+
if (ctx.signal?.aborted)
|
|
140
|
+
return { content: STOPPED_BEFORE_START_RESULT, is_error: true };
|
|
136
141
|
const sessionId = ctx.session?.id;
|
|
137
142
|
if (handover && !isCredentialHandoverApproved(sessionId, handover.alias, handover.scope)) {
|
|
138
143
|
const r = await presentSafetyConfirmation({
|
|
@@ -210,22 +215,29 @@ export async function dispatchTool(name, args, ctx) {
|
|
|
210
215
|
// 2026-10-04 (continuous run, spec §3.5) — a SAP tool that failed because
|
|
211
216
|
// SAP is not reachable from this machine: note it (the REPL pauses the
|
|
212
217
|
// plan after the turn) and tell the model to stop without a manifest.
|
|
213
|
-
// Only inside a plan step
|
|
214
|
-
|
|
218
|
+
// Only inside a plan step. LT-14 (1A fix wave): elsewhere a short note
|
|
219
|
+
// rides on the error (no pause, no flag), so the model never invents
|
|
220
|
+
// network advice for the person.
|
|
221
|
+
if (tool.requiresSap && (result.is_error ?? false)) {
|
|
215
222
|
const code = unreachableCodeInText(result.content);
|
|
216
|
-
if (code) {
|
|
223
|
+
if (code && isPlanPhaseActive()) {
|
|
217
224
|
noteSapUnreachable(code);
|
|
218
225
|
return { content: `${result.content}\n\n${SAP_UNREACHABLE_DIRECTIVE}`, is_error: true };
|
|
219
226
|
}
|
|
227
|
+
if (code)
|
|
228
|
+
return { content: `${result.content}\n\n${SAP_UNREACHABLE_NOTE}`, is_error: true };
|
|
220
229
|
}
|
|
221
230
|
return { content: result.content, is_error: result.is_error ?? false };
|
|
222
231
|
}
|
|
223
232
|
catch (err) {
|
|
224
|
-
const code = tool.requiresSap
|
|
225
|
-
if (code) {
|
|
233
|
+
const code = tool.requiresSap ? (unreachableCode(err) ?? unreachableCodeInText(String(err))) : null;
|
|
234
|
+
if (code && isPlanPhaseActive()) {
|
|
226
235
|
noteSapUnreachable(code);
|
|
227
236
|
return { content: JSON.stringify({ error: String(err), sap_unreachable: true, directive: SAP_UNREACHABLE_DIRECTIVE }), is_error: true };
|
|
228
237
|
}
|
|
238
|
+
// LT-14 — outside a plan step: the short note only.
|
|
239
|
+
if (code)
|
|
240
|
+
return { content: JSON.stringify({ error: String(err), note: SAP_UNREACHABLE_NOTE }), is_error: true };
|
|
229
241
|
return { content: JSON.stringify({ error: String(err) }), is_error: true };
|
|
230
242
|
}
|
|
231
243
|
}
|
|
@@ -42,7 +42,7 @@ export function evaluateWatchdog(input, now = Date.now()) {
|
|
|
42
42
|
if (!input.config.enabled || input.alreadyWarned) {
|
|
43
43
|
return { shouldWarn: false, reason: '', message: '' };
|
|
44
44
|
}
|
|
45
|
-
const elapsedMs = Math.max(0, now - input.startedAt);
|
|
45
|
+
const elapsedMs = Math.max(0, now - input.startedAt - Math.max(0, input.pausedMs ?? 0));
|
|
46
46
|
const wallExceeded = elapsedMs >= input.config.wallBudgetMs;
|
|
47
47
|
const tokensExceeded = input.outputTokensThisTurn >= input.config.tokenBudget;
|
|
48
48
|
if (!wallExceeded && !tokensExceeded) {
|
|
@@ -2,6 +2,7 @@ import chalk from 'chalk';
|
|
|
2
2
|
import { paint } from '../renderer/theme.js';
|
|
3
3
|
import { uiWidth } from '../renderer/ui-width.js';
|
|
4
4
|
import { sanitizeControlChars as clean } from '../renderer/sanitize.js';
|
|
5
|
+
import { getBoxChars, DOUBLE_BOX } from '../ui/ascii-chars.js';
|
|
5
6
|
const ADVISORY_TITLE = ' ADVISORY MODE — proposed change (no SAP write will occur) ';
|
|
6
7
|
export const ADVISORY_KEY_HINT = 'Press c to copy to clipboard, y when applied, s to skip, n to abort.';
|
|
7
8
|
export function renderAdvisoryProposal(args, opts = {}) {
|
|
@@ -12,9 +13,11 @@ export function renderAdvisoryProposal(args, opts = {}) {
|
|
|
12
13
|
// Borders line up: top and bottom share one width (§1.4 rule, widened
|
|
13
14
|
// only if the title itself would not fit).
|
|
14
15
|
const w = Math.max(uiWidth(), ADVISORY_TITLE.length + 6);
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
const
|
|
16
|
+
// Old Windows console: the classic ASCII frame (getBoxChars), same width.
|
|
17
|
+
const b = getBoxChars(DOUBLE_BOX);
|
|
18
|
+
const top = b.tl + b.h + b.h + ADVISORY_TITLE + b.h.repeat(w - 4 - ADVISORY_TITLE.length) + b.tr;
|
|
19
|
+
const bottom = b.bl + b.h.repeat(w - 2) + b.br;
|
|
20
|
+
const rule = paint('dim', getBoxChars().h.repeat(w));
|
|
18
21
|
displayParts.push(chalk.bold(paint('warn', top)));
|
|
19
22
|
displayParts.push(chalk.bold(`Summary: ${clean(args.summary)}`));
|
|
20
23
|
displayParts.push(`Risk: ${args.risk}`);
|
|
@@ -6,14 +6,19 @@ import { uiWidth } from '../renderer/ui-width.js';
|
|
|
6
6
|
import { paint } from '../renderer/theme.js';
|
|
7
7
|
import { emitTrace } from '../renderer/trace.js';
|
|
8
8
|
import { askConfirm } from '../ui/confirm-request.js';
|
|
9
|
+
import { getBoxChars, SQUARE_BOX } from '../ui/ascii-chars.js';
|
|
9
10
|
// Classic box: width from the §1.4 rule (was a fixed 62); padding counts
|
|
10
11
|
// VISIBLE cells (string-width ignores ANSI) so coloured rows line up with
|
|
11
12
|
// plain ones. (strip-ansi is a devDependency only — not usable here.)
|
|
13
|
+
// Old Windows console: the classic ASCII frame (getBoxChars), same width.
|
|
12
14
|
const boxWidth = () => uiWidth() - 2;
|
|
13
|
-
const top = () =>
|
|
14
|
-
const bottom = () =>
|
|
15
|
-
const pad = (s) =>
|
|
16
|
-
const
|
|
15
|
+
const top = () => { const b = getBoxChars(SQUARE_BOX); return b.tl + b.h.repeat(boxWidth()) + b.tr; };
|
|
16
|
+
const bottom = () => { const b = getBoxChars(SQUARE_BOX); return b.bl + b.h.repeat(boxWidth()) + b.br; };
|
|
17
|
+
const pad = (s) => {
|
|
18
|
+
const b = getBoxChars(SQUARE_BOX);
|
|
19
|
+
return b.v + ' ' + s + ' '.repeat(Math.max(0, boxWidth() - 2 - stringWidth(s))) + b.v;
|
|
20
|
+
};
|
|
21
|
+
const blank = () => { const b = getBoxChars(SQUARE_BOX); return b.v + ' '.repeat(boxWidth()) + b.v; };
|
|
17
22
|
/**
|
|
18
23
|
* Capture a single keypress for the y/n/d answer. Raw-mode readline so the
|
|
19
24
|
* hand-drawn box stays the only visible affordance.
|
|
@@ -71,6 +71,20 @@ export function humanType(type) {
|
|
|
71
71
|
return type;
|
|
72
72
|
return type.toLowerCase();
|
|
73
73
|
}
|
|
74
|
+
/** LT-10 (1A fix wave) — the word for a known type code ('CLAS/OC' -> 'class'); undefined otherwise. */
|
|
75
|
+
export function knownTypeWord(type) {
|
|
76
|
+
return Object.prototype.hasOwnProperty.call(TYPE_WORDS, typeCode(type)) ? TYPE_WORDS[typeCode(type)] : undefined;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* LT-11 (1A fix wave) — the types that share their view's name on SAP's
|
|
80
|
+
* generator path. A write to one names its kind ("access control ZR_X"), so
|
|
81
|
+
* it never reads as a write to the view. Every other type: the name alone.
|
|
82
|
+
*/
|
|
83
|
+
const SHARES_VIEW_NAME = new Set(['DDLX', 'DCLS', 'BDEF']);
|
|
84
|
+
export function writtenObjectName(type, name) {
|
|
85
|
+
const code = typeCode(type);
|
|
86
|
+
return SHARES_VIEW_NAME.has(code) ? `${TYPE_WORDS[code]} ${name}` : name;
|
|
87
|
+
}
|
|
74
88
|
/** e.g. "Create table ZMNT_NOTE", "Change class ZCL_X", "Delete table ZOLD". */
|
|
75
89
|
export function verbLine(change) {
|
|
76
90
|
return [humanVerb(change.op), humanType(change.type), change.object]
|
|
@@ -103,6 +103,14 @@ export function getPlanGrant() { return active; }
|
|
|
103
103
|
export function isPlanGrantInScope() {
|
|
104
104
|
return active !== null && approvalsMode === 'plan' && inScope(active);
|
|
105
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* 2026-10-23 — the grant of THIS plan is in force: installed, not expired, and
|
|
108
|
+
* approvals = plan. The same test as isPlanGrantInScope without "a step of the
|
|
109
|
+
* plan is running": the step prompt is built just before its step starts.
|
|
110
|
+
*/
|
|
111
|
+
export function isPlanGrantInForceFor(planId) {
|
|
112
|
+
return active !== null && active.planId === planId && approvalsMode === 'plan' && isLive(active);
|
|
113
|
+
}
|
|
106
114
|
/**
|
|
107
115
|
* The claims a grant-minted id carries for one covered change: the listed
|
|
108
116
|
* type, the listed row's package, and the bound transport.
|
package/dist/approvals/render.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { select, input } from '@inquirer/prompts';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import {
|
|
3
|
+
import { riskReasonTexts, shownRiskReasons } from './risk-floor.js';
|
|
4
4
|
import { verbLine, viewAffordance, toSourceLines, affectsLine, releaseLines, behaviourText, behaviourLines } from './op-labels.js';
|
|
5
5
|
import { uiWidth } from '../renderer/ui-width.js';
|
|
6
6
|
import { shouldUseInk } from '../renderer/tty.js';
|
|
@@ -12,7 +12,7 @@ import { emitTrace } from '../renderer/trace.js';
|
|
|
12
12
|
import { getGlyphs } from '../renderer/glyphs.js';
|
|
13
13
|
import { paint } from '../renderer/theme.js';
|
|
14
14
|
import { sanitizeControlChars } from '../renderer/sanitize.js';
|
|
15
|
-
import {
|
|
15
|
+
import { planRows, looksLikeUnifiedDiff } from './visible-changes.js';
|
|
16
16
|
import { withSpinnerSuspended } from '../renderer/tool-widget.js';
|
|
17
17
|
// Piece 2 §1.1 — the brand accent comes from the theme role, not a legacy hex.
|
|
18
18
|
const peach = (s) => paint('brand', s);
|
|
@@ -75,11 +75,21 @@ function colorizeDiff(diff) {
|
|
|
75
75
|
* compressed. This is what fixes the "what 2 changes?" complaint when the
|
|
76
76
|
* model declares modify+activate of the same object.
|
|
77
77
|
*/
|
|
78
|
-
// collapseImplicitActivates
|
|
78
|
+
// collapseImplicitActivates and planRows (LT-12) live in ./visible-changes.ts (Task 9: request_approval uses the first for its gate count).
|
|
79
|
+
/**
|
|
80
|
+
* LT-12 fix round 1 — the apply choice. One shown row (a create + modify of
|
|
81
|
+
* one object, the gate still opens on the raw count) reads "Apply this
|
|
82
|
+
* change", never "Apply all 1 change".
|
|
83
|
+
*/
|
|
84
|
+
function applyAllLabel(visibleCount, noun) {
|
|
85
|
+
return visibleCount === 1 ? 'Apply this change' : `Apply all ${visibleCount} ${noun}`;
|
|
86
|
+
}
|
|
79
87
|
export async function renderPlanGate(summary, changes, transport, effectiveRisk, system) {
|
|
80
|
-
|
|
88
|
+
// LT-12: one row per new object (display only; the changes behind the card are untouched).
|
|
89
|
+
const visibleChanges = planRows(changes);
|
|
81
90
|
const visibleCount = visibleChanges.length;
|
|
82
91
|
const noun = visibleCount === 1 ? 'change' : 'changes';
|
|
92
|
+
const applyLabel = applyAllLabel(visibleCount, noun);
|
|
83
93
|
// Ink path (piece 2 §5): the plan gate is an APPROVAL confirm card in the
|
|
84
94
|
// card slot. Same three values, same order, cursor on Apply all; anything
|
|
85
95
|
// else (Esc, no listener) is 'cancel'. Digits pick on an APPROVAL card
|
|
@@ -102,7 +112,7 @@ export async function renderPlanGate(summary, changes, transport, effectiveRisk,
|
|
|
102
112
|
];
|
|
103
113
|
const shownReasons = shownRiskReasons(effectiveRisk.raisedBy);
|
|
104
114
|
if (shownReasons.length > 0) {
|
|
105
|
-
facts.push(`Risk raised to ${effectiveRisk.level}: ${
|
|
115
|
+
facts.push(`Risk raised to ${effectiveRisk.level}: ${riskReasonTexts(effectiveRisk).join('; ')}`);
|
|
106
116
|
}
|
|
107
117
|
// Part 2b D7 — a step that names the process it runs in (a BAdI
|
|
108
118
|
// implementation) carries its "Affects:" row right under it. The card's
|
|
@@ -158,7 +168,7 @@ export async function renderPlanGate(summary, changes, transport, effectiveRisk,
|
|
|
158
168
|
title: system ? `Apply this plan on ${system}?` : 'Apply this plan?',
|
|
159
169
|
facts,
|
|
160
170
|
choices: [
|
|
161
|
-
{ value: 'apply_all', label:
|
|
171
|
+
{ value: 'apply_all', label: applyLabel },
|
|
162
172
|
{ value: 'review', label: 'Review each change one by one' },
|
|
163
173
|
{ value: 'cancel', label: 'Cancel' },
|
|
164
174
|
],
|
|
@@ -178,6 +188,7 @@ export async function renderPlanGate(summary, changes, transport, effectiveRisk,
|
|
|
178
188
|
async function classicPlanGate(summary, visibleChanges, transport, effectiveRisk) {
|
|
179
189
|
const visibleCount = visibleChanges.length;
|
|
180
190
|
const noun = visibleCount === 1 ? 'change' : 'changes';
|
|
191
|
+
const applyLabel = applyAllLabel(visibleCount, noun);
|
|
181
192
|
console.log('');
|
|
182
193
|
console.log(`${peach('Plan')} ${summary}`);
|
|
183
194
|
const meta = [`${visibleCount} ${noun}`, riskTag(effectiveRisk.level)];
|
|
@@ -186,7 +197,7 @@ async function classicPlanGate(summary, visibleChanges, transport, effectiveRisk
|
|
|
186
197
|
console.log(` ${chalk.dim(meta.join(' · '))}`);
|
|
187
198
|
const shownReasons = shownRiskReasons(effectiveRisk.raisedBy);
|
|
188
199
|
if (shownReasons.length > 0) {
|
|
189
|
-
console.log(` ${chalk.dim(`(risk raised: ${
|
|
200
|
+
console.log(` ${chalk.dim(`(risk raised: ${riskReasonTexts(effectiveRisk).join('; ')})`)}`);
|
|
190
201
|
}
|
|
191
202
|
visibleChanges.forEach((c, i) => {
|
|
192
203
|
console.log(` ${chalk.dim(`${i + 1}.`)} ${verbLine(c)}`);
|
|
@@ -207,7 +218,7 @@ async function classicPlanGate(summary, visibleChanges, transport, effectiveRisk
|
|
|
207
218
|
default: 'apply_all',
|
|
208
219
|
theme: inquirerTheme,
|
|
209
220
|
choices: [
|
|
210
|
-
{ value: 'apply_all', name:
|
|
221
|
+
{ value: 'apply_all', name: applyLabel },
|
|
211
222
|
{ value: 'review', name: 'Review each change individually' },
|
|
212
223
|
{ value: 'cancel', name: 'Cancel' },
|
|
213
224
|
],
|
|
@@ -100,8 +100,13 @@ export function signatureChange(diff) {
|
|
|
100
100
|
return false;
|
|
101
101
|
let state = 'unknown';
|
|
102
102
|
for (const raw of diff.split('\n')) {
|
|
103
|
-
|
|
103
|
+
// LT-10 (1A fix wave) — a file header starts another file (a diff can
|
|
104
|
+
// cover several includes): a declaration still open in the file before
|
|
105
|
+
// it never carries over.
|
|
106
|
+
if (raw.startsWith('+++') || raw.startsWith('---')) {
|
|
107
|
+
state = 'unknown';
|
|
104
108
|
continue;
|
|
109
|
+
}
|
|
105
110
|
if (raw.startsWith('@@')) {
|
|
106
111
|
// Task 7 (R2) / fix round 1 — a header naming the block
|
|
107
112
|
// (`@@ METHOD to_internal. @@`) hints that the hunk starts in its body.
|
|
@@ -157,6 +162,7 @@ export function effectiveRisk(declared, changes, ctx) {
|
|
|
157
162
|
// a body-only change read "may alter a method signature" when activation
|
|
158
163
|
// was the true reason.
|
|
159
164
|
const applied = [];
|
|
165
|
+
let syntaxObjectWord;
|
|
160
166
|
const raise = (r, label, always = false) => {
|
|
161
167
|
applied.push({ r, label, always });
|
|
162
168
|
};
|
|
@@ -176,6 +182,16 @@ export function effectiveRisk(declared, changes, ctx) {
|
|
|
176
182
|
// Final review A M-4 — the reason is always shown, even when the floor
|
|
177
183
|
// was already high (policy never, or declared high).
|
|
178
184
|
raise('high', 'syntax_errors', true);
|
|
185
|
+
// LT-10 — "this class" / "this program" from the RECORDED keys' types
|
|
186
|
+
// for the hit names (the match is by name, so the row's type may not be
|
|
187
|
+
// what was checked). Only when they all agree on one known word.
|
|
188
|
+
const hitNames = new Set(changes.filter(hit).map((c) => c.object.trim().split(/\s+/)[0].toUpperCase()));
|
|
189
|
+
const words = new Set(ctx.syntaxErrorObjects
|
|
190
|
+
.filter((k) => hitNames.has(k.slice(k.lastIndexOf(':') + 1).toUpperCase()))
|
|
191
|
+
.map((k) => ctx.typeWord?.(k.slice(0, k.lastIndexOf(':')))));
|
|
192
|
+
const [only] = [...words];
|
|
193
|
+
if (words.size === 1 && only)
|
|
194
|
+
syntaxObjectWord = only;
|
|
179
195
|
}
|
|
180
196
|
}
|
|
181
197
|
// Part 2b D7 — a BAdI implementation runs inside a standard SAP process:
|
|
@@ -231,7 +247,7 @@ export function effectiveRisk(declared, changes, ctx) {
|
|
|
231
247
|
raisedBy.splice(act, 1);
|
|
232
248
|
raisedBy.splice(sig, 0, 'activation');
|
|
233
249
|
}
|
|
234
|
-
return { level: floor, raisedBy };
|
|
250
|
+
return syntaxObjectWord ? { level: floor, raisedBy, syntaxObjectWord } : { level: floor, raisedBy };
|
|
235
251
|
}
|
|
236
252
|
/** Plain words for each risk reason code — the codes stay in audit data only. */
|
|
237
253
|
const RISK_REASON_TEXT = {
|
|
@@ -252,15 +268,17 @@ const RISK_REASON_TEXT = {
|
|
|
252
268
|
* Task 7 (R2) — one sentence on every request_approval result (and once in
|
|
253
269
|
* the journey fix block): the risk line is CSPeach's reading of the diff, so
|
|
254
270
|
* the model relays it rather than telling the person it is wrong.
|
|
271
|
+
* LT-6 (1A fix wave): the card already shows it, so the model neither repeats
|
|
272
|
+
* nor explains it unless asked (live: "rated medium only because…").
|
|
255
273
|
*/
|
|
256
|
-
export const RISK_NOTE = "The risk line is CSPeach's reading of the diff
|
|
274
|
+
export const RISK_NOTE = "The risk line is CSPeach's reading of the diff. Do not repeat, explain or dispute it unless the person asks.";
|
|
257
275
|
/**
|
|
258
276
|
* Task 7 (R2) — the risk fields of every request_approval result:
|
|
259
277
|
* effective_risk and raised_by as before (older readers), plus risk { level,
|
|
260
278
|
* reasons } in plain words and the note.
|
|
261
279
|
*/
|
|
262
280
|
export function riskResultFields(eff) {
|
|
263
|
-
const reasons = eff.raisedBy.map(
|
|
281
|
+
const reasons = eff.raisedBy.map((c) => reasonTextFor(c, eff)); // round 8 item 4: plain words, never codes
|
|
264
282
|
return { effective_risk: eff.level, raised_by: reasons, risk: { level: eff.level, reasons: [...reasons] }, note: RISK_NOTE };
|
|
265
283
|
}
|
|
266
284
|
/** Round 8 item 4 — every code the floor can raise (each has plain words above). */
|
|
@@ -279,3 +297,13 @@ const NOT_A_RISK_REASON = new Set(['non_prod_override']);
|
|
|
279
297
|
export function shownRiskReasons(raisedBy) {
|
|
280
298
|
return raisedBy.filter((c) => !NOT_A_RISK_REASON.has(c));
|
|
281
299
|
}
|
|
300
|
+
/** LT-10 — one reason in plain words; the syntax-errors one names the kind of object when known. */
|
|
301
|
+
function reasonTextFor(code, eff) {
|
|
302
|
+
if (code === 'syntax_errors' && eff.syntaxObjectWord)
|
|
303
|
+
return `the last syntax check of this ${eff.syntaxObjectWord} found errors`;
|
|
304
|
+
return riskReasonText(code);
|
|
305
|
+
}
|
|
306
|
+
/** LT-10 — the shown reasons of a result, in plain words (every approval surface). */
|
|
307
|
+
export function riskReasonTexts(eff) {
|
|
308
|
+
return shownRiskReasons(eff.raisedBy).map((c) => reasonTextFor(c, eff));
|
|
309
|
+
}
|
|
@@ -21,6 +21,56 @@ export function collapseImplicitActivates(changes) {
|
|
|
21
21
|
return !writeKeys.has(`${c.type}:${c.object}`);
|
|
22
22
|
});
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* LT-12 (1A fix wave) — the rows a plan list shows. On top of the collapse
|
|
26
|
+
* above, a `modify` of an object that also has a `create` in the list is
|
|
27
|
+
* hidden: the create already covers its source write and activation
|
|
28
|
+
* (approvals/jwt.ts OP_COVERAGE), so "Create X" + "Change X" read as two
|
|
29
|
+
* changes for one new object. Fix round 1: only a modify with no diff of its
|
|
30
|
+
* own, or the same diff as the create, is hidden; a modify carrying other
|
|
31
|
+
* content stays, so the person sees it before "Apply all". When the create
|
|
32
|
+
* row has no behaviour (or affects) and a hidden modify has one, the row
|
|
33
|
+
* shows the modify's — a display copy, never the caller's object. Display
|
|
34
|
+
* only: the `changes[]` behind the card (approval ids, grants) and the gate
|
|
35
|
+
* count (collapseImplicitActivates) are untouched.
|
|
36
|
+
*/
|
|
37
|
+
export function planRows(changes) {
|
|
38
|
+
const rows = collapseImplicitActivates(changes);
|
|
39
|
+
const createOf = new Map();
|
|
40
|
+
for (const c of rows)
|
|
41
|
+
if (c.op === 'create' && !createOf.has(`${c.type}:${c.object}`))
|
|
42
|
+
createOf.set(`${c.type}:${c.object}`, c);
|
|
43
|
+
const hidden = new Set();
|
|
44
|
+
const hiddenOf = new Map();
|
|
45
|
+
for (const c of rows) {
|
|
46
|
+
if (c.op !== 'modify')
|
|
47
|
+
continue;
|
|
48
|
+
const key = `${c.type}:${c.object}`;
|
|
49
|
+
const cr = createOf.get(key);
|
|
50
|
+
if (cr && (!c.diff || c.diff === cr.diff)) {
|
|
51
|
+
hidden.add(c);
|
|
52
|
+
if (!hiddenOf.has(key))
|
|
53
|
+
hiddenOf.set(key, c);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const c of rows) {
|
|
58
|
+
if (hidden.has(c))
|
|
59
|
+
continue;
|
|
60
|
+
const m = c.op === 'create' ? hiddenOf.get(`${c.type}:${c.object}`) : undefined;
|
|
61
|
+
if (!m) {
|
|
62
|
+
out.push(c);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const row = { ...c };
|
|
66
|
+
if (row.behaviour === undefined && m.behaviour !== undefined)
|
|
67
|
+
row.behaviour = m.behaviour;
|
|
68
|
+
if (row.affects === undefined && m.affects !== undefined)
|
|
69
|
+
row.affects = m.affects;
|
|
70
|
+
out.push(row);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
24
74
|
/**
|
|
25
75
|
* Heuristic: a real unified diff has at least one line starting with `+`,
|
|
26
76
|
* `-`, or `@@`. A summary or change description won't. Used to gate
|