@cspeach/cli 0.6.0
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/LICENSE +8 -0
- package/README.md +108 -0
- package/dist/agent/anthropic-provider.js +59 -0
- package/dist/agent/llm-provider.js +1 -0
- package/dist/agent/loop.js +709 -0
- package/dist/agent/maybe-build-project-context.js +126 -0
- package/dist/agent/providers/ai-hub-provider.js +58 -0
- package/dist/agent/providers/byok-provider.js +53 -0
- package/dist/agent/providers/factory.js +13 -0
- package/dist/agent/providers/local-provider.js +125 -0
- package/dist/agent/repair-partial.js +31 -0
- package/dist/agent/retry-key.js +58 -0
- package/dist/agent/retry.js +17 -0
- package/dist/agent/sap-connection-adapter.js +82 -0
- package/dist/agent/skill-checkpoint.js +119 -0
- package/dist/agent/tool-dispatch.js +47 -0
- package/dist/agent/turn-assistant-text.js +49 -0
- package/dist/agent/turn-error-ux.js +126 -0
- package/dist/agent/turn-stream.js +79 -0
- package/dist/agent/turn-watchdog.js +71 -0
- package/dist/approvals/advisory-prompt.js +40 -0
- package/dist/approvals/advisory-render.js +38 -0
- package/dist/approvals/approval-prompt.js +100 -0
- package/dist/approvals/jwt.js +33 -0
- package/dist/approvals/render.js +211 -0
- package/dist/approvals/risk-floor.js +26 -0
- package/dist/auth/api-key.js +40 -0
- package/dist/auth/auth-file.js +59 -0
- package/dist/auth/device.js +8 -0
- package/dist/auth/me.js +19 -0
- package/dist/classifier/client.js +58 -0
- package/dist/cli-args.js +38 -0
- package/dist/cli.js +148 -0
- package/dist/commands/config-set.js +245 -0
- package/dist/commands/config-show.js +159 -0
- package/dist/commands/help.js +93 -0
- package/dist/commands/login.js +122 -0
- package/dist/commands/logout.js +17 -0
- package/dist/commands/project-context-impact.js +215 -0
- package/dist/commands/reroute.js +60 -0
- package/dist/commands/spec-gap-status.js +52 -0
- package/dist/commands/whoami.js +35 -0
- package/dist/config/loader.js +67 -0
- package/dist/config/paths.js +20 -0
- package/dist/doctor/checks/_http-probe.js +56 -0
- package/dist/doctor/checks/auth.js +15 -0
- package/dist/doctor/checks/cert.js +24 -0
- package/dist/doctor/checks/forge-rules.js +102 -0
- package/dist/doctor/checks/keychain-fallback.js +14 -0
- package/dist/doctor/checks/keychain.js +23 -0
- package/dist/doctor/checks/llm-mode.js +27 -0
- package/dist/doctor/checks/proxy.js +13 -0
- package/dist/doctor/checks/sap.js +33 -0
- package/dist/doctor/checks/skill.js +24 -0
- package/dist/doctor/checks/write-mode.js +34 -0
- package/dist/doctor/checks/zcspeach.js +76 -0
- package/dist/doctor/run.js +46 -0
- package/dist/errors/codes.js +12 -0
- package/dist/index.js +6 -0
- package/dist/lock-contention.js +22 -0
- package/dist/one-shot.js +104 -0
- package/dist/project-context/conventions.js +309 -0
- package/dist/project-context/detect.js +250 -0
- package/dist/project-context/domain/abap-cloud.js +26 -0
- package/dist/project-context/domain/abapgit.js +177 -0
- package/dist/project-context/domain/cap.js +164 -0
- package/dist/project-context/domain/fiori.js +326 -0
- package/dist/project-context/git.js +115 -0
- package/dist/project-context/index-files.js +235 -0
- package/dist/project-context/index.js +117 -0
- package/dist/project-context/render.js +308 -0
- package/dist/project-context/types.js +14 -0
- package/dist/projects/build.js +20 -0
- package/dist/projects/canonicalize.js +39 -0
- package/dist/projects/email-template.js +54 -0
- package/dist/projects/extract-cca.js +139 -0
- package/dist/projects/extract-design.js +107 -0
- package/dist/projects/extract-estimate.js +93 -0
- package/dist/projects/extract-modernize.js +130 -0
- package/dist/projects/extract-spec-gap.js +101 -0
- package/dist/projects/extract-test-coverage.js +137 -0
- package/dist/projects/extract-upgrade.js +230 -0
- package/dist/projects/filename.js +18 -0
- package/dist/projects/index.js +8 -0
- package/dist/projects/migration.js +111 -0
- package/dist/projects/promote-command.js +96 -0
- package/dist/projects/promote.js +107 -0
- package/dist/projects/save-command.js +124 -0
- package/dist/projects/save.js +21 -0
- package/dist/projects/status.js +170 -0
- package/dist/projects/types.js +1 -0
- package/dist/projects/validate.js +146 -0
- package/dist/projects/workspace.js +478 -0
- package/dist/renderer/abap-inline.js +121 -0
- package/dist/renderer/banners.js +39 -0
- package/dist/renderer/highlighters/abap.js +126 -0
- package/dist/renderer/highlighters/bdef.js +81 -0
- package/dist/renderer/highlighters/cds.js +91 -0
- package/dist/renderer/markdown.js +291 -0
- package/dist/renderer/pipeline.js +201 -0
- package/dist/renderer/progress-chatter.js +237 -0
- package/dist/renderer/question-normalizer.js +306 -0
- package/dist/renderer/severity.js +61 -0
- package/dist/renderer/status-footer.js +50 -0
- package/dist/renderer/syntax.js +58 -0
- package/dist/renderer/tables.js +55 -0
- package/dist/renderer/thinking-heartbeat.js +70 -0
- package/dist/renderer/tool-widget.js +199 -0
- package/dist/renderer/tty.js +66 -0
- package/dist/renderer/widget-extractor.js +87 -0
- package/dist/renderer/widget-fallback.js +78 -0
- package/dist/renderer/widget-schemas.js +43 -0
- package/dist/repl/at-completer.js +64 -0
- package/dist/repl/at-picker.js +122 -0
- package/dist/repl/bracketed-paste.js +284 -0
- package/dist/repl/current-transport.js +46 -0
- package/dist/repl/diff-display.js +41 -0
- package/dist/repl/file-picker.js +219 -0
- package/dist/repl/inquirer-guard.js +130 -0
- package/dist/repl/inquirer-theme.js +41 -0
- package/dist/repl/rule8-detector.js +99 -0
- package/dist/repl/safety-confirm.js +106 -0
- package/dist/repl/safety-mode-state.js +36 -0
- package/dist/repl/slash-completer.js +59 -0
- package/dist/repl/slash-picker.js +124 -0
- package/dist/repl/update-method-preview-hook.js +45 -0
- package/dist/repl.js +1383 -0
- package/dist/router/classifier.js +38 -0
- package/dist/router/intent-extractor.js +140 -0
- package/dist/router/routing-decision.js +19 -0
- package/dist/sap/connection-manager.js +52 -0
- package/dist/sap/onboarding.js +178 -0
- package/dist/sap/system-info.js +515 -0
- package/dist/session/awaiting-answer.js +73 -0
- package/dist/session/gc.js +28 -0
- package/dist/session/pending.js +37 -0
- package/dist/session/resume.js +77 -0
- package/dist/session/schema.js +20 -0
- package/dist/session/store.js +147 -0
- package/dist/session/time-ago.js +41 -0
- package/dist/skill-catalog.js +222 -0
- package/dist/skills/bundled-skills.js +1 -0
- package/dist/skills/canonical.js +12 -0
- package/dist/skills/manifest-client.js +93 -0
- package/dist/skills/promotion-dispatch.js +24 -0
- package/dist/skills/signing-public-key.js +4 -0
- package/dist/skills/source-bundled.js +20 -0
- package/dist/skills/source-managed.js +26 -0
- package/dist/skills/source-manifest.js +26 -0
- package/dist/tools/_command-shared.js +110 -0
- package/dist/tools/_filesystem-shared.js +81 -0
- package/dist/tools/_flag.js +39 -0
- package/dist/tools/approval.js +228 -0
- package/dist/tools/ask-question.js +205 -0
- package/dist/tools/dispatch-skill.js +81 -0
- package/dist/tools/filesystem/file-edit.js +140 -0
- package/dist/tools/filesystem/file-read.js +89 -0
- package/dist/tools/filesystem/file-write.js +128 -0
- package/dist/tools/filesystem/glob.js +177 -0
- package/dist/tools/filesystem/grep.js +163 -0
- package/dist/tools/index.js +32 -0
- package/dist/tools/project/convention_get.js +91 -0
- package/dist/tools/project/playbook_get.js +132 -0
- package/dist/tools/project/project_context_get.js +101 -0
- package/dist/tools/sap-read.js +454 -0
- package/dist/tools/sap-write.js +746 -0
- package/dist/tools/shell/shell_exec.js +209 -0
- package/dist/tools/snapshot.js +107 -0
- package/dist/tools/subagent/_background-shared.js +133 -0
- package/dist/tools/subagent/agent_run.js +186 -0
- package/dist/tools/subagent/background_run.js +143 -0
- package/dist/tools/subagent/monitor_emit.js +65 -0
- package/dist/tools/subagent/schedule_create.js +131 -0
- package/dist/tools/transport.js +233 -0
- package/dist/tools/update-method-intercept.js +119 -0
- package/dist/tools/verify.js +39 -0
- package/dist/tools/web/_web-shared.js +251 -0
- package/dist/tools/web/web_fetch.js +257 -0
- package/dist/tools/web/web_search.js +195 -0
- package/dist/tools/write-mode.js +22 -0
- package/dist/ui/app.js +95 -0
- package/dist/ui/approval-emitter.js +10 -0
- package/dist/ui/approval-modal.js +53 -0
- package/dist/ui/ascii-chars.js +6 -0
- package/dist/ui/body.js +102 -0
- package/dist/ui/coaching-picker-classic.js +36 -0
- package/dist/ui/coaching-picker-emitter.js +27 -0
- package/dist/ui/command-palette.js +34 -0
- package/dist/ui/error-emitter.js +21 -0
- package/dist/ui/footer.js +103 -0
- package/dist/ui/header.js +17 -0
- package/dist/ui/ink-classifier-route.js +19 -0
- package/dist/ui/login-banner.js +72 -0
- package/dist/ui/rich-error-box.js +9 -0
- package/dist/ui/sap-state-store.js +65 -0
- package/dist/ui/session-timeline.js +31 -0
- package/dist/ui/sidebar.js +10 -0
- package/dist/ui/skill-picker.js +50 -0
- package/dist/ui/status-row.js +12 -0
- package/dist/ui/widget-control.js +4 -0
- package/dist/ui/widgets/bar-chart.js +15 -0
- package/dist/ui/widgets/coaching-picker.js +41 -0
- package/dist/ui/widgets/component-registry.js +12 -0
- package/dist/ui/widgets/dep-graph.js +9 -0
- package/dist/ui/widgets/diff-viewer.js +11 -0
- package/dist/ui/widgets/question-card.js +11 -0
- package/dist/ui/widgets/stack-frames.js +5 -0
- package/dist/upgrade-check.js +28 -0
- package/dist/upgrade.js +13 -0
- package/package.json +83 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 2 — skill-level checkpoints.
|
|
3
|
+
*
|
|
4
|
+
* Background. Layer 1 (commit be1c6d8) makes sure that when a turn aborts
|
|
5
|
+
* mid-stream, every prior assistant message + tool result + accumulated
|
|
6
|
+
* partial text lands on disk in `session.messages`. The model can resume
|
|
7
|
+
* with full conversation context.
|
|
8
|
+
*
|
|
9
|
+
* What Layer 1 does NOT cover: skill-internal state files. /abap-cca, for
|
|
10
|
+
* example, maintains `.abapforge/cca/projects/<id>/project.json` with an
|
|
11
|
+
* inventory of objects, classifications, and per-package state. The skill
|
|
12
|
+
* writes this file at PHASE BOUNDARIES (end of DISCOVER, end of INVENTORY,
|
|
13
|
+
* etc.). A crash mid-DISCOVER means the partial inventory in project.json
|
|
14
|
+
* is stale — the skill has to redo every probe on resume.
|
|
15
|
+
*
|
|
16
|
+
* Layer 2 fixes that. After every tool call returns, the agent loop calls
|
|
17
|
+
* `applyToolResultCheckpoint(...)` which dispatches to a registered handler
|
|
18
|
+
* for the active skill. The handler reads the prior on-disk state, applies
|
|
19
|
+
* one tool result, and writes back atomically. Resume picks up at the next
|
|
20
|
+
* probe with the partial inventory intact, no SAP work redone.
|
|
21
|
+
*
|
|
22
|
+
* v0.6 ships the infrastructure with a default JSONL handler that captures
|
|
23
|
+
* a per-session audit log of every tool call. Skill-specific handlers
|
|
24
|
+
* (e.g. for /abap-cca's project.json) can be added in follow-up commits
|
|
25
|
+
* without touching the loop.
|
|
26
|
+
*/
|
|
27
|
+
import { promises as fs } from 'node:fs';
|
|
28
|
+
import * as path from 'node:path';
|
|
29
|
+
import * as os from 'node:os';
|
|
30
|
+
const HANDLERS = new Map();
|
|
31
|
+
/**
|
|
32
|
+
* Register a skill-specific handler. Idempotent — re-registering replaces
|
|
33
|
+
* the prior handler. Skills should register at module-load time.
|
|
34
|
+
*/
|
|
35
|
+
export function registerCheckpointHandler(handler) {
|
|
36
|
+
HANDLERS.set(handler.skill, handler);
|
|
37
|
+
}
|
|
38
|
+
/** Test helper — clears the registry. Not exported in production paths. */
|
|
39
|
+
export function _resetHandlersForTesting() {
|
|
40
|
+
HANDLERS.clear();
|
|
41
|
+
}
|
|
42
|
+
export function checkpointsRoot() {
|
|
43
|
+
return path.join(os.homedir(), '.cspeach', 'checkpoints');
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Default handler — appends one JSONL row per tool call to
|
|
47
|
+
* ~/.cspeach/checkpoints/<sessionId>/tool-calls.jsonl.
|
|
48
|
+
*
|
|
49
|
+
* This runs for EVERY skill regardless of registered handlers. Provides a
|
|
50
|
+
* uniform audit trail without skills having to register anything. Safe for
|
|
51
|
+
* concurrent appends (atomic O_APPEND on POSIX, single-threaded on Node so
|
|
52
|
+
* mid-write interleaving doesn't happen).
|
|
53
|
+
*/
|
|
54
|
+
async function appendDefaultJsonl(input) {
|
|
55
|
+
try {
|
|
56
|
+
const dir = path.join(checkpointsRoot(), input.sessionId);
|
|
57
|
+
await fs.mkdir(dir, { recursive: true });
|
|
58
|
+
const filePath = path.join(dir, 'tool-calls.jsonl');
|
|
59
|
+
const row = {
|
|
60
|
+
at: input.completedAt,
|
|
61
|
+
tool: input.toolName,
|
|
62
|
+
args: truncateArgs(input.args),
|
|
63
|
+
ok: !input.isError,
|
|
64
|
+
duration_ms: input.durationMs,
|
|
65
|
+
// Cap result payload at 4KB to bound JSONL growth on long turns.
|
|
66
|
+
result: truncateResult(input.result),
|
|
67
|
+
};
|
|
68
|
+
await fs.appendFile(filePath, JSON.stringify(row) + '\n', 'utf-8');
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// best-effort — never throw out
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function truncateArgs(args) {
|
|
75
|
+
try {
|
|
76
|
+
const json = JSON.stringify(args);
|
|
77
|
+
if (json.length <= 1024)
|
|
78
|
+
return args;
|
|
79
|
+
return JSON.parse(json.slice(0, 1024) + '..."}');
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return '<unstringifiable>';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function truncateResult(result) {
|
|
86
|
+
if (typeof result === 'string') {
|
|
87
|
+
return result.length <= 4096 ? result : result.slice(0, 4096) + '...';
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const json = JSON.stringify(result);
|
|
91
|
+
if (json.length <= 4096)
|
|
92
|
+
return result;
|
|
93
|
+
return json.slice(0, 4096) + '...';
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return '<unstringifiable>';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Main entry point — call from the agent loop after every tool dispatch.
|
|
101
|
+
* Always runs the default JSONL handler. Also dispatches to a skill-specific
|
|
102
|
+
* handler if one is registered. Both are best-effort; failures are swallowed.
|
|
103
|
+
*/
|
|
104
|
+
export async function applyToolResultCheckpoint(input) {
|
|
105
|
+
// Default audit JSONL — runs for every skill.
|
|
106
|
+
await appendDefaultJsonl(input);
|
|
107
|
+
// Skill-specific handler (optional).
|
|
108
|
+
if (input.skill) {
|
|
109
|
+
const handler = HANDLERS.get(input.skill);
|
|
110
|
+
if (handler) {
|
|
111
|
+
try {
|
|
112
|
+
await handler.apply(input);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// best-effort
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { getTool } from '../tools/index.js';
|
|
2
|
+
import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
|
|
3
|
+
import { shouldGateRule8, recordWriteOp, renderPlanSummary, getWriteOpsThisTurn, } from '../repl/rule8-detector.js';
|
|
4
|
+
export async function dispatchTool(name, args, ctx) {
|
|
5
|
+
const tool = getTool(name);
|
|
6
|
+
if (!tool) {
|
|
7
|
+
return { content: JSON.stringify({ error: `unknown_tool: ${name}` }), is_error: true };
|
|
8
|
+
}
|
|
9
|
+
// Rule 8 — no batch without plan. 2nd+ mutating call this turn requires
|
|
10
|
+
// explicit confirmation. Fires regardless of /safety-mode toggle (batch
|
|
11
|
+
// detection is unconditional per Plan 3 §6.6).
|
|
12
|
+
if (tool.isMutating && shouldGateRule8()) {
|
|
13
|
+
const r = await presentSafetyConfirmation({
|
|
14
|
+
rule: 'RULE_8_BATCH_PLAN',
|
|
15
|
+
op: tool.name,
|
|
16
|
+
object: {
|
|
17
|
+
type: typeof args?.type === 'string' ? args.type : 'BATCH',
|
|
18
|
+
name: typeof args?.name === 'string' ? args.name : '(multiple)',
|
|
19
|
+
},
|
|
20
|
+
what_will_happen: renderPlanSummary({ tool: tool.name, args }),
|
|
21
|
+
rollback_available: true, // snapshots taken per-write
|
|
22
|
+
transport: typeof args?.transport === 'string' ? args.transport : '',
|
|
23
|
+
batch_count: getWriteOpsThisTurn().length + 1,
|
|
24
|
+
});
|
|
25
|
+
if (!r.confirmed) {
|
|
26
|
+
return {
|
|
27
|
+
content: JSON.stringify({ error: 'cancelled_by_user', reason: r.reason }),
|
|
28
|
+
is_error: true,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const result = await tool.handler(args, ctx);
|
|
34
|
+
// (regression-hunt trace removed 2026-05-01 after root cause found —
|
|
35
|
+
// see memory/project_phase3_lite_regression.md.)
|
|
36
|
+
// Always record AFTER successful mutating dispatch — applies to 1st and
|
|
37
|
+
// 2nd+ writes alike. The 1st write seeds the counter; without this the
|
|
38
|
+
// gate would never fire on the 2nd write.
|
|
39
|
+
if (tool.isMutating && !(result.is_error ?? false)) {
|
|
40
|
+
recordWriteOp(tool.name, args);
|
|
41
|
+
}
|
|
42
|
+
return { content: result.content, is_error: result.is_error ?? false };
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
return { content: JSON.stringify({ error: String(err) }), is_error: true };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collect ALL assistant text emitted during a single turn.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: the end-of-turn save hook (loop.ts, maybeOfferSave call
|
|
5
|
+
* site) needs to scan the turn's assistant output for a skill manifest
|
|
6
|
+
* block (e.g. `<!-- csforge:cca-manifest ... -->` for /abap-cca). Before
|
|
7
|
+
* 2026-05-12 the call site only looked at the FINAL assistant message of
|
|
8
|
+
* the turn — but skills like /abap-cca emit the manifest in the report
|
|
9
|
+
* message, then call `ask_question` for a closing "what's next?" picker,
|
|
10
|
+
* then emit a short wrap-up message. The wrap-up message ended the turn
|
|
11
|
+
* but did not contain the manifest, so `extractCcaAssessment` threw
|
|
12
|
+
* "No manifest" and the save hook silently bailed (save-command.ts:114).
|
|
13
|
+
*
|
|
14
|
+
* Fix: walk session.messages from the per-turn start marker and join the
|
|
15
|
+
* text from every assistant message in between. The save hook now sees
|
|
16
|
+
* the full turn-worth of assistant output regardless of which inner
|
|
17
|
+
* message contained the manifest.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Concatenate text from every assistant message in `messages` starting at
|
|
21
|
+
* `turnStartCount`. Non-text content blocks (tool_use, tool_result) are
|
|
22
|
+
* skipped. Non-string `.text` values are coerced to empty strings rather
|
|
23
|
+
* than thrown — defensive against malformed content arrays.
|
|
24
|
+
*
|
|
25
|
+
* Returns '' (not null) when nothing matches so the callsite stays simple.
|
|
26
|
+
*
|
|
27
|
+
* Messages are joined with `\n\n` so the model's pre-tool message and
|
|
28
|
+
* post-tool message stay separable in the joined output. The save hook's
|
|
29
|
+
* extractor uses a regex that doesn't care about inter-message whitespace.
|
|
30
|
+
*/
|
|
31
|
+
export function collectTurnAssistantText(messages, turnStartCount) {
|
|
32
|
+
if (turnStartCount < 0 || turnStartCount >= messages.length)
|
|
33
|
+
return '';
|
|
34
|
+
const parts = [];
|
|
35
|
+
for (let i = turnStartCount; i < messages.length; i++) {
|
|
36
|
+
const m = messages[i];
|
|
37
|
+
if (m.role !== 'assistant')
|
|
38
|
+
continue;
|
|
39
|
+
if (!Array.isArray(m.content))
|
|
40
|
+
continue;
|
|
41
|
+
for (const block of m.content) {
|
|
42
|
+
if (block?.type !== 'text')
|
|
43
|
+
continue;
|
|
44
|
+
if (typeof block.text === 'string')
|
|
45
|
+
parts.push(block.text);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return parts.join('\n\n');
|
|
49
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graceful turn-error UX.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the v0.5 catch path that printed only `[error] <msg>` and lost
|
|
5
|
+
* everything. The new behaviour:
|
|
6
|
+
*
|
|
7
|
+
* - Writes the full error (message + stack) to ~/.cspeach/logs/<sessionId>-<ts>.log
|
|
8
|
+
* - Prints a clean 4-6 line block to stderr with: cause, session id,
|
|
9
|
+
* stream path, log path, resume hint.
|
|
10
|
+
* - Never throws — best-effort. The CLI returns to the prompt cleanly so
|
|
11
|
+
* the user can either resume or type a new prompt.
|
|
12
|
+
*
|
|
13
|
+
* The previous loop persistence step (loop.ts try/catch) has already
|
|
14
|
+
* pushed any partial assistant content onto session.messages and saved
|
|
15
|
+
* the session before the throw bubbled here. Resume picks up that state.
|
|
16
|
+
*/
|
|
17
|
+
import chalk from 'chalk';
|
|
18
|
+
import { promises as fs } from 'node:fs';
|
|
19
|
+
import * as os from 'node:os';
|
|
20
|
+
import * as path from 'node:path';
|
|
21
|
+
import { TurnStreamWriter } from './turn-stream.js';
|
|
22
|
+
export function logsRoot() {
|
|
23
|
+
return path.join(os.homedir(), '.cspeach', 'logs');
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Pretty-print `err.message` for human display. Translates known
|
|
27
|
+
* provider-stream signatures into actionable phrases. Anything unknown
|
|
28
|
+
* falls through verbatim.
|
|
29
|
+
*/
|
|
30
|
+
export function describeError(err) {
|
|
31
|
+
if (err == null)
|
|
32
|
+
return 'unknown error';
|
|
33
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
34
|
+
if (!msg)
|
|
35
|
+
return 'unknown error';
|
|
36
|
+
// Undici / Node fetch closes a stream with bare "terminated".
|
|
37
|
+
if (msg === 'terminated' || msg.includes('terminated')) {
|
|
38
|
+
return 'provider stream closed mid-response';
|
|
39
|
+
}
|
|
40
|
+
if (msg.includes('ECONNRESET'))
|
|
41
|
+
return 'network connection reset';
|
|
42
|
+
if (msg.includes('ETIMEDOUT'))
|
|
43
|
+
return 'network timeout';
|
|
44
|
+
if (msg.toLowerCase().includes('aborterror'))
|
|
45
|
+
return 'request aborted';
|
|
46
|
+
return msg.slice(0, 200);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Write a full diagnostic log to ~/.cspeach/logs/<sessionId>-<ts>.log.
|
|
50
|
+
* Best-effort — failure here is silent, the user-facing block still renders.
|
|
51
|
+
* Returns the absolute path on success, null on failure.
|
|
52
|
+
*/
|
|
53
|
+
async function writeErrorLog(sessionId, err) {
|
|
54
|
+
try {
|
|
55
|
+
const dir = logsRoot();
|
|
56
|
+
await fs.mkdir(dir, { recursive: true });
|
|
57
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
58
|
+
const filePath = path.join(dir, `${sessionId}-${stamp}.log`);
|
|
59
|
+
const lines = [
|
|
60
|
+
`# CSPeach turn error log`,
|
|
61
|
+
`session: ${sessionId}`,
|
|
62
|
+
`at: ${new Date().toISOString()}`,
|
|
63
|
+
``,
|
|
64
|
+
`## error`,
|
|
65
|
+
`${err instanceof Error ? err.name + ': ' + err.message : String(err)}`,
|
|
66
|
+
``,
|
|
67
|
+
`## stack`,
|
|
68
|
+
`${err instanceof Error && err.stack ? err.stack : '(no stack)'}`,
|
|
69
|
+
``,
|
|
70
|
+
];
|
|
71
|
+
if (err && typeof err === 'object') {
|
|
72
|
+
const e = err;
|
|
73
|
+
const status = e.status ?? e.response?.status;
|
|
74
|
+
if (status)
|
|
75
|
+
lines.push(`status: ${status}`);
|
|
76
|
+
const code = e.code;
|
|
77
|
+
if (code)
|
|
78
|
+
lines.push(`code: ${code}`);
|
|
79
|
+
const cause = e.cause;
|
|
80
|
+
if (cause) {
|
|
81
|
+
lines.push(``, `## cause`, String(cause));
|
|
82
|
+
if (cause instanceof Error && cause.stack) {
|
|
83
|
+
lines.push(``, `## cause stack`, cause.stack);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
await fs.writeFile(filePath, lines.join('\n') + '\n', 'utf-8');
|
|
88
|
+
return filePath;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Render the user-facing turn-error block. Writes a log file (best-effort)
|
|
96
|
+
* and prints a 4-6 line diagnostic that names the cause + the recovery path.
|
|
97
|
+
*/
|
|
98
|
+
export async function renderTurnError(input) {
|
|
99
|
+
const print = input.print ?? ((line) => process.stderr.write(line + '\n'));
|
|
100
|
+
print('');
|
|
101
|
+
if (input.userInterrupted) {
|
|
102
|
+
// User pressed Ctrl+C / Esc — frame as a deliberate cancellation,
|
|
103
|
+
// not a crash. Skip the log file (no diagnostic value).
|
|
104
|
+
print(chalk.yellow(`✋ Turn cancelled.`));
|
|
105
|
+
print(chalk.gray(` Session: ${input.session.id}`));
|
|
106
|
+
print(chalk.gray(` Resume: cspeach --resume ${input.session.id}`));
|
|
107
|
+
print(chalk.gray(` cspeach --resume (no arg → pick from a list)`));
|
|
108
|
+
print('');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const reason = describeError(input.err);
|
|
112
|
+
const logPath = await writeErrorLog(input.session.id, input.err);
|
|
113
|
+
print(chalk.red(`⚠ Turn interrupted: ${reason}`));
|
|
114
|
+
print(chalk.gray(` Session: ${input.session.id}`));
|
|
115
|
+
if (input.streamFilePath) {
|
|
116
|
+
print(chalk.gray(` Stream: ${input.streamFilePath}`));
|
|
117
|
+
}
|
|
118
|
+
if (logPath) {
|
|
119
|
+
print(chalk.gray(` Log: ${logPath}`));
|
|
120
|
+
}
|
|
121
|
+
print(chalk.gray(` Resume: cspeach --resume ${input.session.id}`));
|
|
122
|
+
print(chalk.gray(` cspeach --resume (no arg → pick from a list)`));
|
|
123
|
+
print('');
|
|
124
|
+
}
|
|
125
|
+
/** Re-export for callers that want to compute a stream path independently. */
|
|
126
|
+
export { TurnStreamWriter };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-turn stream-to-disk mirror.
|
|
3
|
+
*
|
|
4
|
+
* The renderer (Ink + ANSI pipeline + widget extraction) is the SOLE display
|
|
5
|
+
* channel for assistant text in v0.5. If the terminal pipeline dies — Ink
|
|
6
|
+
* frame corruption, an exception inside the widget renderer, the host
|
|
7
|
+
* terminal closing — the user sees nothing and the in-memory text was, until
|
|
8
|
+
* v0.6, the only copy. (See loop.ts try/catch for the persistence side of
|
|
9
|
+
* the same problem.)
|
|
10
|
+
*
|
|
11
|
+
* This writer mirrors EVERY text delta arriving from the provider stream to
|
|
12
|
+
* a per-turn markdown file under ~/.cspeach/streams/<sessionId>/<ts>.md.
|
|
13
|
+
* Best-effort: file-IO failures NEVER throw out — a stream-to-disk hiccup
|
|
14
|
+
* must not crash a turn that is otherwise working.
|
|
15
|
+
*
|
|
16
|
+
* Use case: when a turn aborts mid-stream, the user runs `cat
|
|
17
|
+
* ~/.cspeach/streams/<sessionId>/<ts>.md` and recovers the partial output.
|
|
18
|
+
* The graceful error UX (T19) prints this path on every failed turn.
|
|
19
|
+
*/
|
|
20
|
+
import { promises as fs } from 'node:fs';
|
|
21
|
+
import * as path from 'node:path';
|
|
22
|
+
import * as os from 'node:os';
|
|
23
|
+
export function streamsRoot() {
|
|
24
|
+
// Mirror the existing ~/.cspeach/sessions/ convention. The sessions/
|
|
25
|
+
// directory holds the structured JSON; streams/ holds the human-readable
|
|
26
|
+
// text mirror.
|
|
27
|
+
return path.join(os.homedir(), '.cspeach', 'streams');
|
|
28
|
+
}
|
|
29
|
+
export class TurnStreamWriter {
|
|
30
|
+
filePath;
|
|
31
|
+
headerWritten = false;
|
|
32
|
+
constructor(sessionId, startedAt = new Date()) {
|
|
33
|
+
// ISO timestamp with filename-safe punctuation.
|
|
34
|
+
const stamp = startedAt.toISOString().replace(/[:.]/g, '-');
|
|
35
|
+
this.filePath = path.join(streamsRoot(), sessionId, `${stamp}.md`);
|
|
36
|
+
}
|
|
37
|
+
/** The on-disk file path (absolute). Useful for the resume/error UX. */
|
|
38
|
+
path() {
|
|
39
|
+
return this.filePath;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Append assistant text to the stream file. Best-effort — silently swallows
|
|
43
|
+
* file-IO errors so a write hiccup never kills a working turn. Lazily writes
|
|
44
|
+
* a small header on the first append so an unused turn produces no file.
|
|
45
|
+
*/
|
|
46
|
+
async append(text) {
|
|
47
|
+
if (!text || text.length === 0)
|
|
48
|
+
return;
|
|
49
|
+
try {
|
|
50
|
+
if (!this.headerWritten) {
|
|
51
|
+
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
|
52
|
+
const header = `# CSPeach turn stream\n_started ${new Date().toISOString()}_\n\n`;
|
|
53
|
+
await fs.writeFile(this.filePath, header, 'utf-8');
|
|
54
|
+
this.headerWritten = true;
|
|
55
|
+
}
|
|
56
|
+
await fs.appendFile(this.filePath, text, 'utf-8');
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// best-effort: never throw out
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Close the stream with an optional footer (token totals, duration, end
|
|
64
|
+
* status). Idempotent — safe to call multiple times.
|
|
65
|
+
*/
|
|
66
|
+
async close(footer) {
|
|
67
|
+
if (!this.headerWritten)
|
|
68
|
+
return;
|
|
69
|
+
try {
|
|
70
|
+
const block = footer
|
|
71
|
+
? `\n\n---\n${footer}\n`
|
|
72
|
+
: `\n\n---\n_closed ${new Date().toISOString()}_\n`;
|
|
73
|
+
await fs.appendFile(this.filePath, block, 'utf-8');
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// best-effort
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 3 — long-turn watchdog.
|
|
3
|
+
*
|
|
4
|
+
* Background. The lost-77-minute /abap-cca on ZDPR_PAYMENTS run did 30+ tool
|
|
5
|
+
* calls and 200K output tokens in a single turn. Even with Layer 1 + 2, a
|
|
6
|
+
* turn that long is a smell: it's hard to follow, it consumes token budget
|
|
7
|
+
* unevenly, and it sits inside one Anthropic streaming session that's more
|
|
8
|
+
* likely to be cut by a network blip the longer it runs.
|
|
9
|
+
*
|
|
10
|
+
* The watchdog injects a synthetic advisory user message between tool rounds
|
|
11
|
+
* when the running turn approaches a budget. The model sees:
|
|
12
|
+
*
|
|
13
|
+
* "[CSPeach watchdog] You've been running this turn for 18m / 92K output
|
|
14
|
+
* tokens. Consider checkpointing what you have, summarizing progress so
|
|
15
|
+
* far, and prompting the user to continue. Don't keep going as if nothing
|
|
16
|
+
* changed."
|
|
17
|
+
*
|
|
18
|
+
* Soft signal — the model decides whether to wrap up or push through. The
|
|
19
|
+
* advisory is emitted exactly ONCE per turn so we don't spam.
|
|
20
|
+
*
|
|
21
|
+
* Defaults:
|
|
22
|
+
* wall-clock: 20 minutes per turn (overridable via CSPEACH_TURN_WALL_BUDGET_MS)
|
|
23
|
+
* output tokens: 100,000 per turn (overridable via CSPEACH_TURN_TOKEN_BUDGET)
|
|
24
|
+
*
|
|
25
|
+
* Set CSPEACH_TURN_WATCHDOG=off to disable entirely.
|
|
26
|
+
*/
|
|
27
|
+
export const DEFAULT_WALL_BUDGET_MS = 20 * 60 * 1000;
|
|
28
|
+
export const DEFAULT_TOKEN_BUDGET = 100_000;
|
|
29
|
+
export function loadWatchdogConfig(env = process.env) {
|
|
30
|
+
const enabled = (env.CSPEACH_TURN_WATCHDOG ?? 'on').toLowerCase() !== 'off';
|
|
31
|
+
const wallBudgetMs = parsePositiveInt(env.CSPEACH_TURN_WALL_BUDGET_MS, DEFAULT_WALL_BUDGET_MS);
|
|
32
|
+
const tokenBudget = parsePositiveInt(env.CSPEACH_TURN_TOKEN_BUDGET, DEFAULT_TOKEN_BUDGET);
|
|
33
|
+
return { wallBudgetMs, tokenBudget, enabled };
|
|
34
|
+
}
|
|
35
|
+
function parsePositiveInt(raw, fallback) {
|
|
36
|
+
if (!raw)
|
|
37
|
+
return fallback;
|
|
38
|
+
const n = Number.parseInt(raw, 10);
|
|
39
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
40
|
+
}
|
|
41
|
+
export function evaluateWatchdog(input, now = Date.now()) {
|
|
42
|
+
if (!input.config.enabled || input.alreadyWarned) {
|
|
43
|
+
return { shouldWarn: false, reason: '', message: '' };
|
|
44
|
+
}
|
|
45
|
+
const elapsedMs = Math.max(0, now - input.startedAt);
|
|
46
|
+
const wallExceeded = elapsedMs >= input.config.wallBudgetMs;
|
|
47
|
+
const tokensExceeded = input.outputTokensThisTurn >= input.config.tokenBudget;
|
|
48
|
+
if (!wallExceeded && !tokensExceeded) {
|
|
49
|
+
return { shouldWarn: false, reason: '', message: '' };
|
|
50
|
+
}
|
|
51
|
+
const wallMin = Math.floor(elapsedMs / 60_000);
|
|
52
|
+
const wallLimit = Math.floor(input.config.wallBudgetMs / 60_000);
|
|
53
|
+
const reasons = [];
|
|
54
|
+
if (wallExceeded)
|
|
55
|
+
reasons.push(`${wallMin}m elapsed (limit ${wallLimit}m)`);
|
|
56
|
+
if (tokensExceeded) {
|
|
57
|
+
reasons.push(`${kFormat(input.outputTokensThisTurn)} output tokens (limit ${kFormat(input.config.tokenBudget)})`);
|
|
58
|
+
}
|
|
59
|
+
const reason = reasons.join(', ');
|
|
60
|
+
const message = `[CSPeach watchdog] This turn has crossed a budget: ${reason}. ` +
|
|
61
|
+
`Consider checkpointing what you have, summarising progress so far, ` +
|
|
62
|
+
`and prompting the user to continue with a fresh turn. Don't keep ` +
|
|
63
|
+
`going as if nothing changed — long single-turn runs are more likely ` +
|
|
64
|
+
`to be cut by a network blip the longer they get.`;
|
|
65
|
+
return { shouldWarn: true, reason, message };
|
|
66
|
+
}
|
|
67
|
+
function kFormat(n) {
|
|
68
|
+
if (n < 1000)
|
|
69
|
+
return String(n);
|
|
70
|
+
return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}K`;
|
|
71
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import clipboardy from 'clipboardy';
|
|
2
|
+
import readline from 'node:readline/promises';
|
|
3
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
4
|
+
export async function promptAdvisory(args) {
|
|
5
|
+
output.write(args.displayText + '\n');
|
|
6
|
+
const rl = readline.createInterface({ input, output });
|
|
7
|
+
try {
|
|
8
|
+
while (true) {
|
|
9
|
+
const answer = (await rl.question('[y]applied [s]kip [n]abort [c]opy-to-clipboard: '))
|
|
10
|
+
.trim()
|
|
11
|
+
.toLowerCase();
|
|
12
|
+
switch (answer) {
|
|
13
|
+
case 'y':
|
|
14
|
+
case 'yes':
|
|
15
|
+
case 'applied':
|
|
16
|
+
return 'applied';
|
|
17
|
+
case 's':
|
|
18
|
+
case 'skip':
|
|
19
|
+
case 'skipped':
|
|
20
|
+
return 'skipped';
|
|
21
|
+
case 'n':
|
|
22
|
+
case 'no':
|
|
23
|
+
case 'abort':
|
|
24
|
+
case 'aborted':
|
|
25
|
+
return 'aborted';
|
|
26
|
+
case 'c':
|
|
27
|
+
case 'copy':
|
|
28
|
+
await clipboardy.write(args.clipboardText);
|
|
29
|
+
output.write('✓ Copied to clipboard.\n');
|
|
30
|
+
continue;
|
|
31
|
+
default:
|
|
32
|
+
output.write('Unrecognised input. Try y, s, n, or c.\n');
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
rl.close();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
export function renderAdvisoryProposal(args) {
|
|
3
|
+
const displayParts = [];
|
|
4
|
+
const clipboardParts = [];
|
|
5
|
+
displayParts.push(chalk.yellow.bold('╔══ ADVISORY MODE — proposed change (no SAP write will occur) ══╗'));
|
|
6
|
+
displayParts.push(chalk.bold(`Summary: ${args.summary}`));
|
|
7
|
+
displayParts.push(`Risk: ${args.risk}`);
|
|
8
|
+
if (args.transport)
|
|
9
|
+
displayParts.push(`Target transport: ${args.transport}`);
|
|
10
|
+
displayParts.push('');
|
|
11
|
+
clipboardParts.push(`# ${args.summary}`);
|
|
12
|
+
if (args.transport)
|
|
13
|
+
clipboardParts.push(`# Target transport: ${args.transport}`);
|
|
14
|
+
clipboardParts.push('');
|
|
15
|
+
args.changes.forEach((change, idx) => {
|
|
16
|
+
const header = `Change ${idx + 1}: ${change.op.toUpperCase()} ${change.type} ${change.object}`;
|
|
17
|
+
displayParts.push(chalk.cyan(header));
|
|
18
|
+
clipboardParts.push(`## ${header}`);
|
|
19
|
+
if (change.diff) {
|
|
20
|
+
displayParts.push(chalk.gray('─'.repeat(64)));
|
|
21
|
+
displayParts.push(change.diff);
|
|
22
|
+
displayParts.push(chalk.gray('─'.repeat(64)));
|
|
23
|
+
clipboardParts.push('');
|
|
24
|
+
clipboardParts.push(change.diff);
|
|
25
|
+
clipboardParts.push('');
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
displayParts.push('');
|
|
29
|
+
displayParts.push(chalk.yellow('Next step: open this object in ADT or SE80, apply the change, save and activate.'));
|
|
30
|
+
displayParts.push(chalk.gray('Press c to copy to clipboard, y when applied, s to skip, n to abort.'));
|
|
31
|
+
displayParts.push(chalk.yellow.bold('╚══════════════════════════════════════════════════════════════════╝'));
|
|
32
|
+
clipboardParts.push('');
|
|
33
|
+
clipboardParts.push('# This is an advisory proposal. Apply manually in ADT/SE80.');
|
|
34
|
+
return {
|
|
35
|
+
displayText: displayParts.join('\n'),
|
|
36
|
+
clipboardText: clipboardParts.join('\n'),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { loadConfig, saveConfig } from '../config/loader.js';
|
|
3
|
+
const BOX_WIDTH = 62;
|
|
4
|
+
const TOP = '┌' + '─'.repeat(BOX_WIDTH) + '┐';
|
|
5
|
+
const BOTTOM = '└' + '─'.repeat(BOX_WIDTH) + '┘';
|
|
6
|
+
const pad = (s) => '│ ' + s.padEnd(BOX_WIDTH - 2) + '│';
|
|
7
|
+
const blank = '│' + ' '.repeat(BOX_WIDTH) + '│';
|
|
8
|
+
/**
|
|
9
|
+
* Capture a single keypress for the y/n/d answer. Raw-mode readline so the
|
|
10
|
+
* hand-drawn box stays the only visible affordance.
|
|
11
|
+
*
|
|
12
|
+
* Why readline, not inquirer `select`: inquirer would render its own full-screen
|
|
13
|
+
* picker below our hand-drawn box, giving the user TWO UIs for the same
|
|
14
|
+
* question. The spec §3.5 Option A says "the box is the UI" — one affordance,
|
|
15
|
+
* one keypress.
|
|
16
|
+
*/
|
|
17
|
+
async function promptNagChoice() {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
if (process.stdin.isTTY)
|
|
20
|
+
process.stdin.setRawMode(true);
|
|
21
|
+
process.stdin.resume();
|
|
22
|
+
process.stdin.setEncoding('utf8');
|
|
23
|
+
const onData = (key) => {
|
|
24
|
+
const k = key.toLowerCase();
|
|
25
|
+
// Ctrl-C → treat as 'n' and let the outer flow continue.
|
|
26
|
+
if (k === '\u0003') {
|
|
27
|
+
cleanup();
|
|
28
|
+
resolve('n');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (k === 'y' || k === 'n' || k === 'd') {
|
|
32
|
+
cleanup();
|
|
33
|
+
process.stdout.write(`\n${k}\n`);
|
|
34
|
+
resolve(k);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const cleanup = () => {
|
|
38
|
+
process.stdin.removeListener('data', onData);
|
|
39
|
+
if (process.stdin.isTTY)
|
|
40
|
+
process.stdin.setRawMode(false);
|
|
41
|
+
process.stdin.pause();
|
|
42
|
+
};
|
|
43
|
+
process.stdin.on('data', onData);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Called just before `renderPerChangeApproval` when the current change
|
|
48
|
+
* is low-risk on a `never` system and the nag has not been dismissed.
|
|
49
|
+
*
|
|
50
|
+
* Mutates config on 'y' (sets auto_approve = 'low') or 'd' (sets per-sap
|
|
51
|
+
* nag_dismissed = true — see M5 for the scoping rationale). Returns
|
|
52
|
+
* immediately (no side effect) on 'n'.
|
|
53
|
+
*/
|
|
54
|
+
export async function maybeShowAutoApproveNag(ctx) {
|
|
55
|
+
const cfg = await loadConfig();
|
|
56
|
+
const sapCfg = cfg.sap[ctx.sapAlias];
|
|
57
|
+
// Preconditions: low risk, never policy, per-sap nag not dismissed.
|
|
58
|
+
if (ctx.effectiveRisk !== 'low')
|
|
59
|
+
return;
|
|
60
|
+
if (!sapCfg || sapCfg.auto_approve !== 'never')
|
|
61
|
+
return;
|
|
62
|
+
if (sapCfg.nag_dismissed ?? false)
|
|
63
|
+
return;
|
|
64
|
+
// Draw the box. This is the ONLY visible UI — keypress answers it directly.
|
|
65
|
+
console.log('');
|
|
66
|
+
console.log(TOP);
|
|
67
|
+
console.log(blank);
|
|
68
|
+
console.log(pad(chalk.yellow('This change was classified as LOW risk.')));
|
|
69
|
+
console.log(blank);
|
|
70
|
+
console.log(pad(`Your current policy on ${ctx.sapAlias} prompts on everything.`));
|
|
71
|
+
console.log(pad('Want to auto-approve low-risk changes on this system?'));
|
|
72
|
+
console.log(blank);
|
|
73
|
+
console.log(pad(chalk.green('[y] yes') + ' (safer for DEV systems only)'));
|
|
74
|
+
console.log(pad(chalk.dim('[n] no ') + ' (keep prompting every time)'));
|
|
75
|
+
console.log(pad(chalk.dim('[d] don\'t ask me this again (on ' + ctx.sapAlias + ')')));
|
|
76
|
+
console.log(blank);
|
|
77
|
+
console.log(BOTTOM);
|
|
78
|
+
process.stdout.write(chalk.dim('Press y / n / d: '));
|
|
79
|
+
const choice = await promptNagChoice();
|
|
80
|
+
if (choice === 'y') {
|
|
81
|
+
const fresh = await loadConfig(); // re-read to avoid stale write
|
|
82
|
+
if (fresh.sap[ctx.sapAlias]) {
|
|
83
|
+
fresh.sap[ctx.sapAlias].auto_approve = 'low';
|
|
84
|
+
await saveConfig(fresh);
|
|
85
|
+
console.log(chalk.green(`Auto-approve set to "low" for ${ctx.sapAlias}.`));
|
|
86
|
+
console.log(chalk.gray('Change with: cspeach config set sap.' + ctx.sapAlias + '.auto_approve never\n'));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else if (choice === 'd') {
|
|
90
|
+
const fresh = await loadConfig();
|
|
91
|
+
// Per-sap scoping (M5): dismissing on DEV must not suppress the more
|
|
92
|
+
// important PROD prompt. Each sap system has its own `nag_dismissed` flag.
|
|
93
|
+
if (fresh.sap[ctx.sapAlias]) {
|
|
94
|
+
fresh.sap[ctx.sapAlias].nag_dismissed = true;
|
|
95
|
+
await saveConfig(fresh);
|
|
96
|
+
console.log(chalk.gray(`Noted. This prompt will not appear again on ${ctx.sapAlias}.\n`));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// 'n' → no config change, fall through to per-change approval normally.
|
|
100
|
+
}
|