@sakk-dev/sakk 0.0.0-stage → 0.4.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/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +27 -0
- package/CHANGELOG.md +1421 -0
- package/LICENSE +202 -0
- package/NOTICE +82 -0
- package/README.md +620 -2
- package/README.zh-CN.md +555 -0
- package/bin/sakk.mjs +1460 -0
- package/integrations/litellm/LICENSE +202 -0
- package/integrations/litellm/NOTICE +82 -0
- package/integrations/litellm/README.md +366 -0
- package/integrations/litellm/config.example.yaml +52 -0
- package/integrations/litellm/sakk_gateway.py +823 -0
- package/package.json +88 -4
- package/policies/guard-default.json +161 -0
- package/policies/permissive-dev.json +22 -0
- package/prices/snapshot.json +1404 -0
- package/src/append.mjs +81 -0
- package/src/archive.mjs +489 -0
- package/src/classify.mjs +914 -0
- package/src/console-login.mjs +374 -0
- package/src/console.mjs +1628 -0
- package/src/gateway-status.mjs +203 -0
- package/src/guard-install.mjs +387 -0
- package/src/hook.mjs +730 -0
- package/src/ledger.mjs +1229 -0
- package/src/match.mjs +52 -0
- package/src/mcp.mjs +299 -0
- package/src/policy.mjs +423 -0
- package/src/prices.mjs +152 -0
- package/src/secret-rules.generated.mjs +225 -0
- package/src/secret-scan.mjs +489 -0
- package/src/spend.mjs +443 -0
package/bin/sakk.mjs
ADDED
|
@@ -0,0 +1,1460 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import {
|
|
4
|
+
accessSync,
|
|
5
|
+
chmodSync,
|
|
6
|
+
constants as fsConstants,
|
|
7
|
+
existsSync,
|
|
8
|
+
lstatSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
realpathSync,
|
|
12
|
+
renameSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
statSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from 'node:fs';
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
18
|
+
import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
19
|
+
import { createInterface } from 'node:readline';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import {
|
|
22
|
+
initLedger,
|
|
23
|
+
readLedger,
|
|
24
|
+
totalsOf,
|
|
25
|
+
ledgerStatus,
|
|
26
|
+
clearReconciliation,
|
|
27
|
+
forceUnlock,
|
|
28
|
+
halt,
|
|
29
|
+
resume,
|
|
30
|
+
settle,
|
|
31
|
+
alertsPathFor,
|
|
32
|
+
LedgerError,
|
|
33
|
+
DEFAULT_MAX_AGE_MS,
|
|
34
|
+
} from '../src/ledger.mjs';
|
|
35
|
+
import { appendNoFollow } from '../src/append.mjs';
|
|
36
|
+
import { loadPolicy } from '../src/policy.mjs';
|
|
37
|
+
import { startMcpServer } from '../src/mcp.mjs';
|
|
38
|
+
import { display, scanPaths, scanStaged, ScanError } from '../src/secret-scan.mjs';
|
|
39
|
+
import {
|
|
40
|
+
evaluate,
|
|
41
|
+
normalize,
|
|
42
|
+
runHook,
|
|
43
|
+
readConfig,
|
|
44
|
+
writeConfig,
|
|
45
|
+
sakkHome,
|
|
46
|
+
resolveLedgerPath,
|
|
47
|
+
} from '../src/hook.mjs';
|
|
48
|
+
import { loadPrices } from '../src/prices.mjs';
|
|
49
|
+
import {
|
|
50
|
+
daySpend,
|
|
51
|
+
updateIndex,
|
|
52
|
+
systemZone,
|
|
53
|
+
dayOf,
|
|
54
|
+
claudeLogDir,
|
|
55
|
+
SpendError,
|
|
56
|
+
} from '../src/spend.mjs';
|
|
57
|
+
import { doctor, install, uninstall } from '../src/guard-install.mjs';
|
|
58
|
+
import {
|
|
59
|
+
checkHead,
|
|
60
|
+
checkSegmentFiles,
|
|
61
|
+
leftoverSegment,
|
|
62
|
+
readSegment,
|
|
63
|
+
reindex,
|
|
64
|
+
verifyChain,
|
|
65
|
+
} from '../src/archive.mjs';
|
|
66
|
+
import { startConsole } from '../src/console.mjs';
|
|
67
|
+
import { gatewayStatus } from '../src/gateway-status.mjs';
|
|
68
|
+
import { ConsoleLoginError, askConsoleLogin, clearConsoleLogin } from '../src/console-login.mjs';
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* sakk CLI — the smallest useful surface over the ledger.
|
|
72
|
+
*
|
|
73
|
+
* sakk init --cap <usd> [--ledger <path>] [--by <name>]
|
|
74
|
+
* sakk status [--ledger <path>] [--json]
|
|
75
|
+
* sakk reconcile --by <name> [--ledger <path>]
|
|
76
|
+
* sakk halt|resume --by <name> [--agent <id>] [--ledger <path>]
|
|
77
|
+
*
|
|
78
|
+
* Path resolution (resolveLedgerPath, shared with the hook): --ledger flag >
|
|
79
|
+
* the absolute path `sakk init` recorded in ~/.sakk/config.json >
|
|
80
|
+
* SAKK_LEDGER_PATH > ~/.sakk/sakk-ledger.json (the CLI's one explicit
|
|
81
|
+
* default, inside the guard's protected zone since D11/F-177 — always
|
|
82
|
+
* printed, never guessed silently; a leftover ./sakk-ledger.json refuses
|
|
83
|
+
* with LEDGER_PATH_LEGACY; the library itself keeps no default at all).
|
|
84
|
+
*
|
|
85
|
+
* Exit codes: 0 ok · 1 ledger error (every refusal code on stderr) ·
|
|
86
|
+
* 2 usage error · 3 status: reconciliation required (gate your CI on it) ·
|
|
87
|
+
* 4 status: globally halted. 4 wins over 3 when both hold; the text and
|
|
88
|
+
* --json output show both conditions.
|
|
89
|
+
*/
|
|
90
|
+
|
|
91
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
92
|
+
const pkg = JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8'));
|
|
93
|
+
|
|
94
|
+
const argv = process.argv.slice(2);
|
|
95
|
+
const cmd = argv[0];
|
|
96
|
+
const flag = (n) => argv.includes(`--${n}`);
|
|
97
|
+
const val = (n) => {
|
|
98
|
+
const i = argv.indexOf(`--${n}`);
|
|
99
|
+
if (i === -1) return undefined;
|
|
100
|
+
const v = argv[i + 1];
|
|
101
|
+
// A missing value must not swallow the next flag (review finding F3):
|
|
102
|
+
// `--ledger --json` used to create a ledger file literally named --json.
|
|
103
|
+
if (v === undefined || v.startsWith('--')) {
|
|
104
|
+
console.error(`--${n} requires a value`);
|
|
105
|
+
process.exit(2);
|
|
106
|
+
}
|
|
107
|
+
return v;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** The ledger by the hook's own rule (resolveLedgerPath), so a halt lands where the hook reads. */
|
|
111
|
+
function ledgerFrom(fallback) {
|
|
112
|
+
const flagged = val('ledger');
|
|
113
|
+
let config = {};
|
|
114
|
+
if (flagged === undefined)
|
|
115
|
+
try {
|
|
116
|
+
config = readConfig(sakkHome());
|
|
117
|
+
} catch (e) {
|
|
118
|
+
console.error(String(e.message));
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
const r = resolveLedgerPath({ flag: flagged, config, fallback });
|
|
122
|
+
const envPath = process.env.SAKK_LEDGER_PATH;
|
|
123
|
+
if (r.from === 'config' && envPath && resolve(envPath) !== r.path)
|
|
124
|
+
console.error(
|
|
125
|
+
`note: using ${r.path} from ${join(sakkHome(), 'config.json')}; SAKK_LEDGER_PATH (${envPath}) is not used — pass --ledger to choose another`,
|
|
126
|
+
);
|
|
127
|
+
return r;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function ledgerPath() {
|
|
131
|
+
// The default ledger lives under SAKK_HOME (D11, F-177): a ledger in the
|
|
132
|
+
// project cwd is a file the agent may write, so one agent could lift a
|
|
133
|
+
// halt on another by editing it. Under ~/.sakk it sits in the guard's
|
|
134
|
+
// protected zone (protect-guard-state denies the write).
|
|
135
|
+
const r = ledgerFrom(join(sakkHome(), 'sakk-ledger.json'));
|
|
136
|
+
// set-but-empty is a mistake, not "use the current directory" (ledger review N2)
|
|
137
|
+
if (r.from === 'env-empty') {
|
|
138
|
+
console.error('LEDGER_PATH_REQUIRED: SAKK_LEDGER_PATH is set but empty');
|
|
139
|
+
process.exit(2);
|
|
140
|
+
}
|
|
141
|
+
// Migration (three-tables spec, specs/spend/v2-line-c-three-tables.md): a
|
|
142
|
+
// ledger at the pre-v2 default must not be silently abandoned OR silently
|
|
143
|
+
// kept authoritative — refuse and let a person choose.
|
|
144
|
+
if (
|
|
145
|
+
r.from === 'default' &&
|
|
146
|
+
resolve('sakk-ledger.json') !== r.path &&
|
|
147
|
+
existsSync(resolve('sakk-ledger.json'))
|
|
148
|
+
) {
|
|
149
|
+
console.error(
|
|
150
|
+
`LEDGER_PATH_LEGACY: ./sakk-ledger.json exists here, but the default ledger now lives at ${r.path} — pass --ledger ./sakk-ledger.json to keep using this file, or move it to ${r.path}; nothing was read or changed`,
|
|
151
|
+
);
|
|
152
|
+
process.exit(2);
|
|
153
|
+
}
|
|
154
|
+
return r.path;
|
|
155
|
+
}
|
|
156
|
+
// npm exec / npx, bunx and pnpm dlx unpack into caches that get cleaned: a
|
|
157
|
+
// file that pins this installation's path must not point into one.
|
|
158
|
+
const EPHEMERAL =
|
|
159
|
+
/[\\/]_npx[\\/][0-9a-f]{6,}[\\/]node_modules[\\/]|[\\/]bunx-\d+-|[\\/]pnpm[\\/]dlx[\\/]/;
|
|
160
|
+
|
|
161
|
+
/** A name, not whitespace: self-declared and never verified (ledger review N1; F-70; F-126). */
|
|
162
|
+
const named = (by) => typeof by === 'string' && by.trim() !== '';
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* A change that loosens a control the agent is bound by (lifting or raising
|
|
166
|
+
* the cap, removing the guard) goes ahead only once a person retypes `word`
|
|
167
|
+
* at this terminal. A run with no terminal — an agent's tool call, a script —
|
|
168
|
+
* refuses, and nothing changes (F-171). This is not proof of a person: a
|
|
169
|
+
* process that makes its own pseudo-terminal can answer; the hook asks before
|
|
170
|
+
* the commands that do that (docs/GUARD.md).
|
|
171
|
+
*/
|
|
172
|
+
async function confirmByPerson(change, word) {
|
|
173
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
|
174
|
+
console.error(
|
|
175
|
+
`HUMAN_CONFIRMATION_REQUIRED: ${change} — a person confirms this at a terminal, and this run has none; nothing changed`,
|
|
176
|
+
);
|
|
177
|
+
process.exit(2);
|
|
178
|
+
}
|
|
179
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
180
|
+
const answer = await new Promise((done) => {
|
|
181
|
+
rl.on('close', () => done(null));
|
|
182
|
+
rl.on('SIGINT', () => rl.close());
|
|
183
|
+
rl.question(
|
|
184
|
+
`${change}.\nAn agent must not make this change, so a person confirms it here. Type ${word} to go ahead: `,
|
|
185
|
+
done,
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
rl.close();
|
|
189
|
+
if (answer?.trim() !== word) {
|
|
190
|
+
console.error(`HUMAN_CONFIRMATION_REQUIRED: the answer was not ${word}; nothing changed`);
|
|
191
|
+
process.exit(2);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Human display only — JSON keeps raw numbers. 6 decimals, trailing zeros
|
|
196
|
+
// trimmed, so the 1e-6 reserve guard never prints a float tail.
|
|
197
|
+
const usd = (n) =>
|
|
198
|
+
`$${Number(n)
|
|
199
|
+
.toFixed(6)
|
|
200
|
+
.replace(/(\.\d*?)0+$/, '$1')
|
|
201
|
+
.replace(/\.$/, '')}`;
|
|
202
|
+
|
|
203
|
+
const USAGE = `sakk ${pkg.version} — fail-closed spend ledger for AI agents
|
|
204
|
+
|
|
205
|
+
Usage:
|
|
206
|
+
sakk init --cap <usd> [--ledger <path>] [--by <name>] create a ledger (refuses to overwrite)
|
|
207
|
+
sakk status [--ledger <path>] [--json] totals, entries, reconciliation state
|
|
208
|
+
sakk reconcile --by <name> [--ledger <path>] clear the reconciliation flag (named)
|
|
209
|
+
sakk unlock --by <name> [--ledger <path>] break a stuck lock by hand (named)
|
|
210
|
+
sakk settle <callId> --by <name> [--ledger <path>] release a stuck reservation at its reserved amount (named)
|
|
211
|
+
sakk halt --by <name> [--reason <t>] [--agent <id>] kill switch: refuse all new spending (named)
|
|
212
|
+
sakk resume --by <name> [--agent <id>] lift a halt (named)
|
|
213
|
+
sakk mcp --ledger <path> [--policy <path>] MCP stdio server: reserve/settle/status as tools
|
|
214
|
+
sakk ledger verify [--ledger <path>] walk the cold trail's chain and index (exit 1 if broken)
|
|
215
|
+
sakk ledger export [--out <file>] [--ledger <path>] rebuild one flat ledger from cold + hot
|
|
216
|
+
sakk ledger reindex [--ledger <path>] rebuild the call-id index from the cold trail
|
|
217
|
+
sakk scan <paths...> | --staged | --install-hook find secrets (fingerprints only; exit 1 on findings)
|
|
218
|
+
|
|
219
|
+
The agent guard:
|
|
220
|
+
sakk guard install|uninstall <agent> [--user] add or remove the hook (claude-code | cursor | codex; removing asks you to confirm)
|
|
221
|
+
sakk try "<command>" [--read <path>] [--json] how the guard would judge a command (nothing runs)
|
|
222
|
+
sakk doctor [--json] check the guard really fires where you work
|
|
223
|
+
sakk hook --agent <agent> the hook itself: payload on stdin (installed for you)
|
|
224
|
+
sakk ui [--port N] [--allow-halt] [--ledger <path>] a local, read-only console on 127.0.0.1 (token in the URL)
|
|
225
|
+
sakk ui --set-password | --clear-password add or remove an optional sign-in (asked at a terminal)
|
|
226
|
+
|
|
227
|
+
Spend and the daily cap (from Claude Code's local logs; an estimate, not the invoice):
|
|
228
|
+
sakk cap set <usd> [--zone <IANA>] a daily cap the hook enforces at the next call (raising it asks you to confirm)
|
|
229
|
+
sakk cap off lift the cap (asks you to confirm)
|
|
230
|
+
sakk spend [--day <YYYY-MM-DD>] [--json] today's spend by model, and what could not be priced
|
|
231
|
+
sakk prices [--json] the price table in use and its source
|
|
232
|
+
|
|
233
|
+
Your own app, any language (OpenAI-compatible, through the LiteLLM proxy):
|
|
234
|
+
sakk gateway init [--dir <path>] [--ledger <path>] [--port N] [--litellm <path>] [--force]
|
|
235
|
+
write the gateway's files and start script, then print the one line your app changes
|
|
236
|
+
(dir: <SAKK_HOME or ~/.sakk>/gateway · ledger: as below · port: 4000 · refuses to overwrite without --force)
|
|
237
|
+
|
|
238
|
+
Ledger path: --ledger > the one init recorded in ~/.sakk/config.json (the hook reads it too) > $SAKK_LEDGER_PATH > ~/.sakk/sakk-ledger.json
|
|
239
|
+
(a leftover ./sakk-ledger.json refuses with LEDGER_PATH_LEGACY until you pass --ledger or move it)
|
|
240
|
+
Exit codes: 0 ok · 1 ledger error · 2 usage error · 3 reconciliation required (status) · 4 halted (status; wins over 3, both shown)
|
|
241
|
+
max_reservable_usd of 0 means nothing fits under the cap (reserve requires a positive amount)
|
|
242
|
+
`;
|
|
243
|
+
|
|
244
|
+
function fail(e) {
|
|
245
|
+
if (e instanceof LedgerError) {
|
|
246
|
+
console.error(`${e.code}: ${e.message.slice(e.code.length + 2)}`);
|
|
247
|
+
if (Object.keys(e.details ?? {}).length) console.error(JSON.stringify(e.details));
|
|
248
|
+
} else {
|
|
249
|
+
console.error(String(e?.message ?? e));
|
|
250
|
+
}
|
|
251
|
+
process.exit(1);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Every flag each command accepts. Anything else refuses (exit 2) instead of
|
|
255
|
+
// being ignored: `sakk resume --by op --agent=bot` used to lift the GLOBAL
|
|
256
|
+
// halt because the `=` form was not recognised (verify pass S1).
|
|
257
|
+
const COMMANDS = {
|
|
258
|
+
init: { values: ['cap', 'ledger', 'by'], bools: [] },
|
|
259
|
+
status: { values: ['ledger'], bools: ['json'] },
|
|
260
|
+
reconcile: { values: ['by', 'ledger'], bools: [] },
|
|
261
|
+
unlock: { values: ['by', 'ledger'], bools: [] },
|
|
262
|
+
settle: { values: ['by', 'ledger'], bools: [], positional: true },
|
|
263
|
+
halt: { values: ['by', 'reason', 'agent', 'ledger'], bools: [] },
|
|
264
|
+
resume: { values: ['by', 'agent', 'ledger'], bools: [] },
|
|
265
|
+
mcp: { values: ['ledger', 'policy'], bools: [] },
|
|
266
|
+
ledger: { values: ['ledger', 'out'], bools: [], positional: true },
|
|
267
|
+
scan: { values: [], bools: ['staged', 'install-hook'], positional: true },
|
|
268
|
+
hook: { values: ['agent'], bools: [] },
|
|
269
|
+
try: { values: ['read'], bools: ['json'], positional: true },
|
|
270
|
+
guard: { values: [], bools: ['user'], positional: true },
|
|
271
|
+
doctor: { values: [], bools: ['json'] },
|
|
272
|
+
ui: { values: ['port', 'ledger'], bools: ['allow-halt', 'set-password', 'clear-password'] },
|
|
273
|
+
cap: { values: ['zone'], bools: [], positional: true },
|
|
274
|
+
spend: { values: ['day'], bools: ['json'] },
|
|
275
|
+
prices: { values: [], bools: ['json'] },
|
|
276
|
+
gateway: { values: ['dir', 'ledger', 'port', 'litellm'], bools: ['force'], positional: true },
|
|
277
|
+
};
|
|
278
|
+
if (Object.hasOwn(COMMANDS, cmd)) {
|
|
279
|
+
const spec = COMMANDS[cmd];
|
|
280
|
+
const seen = new Set();
|
|
281
|
+
const usage = (code, msg) => {
|
|
282
|
+
console.error(`${code}: ${msg} — see sakk --help`);
|
|
283
|
+
process.exit(2);
|
|
284
|
+
};
|
|
285
|
+
for (let i = 1; i < argv.length; i += 1) {
|
|
286
|
+
const a = argv[i];
|
|
287
|
+
// `sakk ui` never echoes an argument back: it may be a password typed on argv
|
|
288
|
+
const hide = cmd === 'ui';
|
|
289
|
+
if (/^--[^=]+=/.test(a))
|
|
290
|
+
usage(
|
|
291
|
+
'USAGE_EQUALS_FORM',
|
|
292
|
+
hide ? 'write the value after a space' : `write "${a.replace('=', ' ')}"`,
|
|
293
|
+
);
|
|
294
|
+
if (!a.startsWith('--')) {
|
|
295
|
+
if (!spec.positional)
|
|
296
|
+
usage(
|
|
297
|
+
'USAGE_UNKNOWN_ARGUMENT',
|
|
298
|
+
hide
|
|
299
|
+
? 'sakk ui takes no positional argument (not shown, in case it is a password); sakk ui --set-password asks for a password at the terminal'
|
|
300
|
+
: `"${a}" is not an argument of ${cmd}`,
|
|
301
|
+
);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const name = a.slice(2);
|
|
305
|
+
if (!spec.values.includes(name) && !spec.bools.includes(name))
|
|
306
|
+
usage(
|
|
307
|
+
'USAGE_UNKNOWN_ARGUMENT',
|
|
308
|
+
hide
|
|
309
|
+
? `that is not a flag of ui (not shown; the flags are ${[...spec.values, ...spec.bools].map((f) => `--${f}`).join(', ')})`
|
|
310
|
+
: `${a} is not a flag of ${cmd}`,
|
|
311
|
+
);
|
|
312
|
+
if (seen.has(name)) usage('USAGE_REPEATED_FLAG', `${a} given more than once`);
|
|
313
|
+
seen.add(name);
|
|
314
|
+
if (spec.values.includes(name)) i += 1; // val() refuses a missing value
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (cmd === 'init') {
|
|
319
|
+
const capArg = val('cap');
|
|
320
|
+
// plain decimals only: Number() also accepts 0x10, 1e3 and ' 5' (review N12)
|
|
321
|
+
const cap = /^\d+(\.\d+)?$/.test(capArg ?? '') ? Number(capArg) : NaN;
|
|
322
|
+
if (!Number.isFinite(cap) || cap <= 0) {
|
|
323
|
+
console.error('init requires --cap <usd> (a positive number)');
|
|
324
|
+
process.exit(2);
|
|
325
|
+
}
|
|
326
|
+
const p = ledgerPath();
|
|
327
|
+
if (val('by') !== undefined && !named(val('by'))) {
|
|
328
|
+
console.error('init --by needs a name');
|
|
329
|
+
process.exit(2);
|
|
330
|
+
}
|
|
331
|
+
let refused = null;
|
|
332
|
+
try {
|
|
333
|
+
initLedger({ ledgerPath: p, capUsd: cap, initializedBy: val('by') ?? 'sakk-cli' });
|
|
334
|
+
} catch (e) {
|
|
335
|
+
if (!(e instanceof LedgerError && e.code === 'LEDGER_ALREADY_EXISTS')) fail(e);
|
|
336
|
+
refused = e;
|
|
337
|
+
}
|
|
338
|
+
if (!refused) console.log(`ledger created: ${p}`);
|
|
339
|
+
// Record the ledger for the hook so `sakk halt` reaches it (F-172). Re-running
|
|
340
|
+
// init wires an existing ledger too, and never replaces a different recorded one.
|
|
341
|
+
const home = sakkHome();
|
|
342
|
+
let recorded;
|
|
343
|
+
try {
|
|
344
|
+
if (refused) readLedger({ ledgerPath: p, maxAgeMs: Number.POSITIVE_INFINITY });
|
|
345
|
+
recorded = readConfig(home).ledgerPath;
|
|
346
|
+
} catch (e) {
|
|
347
|
+
console.error(`the hook is not wired to ${p}: ${String(e.message)}`);
|
|
348
|
+
if (refused) fail(refused);
|
|
349
|
+
process.exit(1);
|
|
350
|
+
}
|
|
351
|
+
const configFile = join(home, 'config.json');
|
|
352
|
+
if (typeof recorded !== 'string' || recorded === '') {
|
|
353
|
+
writeConfig(home, { ledgerPath: p });
|
|
354
|
+
console.log(`hook wired: a halt on this ledger now stops the guarded agent (${configFile})`);
|
|
355
|
+
} else if (resolve(recorded) !== p)
|
|
356
|
+
console.error(
|
|
357
|
+
`note: the hook still reads halts from ${recorded} (${configFile}); this ledger is not wired — change ledgerPath there to switch`,
|
|
358
|
+
);
|
|
359
|
+
if (refused) fail(refused);
|
|
360
|
+
console.log(`cap: $${cap} — reserve before you spawn; reaching the cap refuses.`);
|
|
361
|
+
} else if (cmd === 'status') {
|
|
362
|
+
const p = ledgerPath();
|
|
363
|
+
let doc;
|
|
364
|
+
try {
|
|
365
|
+
// Reading the books is not spending: status may inspect a stale ledger.
|
|
366
|
+
doc = readLedger({ ledgerPath: p, maxAgeMs: Number.POSITIVE_INFINITY });
|
|
367
|
+
} catch (e) {
|
|
368
|
+
fail(e);
|
|
369
|
+
}
|
|
370
|
+
// one function with the console's ledger page, so the two cannot drift
|
|
371
|
+
// the gateway key is the gateway's own report (null without a status
|
|
372
|
+
// file); it does not change the exit code
|
|
373
|
+
const out = {
|
|
374
|
+
...ledgerStatus({ ledgerPath: p, doc }),
|
|
375
|
+
gateway: gatewayStatus({ ledgerPath: p }),
|
|
376
|
+
};
|
|
377
|
+
const gw = out.gateway;
|
|
378
|
+
const t = totalsOf(doc);
|
|
379
|
+
const byStatus = out.entries;
|
|
380
|
+
const reco = out.reconciliation_required;
|
|
381
|
+
const stale = out.stale;
|
|
382
|
+
if (flag('json')) {
|
|
383
|
+
console.log(JSON.stringify(out, null, 1));
|
|
384
|
+
} else {
|
|
385
|
+
console.log(`ledger ${p}`);
|
|
386
|
+
console.log(`cap ${usd(t.capUsd)}`);
|
|
387
|
+
console.log(`settled ${usd(t.settledUsd)}`);
|
|
388
|
+
console.log(`outstanding ${usd(t.outstandingUsd)}`);
|
|
389
|
+
console.log(
|
|
390
|
+
`committed ${usd(t.committedUsd)} (remaining ${usd(out.remaining_usd)}, max reservable ${usd(out.max_reservable_usd)})`,
|
|
391
|
+
);
|
|
392
|
+
console.log(
|
|
393
|
+
`entries ${doc.entries.length} (reserved ${byStatus.RESERVED}, settled ${byStatus.SETTLED}, reconciliation ${byStatus.RECONCILIATION_REQUIRED})`,
|
|
394
|
+
);
|
|
395
|
+
console.log(`updated ${doc.updatedAt}`);
|
|
396
|
+
// the status file is hand-editable: its text is escaped like any name
|
|
397
|
+
if (gw?.error)
|
|
398
|
+
console.log(`gateway ⚠ status file unreadable (${gw.error}): ${display(gw.message)}`);
|
|
399
|
+
else if (gw?.outage && gw.mode === 'deny')
|
|
400
|
+
console.log(
|
|
401
|
+
`gateway ⚠ SAKK UNREACHABLE since ${display(gw.outage.since)}: deny mode, calls refused with HTTP 503; ${gw.outage.allowed_calls} call(s) let through unmetered earlier, up to ${usd(gw.outage.est_max_unrecorded_usd)} not in this ledger (${display(gw.outage.last_reason)})`,
|
|
402
|
+
);
|
|
403
|
+
else if (gw?.outage)
|
|
404
|
+
console.log(
|
|
405
|
+
`gateway ⚠ SAKK UNREACHABLE since ${display(gw.outage.since)}: ${gw.outage.allowed_calls} call(s) let through unmetered, up to ${usd(gw.outage.est_max_unrecorded_usd)} not in this ledger (${display(gw.outage.last_reason)})`,
|
|
406
|
+
);
|
|
407
|
+
else if (gw?.last_outage)
|
|
408
|
+
console.log(
|
|
409
|
+
`gateway no outage now; last one ${display(gw.last_outage.since)} to ${display(gw.last_outage.ended_at)}: ${gw.last_outage.allowed_calls} call(s) unmetered, up to ${usd(gw.last_outage.est_max_unrecorded_usd)} not in this ledger`,
|
|
410
|
+
);
|
|
411
|
+
else if (gw) console.log(`gateway no outage recorded (mode ${display(String(gw.mode))})`);
|
|
412
|
+
// names and reasons are operator input: escaped like file names, so a
|
|
413
|
+
// hostile `--by` cannot write terminal control sequences (review nit)
|
|
414
|
+
if (out.halted)
|
|
415
|
+
console.log(
|
|
416
|
+
`⛔ HALTED by ${display(out.halted.by)} at ${out.halted.at}${out.halted.reason ? ` — ${display(out.halted.reason)}` : ''} (all new spending refused)`,
|
|
417
|
+
);
|
|
418
|
+
for (const [id, r] of Object.entries(out.halted_agents))
|
|
419
|
+
console.log(
|
|
420
|
+
`⛔ agent ${display(id)} halted by ${display(r.by)} at ${r.at}${r.reason ? ` — ${display(r.reason)}` : ''}`,
|
|
421
|
+
);
|
|
422
|
+
// the reason embeds agent-chosen callIds: escaped like every other name (S7)
|
|
423
|
+
if (reco)
|
|
424
|
+
console.log(
|
|
425
|
+
`⚠ RECONCILIATION REQUIRED: ${display(String(out.reconciliation_reason ?? 'an entry awaits reconciliation'))}`,
|
|
426
|
+
);
|
|
427
|
+
if (stale)
|
|
428
|
+
console.log(
|
|
429
|
+
`⚠ STALE: the books were last confirmed ${doc.updatedAt} (more than 7 days ago) — every reserve refuses until a person checks them and runs \`sakk reconcile --by <name>\``,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
// 3 = a person must act before spending resumes (reconciliation or stale)
|
|
433
|
+
process.exit(out.halted ? 4 : reco || stale ? 3 : 0);
|
|
434
|
+
} else if (cmd === 'reconcile') {
|
|
435
|
+
const by = val('by');
|
|
436
|
+
if (!named(by)) {
|
|
437
|
+
console.error('reconcile requires --by <name> — clearing the books takes a name');
|
|
438
|
+
process.exit(2);
|
|
439
|
+
}
|
|
440
|
+
const p = ledgerPath();
|
|
441
|
+
// Stranger-test finding: claiming "cleared" on a ledger that required no
|
|
442
|
+
// clearing misreports an action that never happened. Say so instead.
|
|
443
|
+
// Read like the operation reads (review S3): a stale ledger is exactly
|
|
444
|
+
// what a named reconcile is for.
|
|
445
|
+
let doc;
|
|
446
|
+
try {
|
|
447
|
+
doc = readLedger({ ledgerPath: p, maxAgeMs: Number.POSITIVE_INFINITY });
|
|
448
|
+
} catch (e) {
|
|
449
|
+
fail(e);
|
|
450
|
+
}
|
|
451
|
+
const stale = Date.now() - Date.parse(doc.updatedAt) > DEFAULT_MAX_AGE_MS;
|
|
452
|
+
if (doc.flags?.reconciliationRequired !== true && !stale) {
|
|
453
|
+
console.log(`nothing to clear: no reconciliation required on ${p}`);
|
|
454
|
+
process.exit(0);
|
|
455
|
+
}
|
|
456
|
+
// Clearing the books loosens a control the agent is bound by, so a person
|
|
457
|
+
// confirms it at this terminal; the hook denies the command from an agent
|
|
458
|
+
// (F-177, D11). The no-op path above never asks.
|
|
459
|
+
await confirmByPerson(`This clears the books (reconciliation) on ${p}`, 'reconcile');
|
|
460
|
+
try {
|
|
461
|
+
clearReconciliation({ ledgerPath: p, by });
|
|
462
|
+
} catch (e) {
|
|
463
|
+
fail(e);
|
|
464
|
+
}
|
|
465
|
+
console.log(
|
|
466
|
+
`${stale && doc.flags?.reconciliationRequired !== true ? 'stale books confirmed' : 'reconciliation cleared'} by ${display(by)}: ${p}`,
|
|
467
|
+
);
|
|
468
|
+
} else if (cmd === 'halt' || cmd === 'resume') {
|
|
469
|
+
const by = val('by');
|
|
470
|
+
if (typeof by !== 'string' || by === '') {
|
|
471
|
+
console.error(`${cmd} requires --by <name> — stopping and restarting spend takes a name`);
|
|
472
|
+
process.exit(2);
|
|
473
|
+
}
|
|
474
|
+
const p = ledgerPath();
|
|
475
|
+
const agentId = val('agent');
|
|
476
|
+
if (agentId === '') {
|
|
477
|
+
// An empty id must not silently widen to "all spending" in either direction.
|
|
478
|
+
console.error(`AGENT_ID_INVALID: --agent needs a non-empty id (exact, case-sensitive)`);
|
|
479
|
+
process.exit(2);
|
|
480
|
+
}
|
|
481
|
+
const scopeText = agentId !== undefined ? `agent ${JSON.stringify(agentId)}` : 'all spending';
|
|
482
|
+
if (cmd === 'resume') {
|
|
483
|
+
// Read first: lifting nothing asks no one (the guard-uninstall precedent).
|
|
484
|
+
// Lifting a halt loosens the control that binds the agent, so a person
|
|
485
|
+
// confirms it; the hook denies the command from an agent (F-177, D11).
|
|
486
|
+
let h;
|
|
487
|
+
try {
|
|
488
|
+
h = readLedger({ ledgerPath: p, maxAgeMs: Number.POSITIVE_INFINITY }).halt;
|
|
489
|
+
} catch (e) {
|
|
490
|
+
fail(e);
|
|
491
|
+
}
|
|
492
|
+
const haltedNow =
|
|
493
|
+
agentId !== undefined
|
|
494
|
+
? Boolean(h?.agents && Object.hasOwn(h.agents, agentId))
|
|
495
|
+
: Boolean(h?.global);
|
|
496
|
+
if (!haltedNow) {
|
|
497
|
+
// Answer here, without calling resume(): entering the write path now
|
|
498
|
+
// would lift a halt that landed after the unlocked read above, with no
|
|
499
|
+
// confirmation (F-209). reconcile and unlock exit their no-op the same
|
|
500
|
+
// way, before their confirmation and their write.
|
|
501
|
+
console.log(`nothing halted (${scopeText})`);
|
|
502
|
+
process.exit(0);
|
|
503
|
+
}
|
|
504
|
+
await confirmByPerson(`This lifts the halt on ${scopeText} (${p})`, 'resume');
|
|
505
|
+
}
|
|
506
|
+
// A runaway storm is exactly when the lock is contended: retry rather
|
|
507
|
+
// than let a kill switch lose a race (specs/kill-switch row 5).
|
|
508
|
+
let r;
|
|
509
|
+
for (let attempt = 1; ; attempt++) {
|
|
510
|
+
try {
|
|
511
|
+
r =
|
|
512
|
+
cmd === 'halt'
|
|
513
|
+
? halt({ ledgerPath: p, by, reason: val('reason') ?? '', agentId })
|
|
514
|
+
: resume({ ledgerPath: p, by, agentId });
|
|
515
|
+
break;
|
|
516
|
+
} catch (e) {
|
|
517
|
+
if (e instanceof LedgerError && e.code === 'LEDGER_LOCK_UNAVAILABLE' && attempt < 15)
|
|
518
|
+
continue;
|
|
519
|
+
fail(e);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
const scope = scopeText;
|
|
523
|
+
if (cmd === 'halt')
|
|
524
|
+
console.log(
|
|
525
|
+
r.alreadyHalted
|
|
526
|
+
? `already halted (${scope}) by ${r.halt.by} at ${r.halt.at} — original record kept`
|
|
527
|
+
: `HALTED ${scope} by ${by}: every new reservation now refuses. Refusals are logged to sakk-alerts.jsonl beside the ledger.`,
|
|
528
|
+
);
|
|
529
|
+
else
|
|
530
|
+
console.log(
|
|
531
|
+
r.wasHalted
|
|
532
|
+
? `resumed ${scope} by ${by} (was halted by ${r.lifted.by})`
|
|
533
|
+
: `nothing halted (${scope})`,
|
|
534
|
+
);
|
|
535
|
+
} else if (cmd === 'unlock') {
|
|
536
|
+
const by = val('by');
|
|
537
|
+
if (!named(by)) {
|
|
538
|
+
console.error('unlock requires --by <name> — breaking a lock takes a name');
|
|
539
|
+
process.exit(2);
|
|
540
|
+
}
|
|
541
|
+
const p = ledgerPath();
|
|
542
|
+
// No lock, nothing to break, no one asked (the guard-uninstall precedent).
|
|
543
|
+
// Breaking a live lock loosens the serialization every write relies on, so
|
|
544
|
+
// a person confirms it; the hook denies the command from an agent (F-177).
|
|
545
|
+
if (!existsSync(`${p}.lock`)) {
|
|
546
|
+
console.log(`no lock present: ${p}.lock`);
|
|
547
|
+
process.exit(0);
|
|
548
|
+
}
|
|
549
|
+
await confirmByPerson(`This breaks the lock on ${p}`, 'unlock');
|
|
550
|
+
let r;
|
|
551
|
+
try {
|
|
552
|
+
r = forceUnlock({ ledgerPath: p, by });
|
|
553
|
+
} catch (e) {
|
|
554
|
+
fail(e);
|
|
555
|
+
}
|
|
556
|
+
console.log(r.removed ? `lock removed by ${by}: ${p}.lock` : `no lock present: ${p}.lock`);
|
|
557
|
+
} else if (cmd === 'settle') {
|
|
558
|
+
// The call id is the one word that is not a flag or a flag's value; the
|
|
559
|
+
// parser above has already refused unknown and repeated flags.
|
|
560
|
+
const ids = [];
|
|
561
|
+
for (let i = 1; i < argv.length; i += 1) {
|
|
562
|
+
if (argv[i].startsWith('--')) i += 1;
|
|
563
|
+
else ids.push(argv[i]);
|
|
564
|
+
}
|
|
565
|
+
const by = val('by');
|
|
566
|
+
if (!named(by) || ids.length !== 1 || ids[0] === '') {
|
|
567
|
+
console.error(
|
|
568
|
+
'settle requires one call id and --by <name> — releasing a reservation takes a name: sakk settle <callId> --by <name>',
|
|
569
|
+
);
|
|
570
|
+
process.exit(2);
|
|
571
|
+
}
|
|
572
|
+
const [callId] = ids;
|
|
573
|
+
const p = ledgerPath();
|
|
574
|
+
let doc;
|
|
575
|
+
try {
|
|
576
|
+
doc = readLedger({ ledgerPath: p, maxAgeMs: Number.POSITIVE_INFINITY });
|
|
577
|
+
} catch (e) {
|
|
578
|
+
fail(e);
|
|
579
|
+
}
|
|
580
|
+
const e = doc.entries.find((x) => x.callId === callId);
|
|
581
|
+
// Nothing held, nothing to release, no one asked (the reconcile and unlock
|
|
582
|
+
// precedent). An id gone from the hot file is either sealed in the cold
|
|
583
|
+
// trail (settled) or was never reserved: the ledger's own lookup tells them
|
|
584
|
+
// apart, and with no amount it changes nothing.
|
|
585
|
+
if (e === undefined) {
|
|
586
|
+
try {
|
|
587
|
+
settle({ ledgerPath: p, callId });
|
|
588
|
+
} catch (err) {
|
|
589
|
+
fail(err);
|
|
590
|
+
}
|
|
591
|
+
console.log(`nothing to settle: ${display(callId)} is already settled (sealed): ${p}`);
|
|
592
|
+
process.exit(0);
|
|
593
|
+
}
|
|
594
|
+
if (e.status === 'SETTLED') {
|
|
595
|
+
console.log(
|
|
596
|
+
`nothing to settle: ${display(callId)} is already settled at ${usd(e.actualUsd)}: ${p}`,
|
|
597
|
+
);
|
|
598
|
+
process.exit(0);
|
|
599
|
+
}
|
|
600
|
+
// A failed call of unknown cost is a person's figure to decide, not the
|
|
601
|
+
// reservation's: settling it at the worst case here would close the
|
|
602
|
+
// question without anyone answering it (F-242).
|
|
603
|
+
if (e.status !== 'RESERVED') {
|
|
604
|
+
console.error(
|
|
605
|
+
`SETTLE_AWAITS_RECONCILIATION: ${display(callId)} is ${display(String(e.status))}, not a held reservation — settle it with the figure you decided on (settle() with actualUsd, see the README), then run sakk reconcile --by <name>; nothing changed`,
|
|
606
|
+
);
|
|
607
|
+
process.exit(1);
|
|
608
|
+
}
|
|
609
|
+
// The dollars stay counted (the worst case is booked as spent), but the
|
|
610
|
+
// entry stops counting toward the outstanding limit (LEDGER_FULL) and its
|
|
611
|
+
// real cost is closed unanswered — a person's call, confirmed at this
|
|
612
|
+
// terminal; the hook denies the command from an agent (F-177, F-242).
|
|
613
|
+
const amount = e.estimatedMaxUsd;
|
|
614
|
+
await confirmByPerson(
|
|
615
|
+
`This settles reservation ${display(callId)} at its reserved ${usd(amount)}, the most it could have cost (${p})`,
|
|
616
|
+
'settle',
|
|
617
|
+
);
|
|
618
|
+
// settle() re-reads the entry under the lock: a real settle that landed
|
|
619
|
+
// since the read above refuses (SETTLE_MISMATCH) instead of being replaced.
|
|
620
|
+
let r;
|
|
621
|
+
for (let attempt = 1; ; attempt++) {
|
|
622
|
+
try {
|
|
623
|
+
r = settle({ ledgerPath: p, callId, actualUsd: amount, allowReconciling: false });
|
|
624
|
+
break;
|
|
625
|
+
} catch (err) {
|
|
626
|
+
if (err instanceof LedgerError && err.code === 'LEDGER_LOCK_UNAVAILABLE' && attempt < 15)
|
|
627
|
+
continue;
|
|
628
|
+
fail(err);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (r.duplicate) {
|
|
632
|
+
console.log(`nothing to settle: ${display(callId)} was settled at ${usd(amount)} meanwhile`);
|
|
633
|
+
process.exit(0);
|
|
634
|
+
}
|
|
635
|
+
const alerts = alertsPathFor(p);
|
|
636
|
+
try {
|
|
637
|
+
appendNoFollow(
|
|
638
|
+
alerts,
|
|
639
|
+
`${JSON.stringify({ at: new Date().toISOString(), kind: 'SETTLED_AT_RESERVED', callId, usd: amount, by, agentId: e.agentId ?? null })}\n`,
|
|
640
|
+
);
|
|
641
|
+
} catch (err) {
|
|
642
|
+
console.error(
|
|
643
|
+
`${display(callId)} is settled at ${usd(amount)}, but who did it could not be written to ${alerts} (${err.code ?? err.message}) — record it by hand`,
|
|
644
|
+
);
|
|
645
|
+
process.exit(1);
|
|
646
|
+
}
|
|
647
|
+
console.log(
|
|
648
|
+
`settled ${display(callId)} at its reserved ${usd(amount)} by ${display(by)}: ${p} (logged to ${alerts})`,
|
|
649
|
+
);
|
|
650
|
+
} else if (cmd === 'mcp') {
|
|
651
|
+
// The MCP gate takes NO default ledger path: an MCP server's cwd is
|
|
652
|
+
// whatever the agent host chose, and a gate quietly booking into a
|
|
653
|
+
// guessed file is not a gate (specs/mcp-server/ fail-closed table).
|
|
654
|
+
const raw = val('ledger') ?? (process.env.SAKK_LEDGER_PATH || undefined);
|
|
655
|
+
// resolved once, here: the host's cwd must not decide where the books are
|
|
656
|
+
// on every call (MCP review N4)
|
|
657
|
+
const p = typeof raw === 'string' && raw !== '' ? resolve(raw) : raw;
|
|
658
|
+
if (typeof p !== 'string' || p === '') {
|
|
659
|
+
console.error(
|
|
660
|
+
'MCP_LEDGER_PATH_REQUIRED: the MCP gate needs an explicit ledger (--ledger or SAKK_LEDGER_PATH)',
|
|
661
|
+
);
|
|
662
|
+
process.exit(2);
|
|
663
|
+
}
|
|
664
|
+
// Review F8: SAKK_POLICY_PATH set-but-empty is a configured policy that
|
|
665
|
+
// cannot load — refusing beats silently running without a gate.
|
|
666
|
+
const envPolicy = 'SAKK_POLICY_PATH' in process.env ? process.env.SAKK_POLICY_PATH : undefined;
|
|
667
|
+
const policyPath = val('policy') ?? envPolicy;
|
|
668
|
+
let loaded = null;
|
|
669
|
+
if (policyPath !== undefined) {
|
|
670
|
+
try {
|
|
671
|
+
loaded = loadPolicy({ policyPath });
|
|
672
|
+
} catch (e) {
|
|
673
|
+
console.error(String(e.message));
|
|
674
|
+
process.exit(2);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
startMcpServer({
|
|
678
|
+
ledgerPath: p,
|
|
679
|
+
loaded,
|
|
680
|
+
decisionLogPath: `${resolve(p)}.decisions.jsonl`,
|
|
681
|
+
input: process.stdin,
|
|
682
|
+
output: process.stdout,
|
|
683
|
+
version: pkg.version,
|
|
684
|
+
});
|
|
685
|
+
} else if (cmd === 'scan') {
|
|
686
|
+
// process.exitCode, never process.exit: an early exit truncates piped
|
|
687
|
+
// output (the summary line could be lost behind a slow reader).
|
|
688
|
+
const report = (r) => {
|
|
689
|
+
for (const f of r.findings) console.log(`${f.path}:${f.line} ${f.rule} fp=${f.fingerprint}`);
|
|
690
|
+
const skips = Object.entries(r.skipped ?? {})
|
|
691
|
+
.filter(([, n]) => n > 0)
|
|
692
|
+
.map(([k, n]) => `${n} ${k}`)
|
|
693
|
+
.join(', ');
|
|
694
|
+
console.log(
|
|
695
|
+
`scanned ${r.scanned} file(s): ${r.findings.length} finding(s), ${r.exempted} exempted (TEST_ONLY_FAKE_SECRET_DO_NOT_USE marker)${skips ? `; skipped ${skips}` : ''}`,
|
|
696
|
+
);
|
|
697
|
+
if (r.unreadable.length > 0) {
|
|
698
|
+
// A scan that could not look everywhere it was told to is not clean.
|
|
699
|
+
console.error(
|
|
700
|
+
`SCAN_UNREADABLE: ${r.unreadable.length} path(s) could not be read: ${r.unreadable.slice(0, 5).join(', ')}`,
|
|
701
|
+
);
|
|
702
|
+
process.exitCode = 2;
|
|
703
|
+
} else process.exitCode = r.findings.length === 0 ? 0 : 1;
|
|
704
|
+
};
|
|
705
|
+
const usage = (msg) => {
|
|
706
|
+
console.error(`SCAN_USAGE: ${msg}`);
|
|
707
|
+
process.exitCode = 2;
|
|
708
|
+
};
|
|
709
|
+
const paths = argv.slice(1).filter((a) => !a.startsWith('--'));
|
|
710
|
+
if (flag('install-hook') && (paths.length > 0 || flag('staged'))) {
|
|
711
|
+
usage('--install-hook takes no paths and no --staged');
|
|
712
|
+
} else if (flag('install-hook')) {
|
|
713
|
+
let hooksDir;
|
|
714
|
+
try {
|
|
715
|
+
hooksDir = execFileSync('git', ['rev-parse', '--git-path', 'hooks'], {
|
|
716
|
+
encoding: 'utf8',
|
|
717
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
718
|
+
}).trim();
|
|
719
|
+
} catch (e) {
|
|
720
|
+
console.error(
|
|
721
|
+
e.code === 'ENOENT'
|
|
722
|
+
? 'SCAN_GIT_MISSING: git is not on PATH'
|
|
723
|
+
: 'SCAN_NOT_A_REPO: run --install-hook inside a git repository',
|
|
724
|
+
);
|
|
725
|
+
process.exit(2);
|
|
726
|
+
}
|
|
727
|
+
// pre-merge-commit too: a merge commit never runs pre-commit (review S2)
|
|
728
|
+
const hooks = ['pre-commit', 'pre-merge-commit'].map((h) => join(hooksDir, h));
|
|
729
|
+
// Review S-1: never `npx sakk` at commit time — the hook runs THIS
|
|
730
|
+
// installation by absolute path. An installation inside the npx cache
|
|
731
|
+
// is temporary: once the cache is cleaned every commit would fail.
|
|
732
|
+
const self = realpathSync(fileURLToPath(import.meta.url));
|
|
733
|
+
if (EPHEMERAL.test(self)) {
|
|
734
|
+
console.error(
|
|
735
|
+
'SCAN_HOOK_EPHEMERAL: this sakk runs from the temporary npx cache — install it first (npm install --save-dev @sakk-dev/sakk, or npm install -g @sakk-dev/sakk), then run the installed `sakk scan --install-hook`',
|
|
736
|
+
);
|
|
737
|
+
process.exit(2);
|
|
738
|
+
}
|
|
739
|
+
const q = (x) => `'${x.replace(/'/g, `'\\''`)}'`;
|
|
740
|
+
// node upgrades (Homebrew Cellar, nvm) move the realpath this pins: say
|
|
741
|
+
// so in sakk's words instead of a bare shell error (review S6)
|
|
742
|
+
const guard = `[ -x ${q(process.execPath)} ] && [ -f ${q(self)} ] || { echo 'sakk: SCAN_HOOK_STALE: the node or sakk this hook runs is gone (node upgraded? sakk moved?) - re-run: sakk scan --install-hook' >&2; exit 1; }`;
|
|
743
|
+
const line = `${q(process.execPath)} ${q(self)} scan --staged || exit 1`;
|
|
744
|
+
const body = `#!/bin/sh\n# installed by sakk scan --install-hook (re-run it if node or sakk moves)\n${guard}\n${line}\n`;
|
|
745
|
+
const exists = (p) => {
|
|
746
|
+
try {
|
|
747
|
+
lstatSync(p); // a dangling symlink exists too
|
|
748
|
+
return true;
|
|
749
|
+
} catch {
|
|
750
|
+
return false;
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
const taken = hooks.find(exists);
|
|
754
|
+
if (taken) {
|
|
755
|
+
console.error(
|
|
756
|
+
`SCAN_HOOK_EXISTS: ${taken} already exists — nothing installed; add these lines to your pre-commit and pre-merge-commit hooks instead:\n ${guard}\n ${line}`,
|
|
757
|
+
);
|
|
758
|
+
process.exit(2);
|
|
759
|
+
}
|
|
760
|
+
const written = [];
|
|
761
|
+
try {
|
|
762
|
+
for (const hook of hooks) {
|
|
763
|
+
// 'wx' = O_EXCL: refuses ANY existing entry, a dangling symlink
|
|
764
|
+
// included, so an existing hook is never overwritten or written through.
|
|
765
|
+
writeFileSync(hook, body, { flag: 'wx', mode: 0o755 });
|
|
766
|
+
written.push(hook);
|
|
767
|
+
chmodSync(hook, 0o755); // the create mode is masked by umask; git ignores a non-executable hook
|
|
768
|
+
}
|
|
769
|
+
} catch (e) {
|
|
770
|
+
for (const w of written) rmSync(w, { force: true }); // both or neither
|
|
771
|
+
if (e.code === 'EEXIST') {
|
|
772
|
+
console.error(`SCAN_HOOK_EXISTS: a hook appeared while installing — nothing installed`);
|
|
773
|
+
process.exit(2);
|
|
774
|
+
}
|
|
775
|
+
if (e.code === 'ENOENT') {
|
|
776
|
+
console.error(`SCAN_HOOK_DIR_MISSING: ${hooksDir} does not exist (check core.hooksPath)`);
|
|
777
|
+
process.exit(2);
|
|
778
|
+
}
|
|
779
|
+
console.error(
|
|
780
|
+
`SCAN_HOOK_UNWRITABLE: ${hooksDir} hooks could not be written (${e.code ?? e.message})`,
|
|
781
|
+
);
|
|
782
|
+
process.exit(2);
|
|
783
|
+
}
|
|
784
|
+
console.log(
|
|
785
|
+
`pre-commit and pre-merge-commit hooks installed in ${hooksDir} — commits and merge commits containing recognized secrets will be refused. Not covered by any git hook: fast-forward merges, rebases, cherry-picks, --no-verify. Run \`sakk scan .\` in CI as well.`,
|
|
786
|
+
);
|
|
787
|
+
} else if (flag('staged') && paths.length > 0) {
|
|
788
|
+
usage('--staged scans the index and takes no paths — run the two scans separately');
|
|
789
|
+
} else if (!flag('staged') && paths.length === 0) {
|
|
790
|
+
usage('scan requires paths, --staged, or --install-hook');
|
|
791
|
+
} else {
|
|
792
|
+
try {
|
|
793
|
+
report(flag('staged') ? scanStaged() : scanPaths(paths));
|
|
794
|
+
} catch (e) {
|
|
795
|
+
// git's own stderr can carry paths: escaped and redacted like any name (review N5)
|
|
796
|
+
console.error(
|
|
797
|
+
e instanceof ScanError ? e.message : `SCAN_FAILED: ${display(String(e.code ?? e.message))}`,
|
|
798
|
+
);
|
|
799
|
+
process.exitCode = 2;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
} else if (cmd === 'hook') {
|
|
803
|
+
// never a crash, never a prompt: whatever happens, the agent gets an answer
|
|
804
|
+
let raw = '';
|
|
805
|
+
try {
|
|
806
|
+
raw = readFileSync(0, 'utf8');
|
|
807
|
+
} catch {
|
|
808
|
+
/* an unreadable stdin is an empty payload: runHook denies it */
|
|
809
|
+
}
|
|
810
|
+
const out = runHook(val('agent'), raw);
|
|
811
|
+
if (out) process.stdout.write(out);
|
|
812
|
+
} else if (cmd === 'try') {
|
|
813
|
+
const words = argv.slice(1).filter((a, i, all) => !a.startsWith('--') && all[i - 1] !== '--read');
|
|
814
|
+
const read = val('read');
|
|
815
|
+
if ((words.length === 0) === (read === undefined)) {
|
|
816
|
+
console.error(
|
|
817
|
+
'TRY_USAGE: give one command in quotes, or --read <path> — e.g. sakk try "git push --force"',
|
|
818
|
+
);
|
|
819
|
+
process.exit(2);
|
|
820
|
+
}
|
|
821
|
+
const payload = {
|
|
822
|
+
hook_event_name: 'PreToolUse',
|
|
823
|
+
cwd: process.cwd(),
|
|
824
|
+
session_id: 'sakk-try',
|
|
825
|
+
tool_use_id: `try-${Date.now()}`,
|
|
826
|
+
...(read === undefined
|
|
827
|
+
? { tool_name: 'Bash', tool_input: { command: words.join(' ') } }
|
|
828
|
+
: { tool_name: 'Read', tool_input: { file_path: resolve(read) } }),
|
|
829
|
+
};
|
|
830
|
+
let r;
|
|
831
|
+
try {
|
|
832
|
+
r = evaluate(normalize('claude-code', payload), { log: false });
|
|
833
|
+
} catch (e) {
|
|
834
|
+
console.error(String(e?.message ?? e));
|
|
835
|
+
process.exit(1);
|
|
836
|
+
}
|
|
837
|
+
if (flag('json')) {
|
|
838
|
+
console.log(
|
|
839
|
+
JSON.stringify(
|
|
840
|
+
{
|
|
841
|
+
verdict: r.verdict,
|
|
842
|
+
reason: r.reason,
|
|
843
|
+
policy: r.policyPath,
|
|
844
|
+
policyHash: r.policyHash,
|
|
845
|
+
decisions: r.decisions.map((d) => ({
|
|
846
|
+
action: d.context.action,
|
|
847
|
+
scope: d.context.scope ?? null,
|
|
848
|
+
verdict: d.verdict,
|
|
849
|
+
rule: d.ruleId,
|
|
850
|
+
})),
|
|
851
|
+
},
|
|
852
|
+
null,
|
|
853
|
+
1,
|
|
854
|
+
),
|
|
855
|
+
);
|
|
856
|
+
} else {
|
|
857
|
+
for (const d of r.decisions)
|
|
858
|
+
console.log(
|
|
859
|
+
`${d.verdict.padEnd(16)} ${d.context.action}${d.context.scope ? ` ${display(d.context.scope)}` : ''} (rule ${d.ruleId ?? 'default'})`,
|
|
860
|
+
);
|
|
861
|
+
console.log(
|
|
862
|
+
`→ ${r.verdict === 'require_approval' ? 'ask you first' : r.verdict}${r.reason ? `\n${r.reason}` : ''}`,
|
|
863
|
+
);
|
|
864
|
+
console.log(`policy ${r.policyPath} (sha256 ${r.policyHash.slice(0, 12)}…)`);
|
|
865
|
+
}
|
|
866
|
+
} else if (cmd === 'guard') {
|
|
867
|
+
const [action, agent] = argv.slice(1).filter((a) => !a.startsWith('--'));
|
|
868
|
+
if (
|
|
869
|
+
!['install', 'uninstall'].includes(action) ||
|
|
870
|
+
!['claude-code', 'cursor', 'codex'].includes(agent)
|
|
871
|
+
) {
|
|
872
|
+
console.error('GUARD_USAGE: sakk guard install|uninstall claude-code|cursor|codex [--user]');
|
|
873
|
+
process.exit(2);
|
|
874
|
+
}
|
|
875
|
+
let r;
|
|
876
|
+
try {
|
|
877
|
+
if (action === 'uninstall') {
|
|
878
|
+
const plan = uninstall(agent, { user: flag('user'), dryRun: true });
|
|
879
|
+
if (plan.changed) await confirmByPerson(`This removes the guard from ${plan.path}`, agent);
|
|
880
|
+
}
|
|
881
|
+
r = (action === 'install' ? install : uninstall)(agent, { user: flag('user') });
|
|
882
|
+
} catch (e) {
|
|
883
|
+
console.error(String(e?.message ?? e));
|
|
884
|
+
process.exit(1);
|
|
885
|
+
}
|
|
886
|
+
if (!r.changed) console.log(`${agent}: nothing to change in ${r.path}`);
|
|
887
|
+
else
|
|
888
|
+
console.log(
|
|
889
|
+
`${agent}: ${action === 'install' ? 'guard added to' : 'guard removed from'} ${r.path}${r.backup ? ` (previous file kept at ${r.backup})` : ''}${r.onFailure ? '; a hook that fails to run blocks the call (onFailure: block)' : ''}`,
|
|
890
|
+
);
|
|
891
|
+
// A degraded, fail-open install is a success WITH A WARNING, never a quiet
|
|
892
|
+
// success with a shorter line. On a Claude Code too old for onFailure
|
|
893
|
+
// (< 2.1.295) the guard cannot fail closed, so say so to the operator
|
|
894
|
+
// explicitly rather than leave the lost guarantee to be inferred from an
|
|
895
|
+
// absent clause (F-174).
|
|
896
|
+
if (action === 'install' && agent === 'claude-code' && !r.onFailure)
|
|
897
|
+
console.log(
|
|
898
|
+
'! claude-code: this Claude Code is too old for onFailure (needs 2.1.295+), so the guard is installed FAIL-OPEN — if the hook ever fails to start, the tool call proceeds UNGUARDED. Upgrade Claude Code and reinstall for a fail-closed guard; sakk doctor flags this.',
|
|
899
|
+
);
|
|
900
|
+
if (action === 'install') console.log('next: sakk doctor — it checks the hook really fires');
|
|
901
|
+
} else if (cmd === 'doctor') {
|
|
902
|
+
const checks = doctor();
|
|
903
|
+
if (flag('json')) console.log(JSON.stringify(checks, null, 1));
|
|
904
|
+
else
|
|
905
|
+
for (const c of checks)
|
|
906
|
+
console.log(`${c.ok ? (c.warn ? '!' : '✓') : '✗'} ${c.name} — ${c.detail}`);
|
|
907
|
+
process.exitCode = checks.every((c) => c.ok) ? 0 : 1;
|
|
908
|
+
} else if (cmd === 'ledger') {
|
|
909
|
+
const sub = argv[1];
|
|
910
|
+
if (sub !== 'verify' && sub !== 'export' && sub !== 'reindex') {
|
|
911
|
+
console.error(
|
|
912
|
+
'USAGE_UNKNOWN_ARGUMENT: sakk ledger verify | export [--out <file>] | reindex [--ledger <path>] — see sakk --help',
|
|
913
|
+
);
|
|
914
|
+
process.exit(2);
|
|
915
|
+
}
|
|
916
|
+
const p = ledgerPath();
|
|
917
|
+
let doc;
|
|
918
|
+
try {
|
|
919
|
+
doc = readLedger({ ledgerPath: p, maxAgeMs: Number.POSITIVE_INFINITY });
|
|
920
|
+
} catch (e) {
|
|
921
|
+
fail(e);
|
|
922
|
+
}
|
|
923
|
+
const archive = doc.archive ?? { segments: 0, settledCount: 0, settledUsd: 0, tip: '' };
|
|
924
|
+
if (sub === 'verify') {
|
|
925
|
+
// walk the whole cold chain; a broken or lost segment exits non-zero so CI can gate on it
|
|
926
|
+
try {
|
|
927
|
+
const got = verifyChain(p, archive);
|
|
928
|
+
// a segment a crashed seal left is checked as the next reserve or
|
|
929
|
+
// settle would adopt it, then the gate's own check and the files run
|
|
930
|
+
// against the summary through it: exactly 1..segments (F-193)
|
|
931
|
+
const left = leftoverSegment(p, archive, doc.entries);
|
|
932
|
+
const through = left?.archive ?? archive;
|
|
933
|
+
checkHead(p, through);
|
|
934
|
+
checkSegmentFiles(p, through);
|
|
935
|
+
console.log(
|
|
936
|
+
`ledger verify: sound — ${got.segments} segment(s), ${got.settledCount} sealed call(s), ${usd(got.settledUsd)} sealed; chain tip matches`,
|
|
937
|
+
);
|
|
938
|
+
if (left !== null)
|
|
939
|
+
console.log(
|
|
940
|
+
` segment ${through.segments} is left from a seal that was interrupted; the next reserve or settle adopts it as written`,
|
|
941
|
+
);
|
|
942
|
+
process.exit(0);
|
|
943
|
+
} catch (e) {
|
|
944
|
+
console.error(
|
|
945
|
+
`${e.code ?? 'LEDGER_ARCHIVE_BROKEN'}: ${String(e.message)
|
|
946
|
+
.replace(/^[A-Z_]+: /, '')
|
|
947
|
+
.replace(/ — run sakk ledger verify$/, '')}`,
|
|
948
|
+
);
|
|
949
|
+
process.exit(1);
|
|
950
|
+
}
|
|
951
|
+
} else if (sub === 'export') {
|
|
952
|
+
// rebuild one flat ledger from cold + hot: the whole history as it would
|
|
953
|
+
// read before the hot/cold split, for audit or portability
|
|
954
|
+
let entries = [];
|
|
955
|
+
try {
|
|
956
|
+
for (let seq = 1; seq <= archive.segments; seq++)
|
|
957
|
+
entries.push(...readSegment(p, seq).entries);
|
|
958
|
+
} catch (e) {
|
|
959
|
+
console.error(
|
|
960
|
+
`${e.code ?? 'LEDGER_ARCHIVE_BROKEN'}: ${String(e.message).replace(/^[A-Z_]+: /, '')}`,
|
|
961
|
+
);
|
|
962
|
+
process.exit(1);
|
|
963
|
+
}
|
|
964
|
+
entries.push(...doc.entries);
|
|
965
|
+
const flat = {
|
|
966
|
+
schema: doc.halt ? 'sakk-ledger.v2' : 'sakk-ledger.v1',
|
|
967
|
+
initializedAt: doc.initializedAt,
|
|
968
|
+
initializedBy: doc.initializedBy,
|
|
969
|
+
capUsd: doc.capUsd,
|
|
970
|
+
flags: doc.flags,
|
|
971
|
+
entries,
|
|
972
|
+
updatedAt: doc.updatedAt,
|
|
973
|
+
...(doc.halt ? { halt: doc.halt } : {}),
|
|
974
|
+
};
|
|
975
|
+
const out = `${JSON.stringify(flat, null, 1)}\n`;
|
|
976
|
+
const dest = val('out');
|
|
977
|
+
if (dest) {
|
|
978
|
+
// wx: never overwrite an existing file or write through a planted symlink.
|
|
979
|
+
// Wrap it in the typed CLI error form, not a raw Node stack (F-105 review).
|
|
980
|
+
try {
|
|
981
|
+
writeFileSync(resolve(dest), out, { flag: 'wx' });
|
|
982
|
+
} catch (e) {
|
|
983
|
+
console.error(
|
|
984
|
+
`LEDGER_EXPORT_FAILED: cannot write ${resolve(dest)}: ${e.code ?? e.message}`,
|
|
985
|
+
);
|
|
986
|
+
process.exit(1);
|
|
987
|
+
}
|
|
988
|
+
console.log(
|
|
989
|
+
`ledger export: ${entries.length} ${entries.length === 1 ? 'entry' : 'entries'} written to ${resolve(dest)}`,
|
|
990
|
+
);
|
|
991
|
+
} else process.stdout.write(out);
|
|
992
|
+
process.exit(0);
|
|
993
|
+
} else if (sub === 'reindex') {
|
|
994
|
+
// rebuild the call-id index from the cold trail, for recovery after an index loss
|
|
995
|
+
try {
|
|
996
|
+
const n = reindex(p, archive);
|
|
997
|
+
console.log(
|
|
998
|
+
`ledger reindex: rebuilt ${n} index shard(s) from ${archive.segments} segment(s)`,
|
|
999
|
+
);
|
|
1000
|
+
process.exit(0);
|
|
1001
|
+
} catch (e) {
|
|
1002
|
+
console.error(
|
|
1003
|
+
`${e.code ?? 'LEDGER_ARCHIVE_BROKEN'}: ${String(e.message).replace(/^[A-Z_]+: /, '')}`,
|
|
1004
|
+
);
|
|
1005
|
+
process.exit(1);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
} else if (cmd === 'cap') {
|
|
1009
|
+
// set or lift the daily spend cap the hook enforces. A person runs this: the
|
|
1010
|
+
// hook denies the agent `sakk cap` like any write to ~/.sakk, and lifting or
|
|
1011
|
+
// raising a cap, or moving its day, asks a person at the terminal (F-171).
|
|
1012
|
+
const sub = argv[1];
|
|
1013
|
+
const home = sakkHome();
|
|
1014
|
+
if (sub !== 'off' && sub !== 'set') {
|
|
1015
|
+
console.error('CAP_USAGE: sakk cap set <usd> [--zone <IANA>] | sakk cap off');
|
|
1016
|
+
process.exit(2);
|
|
1017
|
+
}
|
|
1018
|
+
let cap;
|
|
1019
|
+
let zone;
|
|
1020
|
+
if (sub === 'set') {
|
|
1021
|
+
const amt = argv[2];
|
|
1022
|
+
cap = /^\d+(\.\d+)?$/.test(amt ?? '') ? Number(amt) : NaN;
|
|
1023
|
+
if (!Number.isFinite(cap) || cap <= 0) {
|
|
1024
|
+
console.error('CAP_USAGE: sakk cap set <usd> — a positive number (e.g. sakk cap set 5)');
|
|
1025
|
+
process.exit(2);
|
|
1026
|
+
}
|
|
1027
|
+
zone = val('zone');
|
|
1028
|
+
if (zone !== undefined) {
|
|
1029
|
+
try {
|
|
1030
|
+
new Intl.DateTimeFormat('en-CA', { timeZone: zone });
|
|
1031
|
+
} catch {
|
|
1032
|
+
console.error(`CAP_ZONE_INVALID: ${zone} is not an IANA time zone (e.g. Asia/Dubai)`);
|
|
1033
|
+
process.exit(2);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
// a pre-existing malformed/symlinked config.json makes writeConfig's merge
|
|
1038
|
+
// read throw; report it as a typed error, not an uncaught stack (spend/cap review)
|
|
1039
|
+
try {
|
|
1040
|
+
const was = readConfig(home);
|
|
1041
|
+
if (typeof was.capUsd === 'number') {
|
|
1042
|
+
const day = (z) => z ?? was.timeZone ?? systemZone();
|
|
1043
|
+
if (sub === 'off') await confirmByPerson(`This lifts the daily cap of $${was.capUsd}`, 'off');
|
|
1044
|
+
else if (cap > was.capUsd)
|
|
1045
|
+
await confirmByPerson(`This raises the daily cap from $${was.capUsd} to $${cap}`, argv[2]);
|
|
1046
|
+
else if (day(zone) !== day(undefined))
|
|
1047
|
+
await confirmByPerson(
|
|
1048
|
+
`This moves the cap's day from ${day(undefined)} to ${zone}, which can start a new day early`,
|
|
1049
|
+
zone,
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
if (sub === 'off') {
|
|
1053
|
+
writeConfig(home, { capUsd: undefined });
|
|
1054
|
+
console.log(
|
|
1055
|
+
`cap off: ${join(home, 'config.json')} — no daily cap; spend no longer gates paid calls`,
|
|
1056
|
+
);
|
|
1057
|
+
} else {
|
|
1058
|
+
writeConfig(home, { capUsd: cap, ...(zone !== undefined ? { timeZone: zone } : {}) });
|
|
1059
|
+
console.log(
|
|
1060
|
+
`cap set: $${cap}/day${zone ? ` (${zone})` : ''} in ${join(home, 'config.json')} — the hook denies paid calls once today's spend from local logs reaches it`,
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
1063
|
+
} catch (e) {
|
|
1064
|
+
fail(e);
|
|
1065
|
+
}
|
|
1066
|
+
} else if (cmd === 'spend') {
|
|
1067
|
+
const home = sakkHome();
|
|
1068
|
+
const dayArg = val('day');
|
|
1069
|
+
if (dayArg !== undefined) {
|
|
1070
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dayArg);
|
|
1071
|
+
const d = m && new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
|
|
1072
|
+
// reject an impossible date (2026-13-40, 2026-02-30): the parts must round-trip
|
|
1073
|
+
const real =
|
|
1074
|
+
d &&
|
|
1075
|
+
d.getUTCFullYear() === +m[1] &&
|
|
1076
|
+
d.getUTCMonth() === +m[2] - 1 &&
|
|
1077
|
+
d.getUTCDate() === +m[3];
|
|
1078
|
+
if (!real) {
|
|
1079
|
+
console.error('SPEND_USAGE: --day must be a real calendar date, YYYY-MM-DD');
|
|
1080
|
+
process.exit(2);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
let config;
|
|
1084
|
+
try {
|
|
1085
|
+
config = readConfig(home);
|
|
1086
|
+
} catch (e) {
|
|
1087
|
+
fail(e);
|
|
1088
|
+
}
|
|
1089
|
+
const { table } = loadPrices({ home });
|
|
1090
|
+
const zone = config.timeZone || systemZone();
|
|
1091
|
+
const day = dayArg ?? dayOf(Date.now(), zone);
|
|
1092
|
+
const logDir = config.claudeLogDir || claudeLogDir(process.env);
|
|
1093
|
+
let indexNote = '';
|
|
1094
|
+
try {
|
|
1095
|
+
// an explicit historical --day scans all files (their mtime is old); today
|
|
1096
|
+
// only needs files touched in the last 36 h (spend/cap review: no false $0)
|
|
1097
|
+
const idx = updateIndex({
|
|
1098
|
+
home,
|
|
1099
|
+
logDir,
|
|
1100
|
+
zone,
|
|
1101
|
+
since: dayArg !== undefined ? 0 : Date.now() - 36 * 3600 * 1000,
|
|
1102
|
+
});
|
|
1103
|
+
// a folder that cannot be listed is uncounted spend, not $0: a seemingly
|
|
1104
|
+
// credible low figure must not be reported (F-204, UAT S-REL-002)
|
|
1105
|
+
if (idx.unlistable.length) {
|
|
1106
|
+
console.error(
|
|
1107
|
+
`SPEND_LOGS_UNLISTABLE: ${idx.unlistable.length} path(s) under ${logDir} could not be read, so spend cannot be counted — fix the permissions and run sakk spend again:`,
|
|
1108
|
+
);
|
|
1109
|
+
for (const u of idx.unlistable) console.error(` ${display(u.path)} (${u.code})`);
|
|
1110
|
+
process.exit(1);
|
|
1111
|
+
}
|
|
1112
|
+
} catch (e) {
|
|
1113
|
+
if (!(e instanceof SpendError)) throw e;
|
|
1114
|
+
indexNote = ` (index not refreshed: ${e.code})`;
|
|
1115
|
+
}
|
|
1116
|
+
const s = daySpend({ home, day, table });
|
|
1117
|
+
const unpriced = Object.keys(s.unpriced);
|
|
1118
|
+
const out = {
|
|
1119
|
+
day,
|
|
1120
|
+
zone,
|
|
1121
|
+
cap_usd: config.capUsd ?? null,
|
|
1122
|
+
spend_usd: s.usd,
|
|
1123
|
+
calls: s.calls,
|
|
1124
|
+
future: s.future,
|
|
1125
|
+
backdated: s.backdated,
|
|
1126
|
+
by_model: s.byModel,
|
|
1127
|
+
unpriced: s.unpriced,
|
|
1128
|
+
unreadable: s.unreadable,
|
|
1129
|
+
source: 'from local logs',
|
|
1130
|
+
};
|
|
1131
|
+
if (flag('json')) console.log(JSON.stringify(out, null, 1));
|
|
1132
|
+
else {
|
|
1133
|
+
console.log(
|
|
1134
|
+
`spend ${day} (${zone}, from local logs): ${usd(s.usd)}${config.capUsd !== undefined ? ` of ${usd(config.capUsd)} cap` : ' (no cap set)'}${indexNote}`,
|
|
1135
|
+
);
|
|
1136
|
+
for (const [m, c] of Object.entries(s.byModel)) console.log(` ${display(m)} ${usd(c)}`);
|
|
1137
|
+
if (unpriced.length)
|
|
1138
|
+
console.log(
|
|
1139
|
+
` unpriced: ${unpriced.map(display).join(', ')} — add it to ${join(home, 'prices.json')}`,
|
|
1140
|
+
);
|
|
1141
|
+
if (s.future)
|
|
1142
|
+
console.log(` ${s.future} call(s) dated in the future — counted on the day observed`);
|
|
1143
|
+
if (s.backdated)
|
|
1144
|
+
console.log(
|
|
1145
|
+
` ${s.backdated} call(s) first seen today claiming an earlier day — counted today`,
|
|
1146
|
+
);
|
|
1147
|
+
if (s.unreadable) console.log(` ${s.unreadable} log line(s) unreadable today`);
|
|
1148
|
+
}
|
|
1149
|
+
} else if (cmd === 'prices') {
|
|
1150
|
+
const home = sakkHome();
|
|
1151
|
+
let loaded;
|
|
1152
|
+
try {
|
|
1153
|
+
loaded = loadPrices({ home });
|
|
1154
|
+
} catch (e) {
|
|
1155
|
+
console.error(
|
|
1156
|
+
`${e.code ?? 'PRICES_UNREADABLE'}: ${String(e.message).replace(/^[A-Z_]+: /, '')}`,
|
|
1157
|
+
);
|
|
1158
|
+
process.exit(1);
|
|
1159
|
+
}
|
|
1160
|
+
const models = Object.entries(loaded.table.models ?? loaded.table);
|
|
1161
|
+
// The table's own claim about where its prices came from; an operator's
|
|
1162
|
+
// prices.json can say anything here, so it is shown, never trusted.
|
|
1163
|
+
const src = loaded.table.source;
|
|
1164
|
+
let provenance = null;
|
|
1165
|
+
if (src !== null && typeof src === 'object' && !Array.isArray(src)) {
|
|
1166
|
+
const kept = {};
|
|
1167
|
+
for (const k of ['url', 'commit', 'sha256', 'fetchedAt', 'license'])
|
|
1168
|
+
if (typeof src[k] === 'string') kept[k] = src[k];
|
|
1169
|
+
if (Object.keys(kept).length) provenance = kept;
|
|
1170
|
+
}
|
|
1171
|
+
if (flag('json'))
|
|
1172
|
+
console.log(
|
|
1173
|
+
JSON.stringify(
|
|
1174
|
+
{
|
|
1175
|
+
source: loaded.path,
|
|
1176
|
+
provenance,
|
|
1177
|
+
approximate: Boolean(loaded.table.approximate),
|
|
1178
|
+
models: Object.fromEntries(models),
|
|
1179
|
+
},
|
|
1180
|
+
null,
|
|
1181
|
+
1,
|
|
1182
|
+
),
|
|
1183
|
+
);
|
|
1184
|
+
else {
|
|
1185
|
+
console.log(
|
|
1186
|
+
`prices: ${models.length} model(s) from ${loaded.path}${loaded.table.approximate ? ' (approximate)' : ''}`,
|
|
1187
|
+
);
|
|
1188
|
+
if (provenance) {
|
|
1189
|
+
const p = provenance;
|
|
1190
|
+
const parts = [
|
|
1191
|
+
p.url && display(p.url),
|
|
1192
|
+
p.commit && `at commit ${display(p.commit)}`,
|
|
1193
|
+
p.sha256 && `sha256 ${display(p.sha256)}`,
|
|
1194
|
+
p.fetchedAt && `fetched ${display(p.fetchedAt)}`,
|
|
1195
|
+
p.license && display(p.license),
|
|
1196
|
+
].filter(Boolean);
|
|
1197
|
+
console.log(` provenance (as recorded in the table): ${parts.join(', ')}`);
|
|
1198
|
+
} else console.log(' provenance: not recorded in this table');
|
|
1199
|
+
for (const [id, m] of models) console.log(` ${display(id)} in ${m.input} / out ${m.output}`);
|
|
1200
|
+
}
|
|
1201
|
+
} else if (cmd === 'ui' && (flag('set-password') || flag('clear-password'))) {
|
|
1202
|
+
if (argv.length !== 2) {
|
|
1203
|
+
console.error(
|
|
1204
|
+
'UI_PASSWORD_ALONE: --set-password and --clear-password take no other argument; start sakk ui after',
|
|
1205
|
+
);
|
|
1206
|
+
process.exit(2);
|
|
1207
|
+
}
|
|
1208
|
+
const home = sakkHome();
|
|
1209
|
+
if (flag('clear-password')) {
|
|
1210
|
+
const r = clearConsoleLogin({ home });
|
|
1211
|
+
console.log(
|
|
1212
|
+
r.removed
|
|
1213
|
+
? `Console sign-in removed (${r.path}). Restart sakk ui: it is token-only again.`
|
|
1214
|
+
: 'No console sign-in was set; sakk ui is token-only.',
|
|
1215
|
+
);
|
|
1216
|
+
} else {
|
|
1217
|
+
// a person at a terminal sets it: never a pipe, argv or an environment variable
|
|
1218
|
+
askConsoleLogin({ home }).then(
|
|
1219
|
+
(r) => {
|
|
1220
|
+
console.log(
|
|
1221
|
+
`Console sign-in set for ${r.username} (${r.path}, owner-only). Restart sakk ui for it to take effect.`,
|
|
1222
|
+
);
|
|
1223
|
+
},
|
|
1224
|
+
(e) => {
|
|
1225
|
+
console.error(`${e.code ?? 'UI_FAILED'}: ${String(e.message ?? e)}`);
|
|
1226
|
+
process.exit(e instanceof ConsoleLoginError ? 2 : 1);
|
|
1227
|
+
},
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1230
|
+
} else if (cmd === 'ui') {
|
|
1231
|
+
const portArg = val('port');
|
|
1232
|
+
let port = 0;
|
|
1233
|
+
if (portArg !== undefined) {
|
|
1234
|
+
if (!/^\d+$/.test(portArg) || Number(portArg) > 65535) {
|
|
1235
|
+
console.error('UI_PORT_INVALID: --port must be a number 1024-65535 (0 = a free port)');
|
|
1236
|
+
process.exit(2);
|
|
1237
|
+
}
|
|
1238
|
+
port = Number(portArg);
|
|
1239
|
+
if (port > 0 && port < 1024) {
|
|
1240
|
+
console.error(
|
|
1241
|
+
'UI_PORT_PRIVILEGED: choose a port 1024 or higher; privileged ports are refused',
|
|
1242
|
+
);
|
|
1243
|
+
process.exit(2);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
// the hook's own rule, with no ./sakk-ledger.json guess: the stop button
|
|
1247
|
+
// halts exactly the ledger the hook reads (F-172)
|
|
1248
|
+
const lp = val('ledger') === '' ? null : ledgerFrom(undefined).path;
|
|
1249
|
+
const allowHalt = flag('allow-halt');
|
|
1250
|
+
startConsole({ port, allowHalt, cwd: process.cwd(), ledgerPath: lp }).then(
|
|
1251
|
+
(c) => {
|
|
1252
|
+
console.log(
|
|
1253
|
+
`Sakk console: ${c.url} (read-only${allowHalt ? '; stop enabled' : ''}; Ctrl+C to stop)`,
|
|
1254
|
+
);
|
|
1255
|
+
if (c.login)
|
|
1256
|
+
console.log(
|
|
1257
|
+
`Sign-in is on: http://127.0.0.1:${c.port}/ asks for the username and password; the address above works too. sakk ui --clear-password turns it off.`,
|
|
1258
|
+
);
|
|
1259
|
+
process.on('SIGINT', () => c.close().then(() => process.exit(0)));
|
|
1260
|
+
},
|
|
1261
|
+
(e) => {
|
|
1262
|
+
if (e && e.code === 'EADDRINUSE') {
|
|
1263
|
+
console.error(`UI_PORT_IN_USE: port ${port} is already in use — choose another --port`);
|
|
1264
|
+
process.exit(2);
|
|
1265
|
+
}
|
|
1266
|
+
if (e instanceof ConsoleLoginError) {
|
|
1267
|
+
console.error(`${e.code}: ${e.message}`);
|
|
1268
|
+
process.exit(2);
|
|
1269
|
+
}
|
|
1270
|
+
const uiCode = 'UI_FAILED';
|
|
1271
|
+
console.error(`${uiCode}: ${String(e?.message ?? e)}`);
|
|
1272
|
+
process.exit(1);
|
|
1273
|
+
},
|
|
1274
|
+
);
|
|
1275
|
+
} else if (cmd === 'gateway') {
|
|
1276
|
+
// Every refusal below comes before the first write, so a refused run leaves
|
|
1277
|
+
// the directory as it was. The hook judges this command as the write of
|
|
1278
|
+
// start.sh (src/classify.mjs): an agent cannot re-point the gateway's ledger
|
|
1279
|
+
// or executable inside Sakk's home.
|
|
1280
|
+
const nothing = 'nothing was written';
|
|
1281
|
+
const pos = [];
|
|
1282
|
+
for (let i = 1; i < argv.length; i += 1)
|
|
1283
|
+
if (!argv[i].startsWith('--')) pos.push(argv[i]);
|
|
1284
|
+
else if (COMMANDS.gateway.values.includes(argv[i].slice(2))) i += 1;
|
|
1285
|
+
if (pos.length !== 1 || pos[0] !== 'init') {
|
|
1286
|
+
console.error(
|
|
1287
|
+
'GATEWAY_USAGE: sakk gateway init [--dir <path>] [--ledger <path>] [--port N] [--litellm <path>] [--force] — see sakk --help',
|
|
1288
|
+
);
|
|
1289
|
+
process.exit(2);
|
|
1290
|
+
}
|
|
1291
|
+
const portArg = val('port') ?? '4000';
|
|
1292
|
+
if (!/^\d{4,5}$/.test(portArg) || Number(portArg) < 1024 || Number(portArg) > 65535) {
|
|
1293
|
+
console.error(`GATEWAY_PORT_INVALID: --port must be a whole number 1024-65535; ${nothing}`);
|
|
1294
|
+
process.exit(2);
|
|
1295
|
+
}
|
|
1296
|
+
const port = Number(portArg);
|
|
1297
|
+
// start.sh pins this sakk by path; once a cache cleaner removes it the
|
|
1298
|
+
// gateway cannot reach its ledger, and its calls stop being metered
|
|
1299
|
+
const bin = realpathSync(fileURLToPath(import.meta.url));
|
|
1300
|
+
if (EPHEMERAL.test(bin)) {
|
|
1301
|
+
console.error(
|
|
1302
|
+
`GATEWAY_EPHEMERAL: this sakk runs from a temporary package cache (${display(bin)}) that gets cleaned — install it first (npm install -g @sakk-dev/sakk), then run the installed sakk gateway init; ${nothing}`,
|
|
1303
|
+
);
|
|
1304
|
+
process.exit(2);
|
|
1305
|
+
}
|
|
1306
|
+
const shipped = join(HERE, '..', 'integrations', 'litellm');
|
|
1307
|
+
const source = {};
|
|
1308
|
+
for (const f of ['sakk_gateway.py', 'config.example.yaml'])
|
|
1309
|
+
try {
|
|
1310
|
+
source[f] = readFileSync(join(shipped, f), 'utf8');
|
|
1311
|
+
} catch (e) {
|
|
1312
|
+
console.error(
|
|
1313
|
+
`GATEWAY_SOURCE_MISSING: ${join(shipped, f)} is not in this install (${e.code ?? e.message}) — reinstall sakk; ${nothing}`,
|
|
1314
|
+
);
|
|
1315
|
+
process.exit(1);
|
|
1316
|
+
}
|
|
1317
|
+
// A gateway on a ledger it cannot read would treat every call as an outage
|
|
1318
|
+
// call, so the ledger must answer now, before anything points at it.
|
|
1319
|
+
const ledger = resolve(ledgerPath());
|
|
1320
|
+
try {
|
|
1321
|
+
readLedger({ ledgerPath: ledger, maxAgeMs: Number.POSITIVE_INFINITY });
|
|
1322
|
+
} catch (e) {
|
|
1323
|
+
console.error(
|
|
1324
|
+
`GATEWAY_LEDGER_UNREADABLE: ${display(ledger)}: ${display(String(e.message))} — create it with sakk init --cap <usd>, or pass --ledger <path>; ${nothing}`,
|
|
1325
|
+
);
|
|
1326
|
+
process.exit(1);
|
|
1327
|
+
}
|
|
1328
|
+
// Existence and the execute bit only: litellm is never run here.
|
|
1329
|
+
const runnable = (p) => {
|
|
1330
|
+
try {
|
|
1331
|
+
accessSync(p, fsConstants.X_OK);
|
|
1332
|
+
return statSync(p).isFile();
|
|
1333
|
+
} catch {
|
|
1334
|
+
return false;
|
|
1335
|
+
}
|
|
1336
|
+
};
|
|
1337
|
+
const given = val('litellm');
|
|
1338
|
+
let litellm = null;
|
|
1339
|
+
if (given !== undefined) litellm = runnable(resolve(given)) ? resolve(given) : null;
|
|
1340
|
+
else
|
|
1341
|
+
for (const d of (process.env.PATH ?? '').split(delimiter)) {
|
|
1342
|
+
// a relative PATH entry means a different file from every working directory
|
|
1343
|
+
if (!isAbsolute(d) || !runnable(join(d, 'litellm'))) continue;
|
|
1344
|
+
litellm = join(d, 'litellm');
|
|
1345
|
+
break;
|
|
1346
|
+
}
|
|
1347
|
+
if (litellm === null) {
|
|
1348
|
+
console.error(
|
|
1349
|
+
[
|
|
1350
|
+
`GATEWAY_LITELLM_NOT_FOUND: ${given === undefined ? 'no executable named litellm on PATH' : `${display(resolve(given))} is not an executable file`}; ${nothing}.`,
|
|
1351
|
+
'Install the pinned LiteLLM proxy from a hash-locked file, then run this again',
|
|
1352
|
+
'(add --litellm "$PWD/.venv/bin/litellm" when the venv is not on PATH):',
|
|
1353
|
+
" printf 'litellm[proxy]==1.106.2\\n' > req.in",
|
|
1354
|
+
' uv venv --python 3.12 .venv',
|
|
1355
|
+
' uv pip compile req.in --generate-hashes --exclude-newer 2026-10-21T11:00:00Z -o req.lock',
|
|
1356
|
+
' uv pip install --require-hashes -r req.lock',
|
|
1357
|
+
'Do not install LiteLLM 1.82.7, 1.82.8 or 1.107.0: those releases were compromised.',
|
|
1358
|
+
`Details: ${join(shipped, 'README.md')}`,
|
|
1359
|
+
].join('\n'),
|
|
1360
|
+
);
|
|
1361
|
+
process.exit(1);
|
|
1362
|
+
}
|
|
1363
|
+
const dir = resolve(val('dir') ?? join(sakkHome(), 'gateway'));
|
|
1364
|
+
const config = join(dir, 'config.yaml');
|
|
1365
|
+
const start = join(dir, 'start.sh');
|
|
1366
|
+
// POSIX single quotes: nothing inside is expanded; a quote is closed, escaped, reopened
|
|
1367
|
+
const q = (s) => `'${s.replaceAll("'", `'\\''`)}'`;
|
|
1368
|
+
const files = [
|
|
1369
|
+
['sakk_gateway.py', 0o600, source['sakk_gateway.py']],
|
|
1370
|
+
[
|
|
1371
|
+
'config.yaml',
|
|
1372
|
+
0o600,
|
|
1373
|
+
`# Written by sakk gateway init (sakk ${pkg.version}) from config.example.yaml.\n# Keys stay os.environ/ references: put no key in this file.\n${source['config.example.yaml']}`,
|
|
1374
|
+
],
|
|
1375
|
+
[
|
|
1376
|
+
'start.sh',
|
|
1377
|
+
0o700,
|
|
1378
|
+
[
|
|
1379
|
+
'#!/bin/sh',
|
|
1380
|
+
`# Written by sakk gateway init (sakk ${pkg.version}): the LiteLLM proxy with the Sakk spend gate on 127.0.0.1:${port}.`,
|
|
1381
|
+
'# It holds no key. LITELLM_MASTER_KEY (the key your app sends) and the provider keys',
|
|
1382
|
+
'# (DEEPSEEK_API_KEY and the others config.yaml names) come from the environment you start it in.',
|
|
1383
|
+
'# When the ledger cannot answer, calls go through unmetered with a warning;',
|
|
1384
|
+
'# start it with SAKK_ON_UNAVAILABLE=deny to refuse them instead.',
|
|
1385
|
+
'set -eu',
|
|
1386
|
+
': "${LITELLM_MASTER_KEY:?set LITELLM_MASTER_KEY to the key your app will send; this script does not start a proxy without one}"',
|
|
1387
|
+
`[ -x ${q(process.execPath)} ] && [ -f ${q(bin)} ] && [ -f ${q(ledger)} ] || { echo 'sakk: GATEWAY_STALE: the node, sakk or ledger this script names is gone (node upgraded? sakk moved?) - run sakk gateway init --force again' >&2; exit 1; }`,
|
|
1388
|
+
`export SAKK_NODE=${q(process.execPath)}`,
|
|
1389
|
+
`export SAKK_BIN=${q(bin)}`,
|
|
1390
|
+
`export SAKK_LEDGER=${q(ledger)}`,
|
|
1391
|
+
`export SAKK_GATEWAY_CONFIG=${q(config)}`,
|
|
1392
|
+
'export LITELLM_LOCAL_MODEL_COST_MAP=True',
|
|
1393
|
+
`exec ${q(litellm)} --config ${q(config)} --host 127.0.0.1 --port ${port}`,
|
|
1394
|
+
'',
|
|
1395
|
+
].join('\n'),
|
|
1396
|
+
],
|
|
1397
|
+
];
|
|
1398
|
+
const present = new Set();
|
|
1399
|
+
for (const [name] of files)
|
|
1400
|
+
try {
|
|
1401
|
+
lstatSync(join(dir, name));
|
|
1402
|
+
present.add(name);
|
|
1403
|
+
} catch {}
|
|
1404
|
+
if (present.size > 0 && !flag('force')) {
|
|
1405
|
+
console.error(
|
|
1406
|
+
`GATEWAY_FILES_EXIST: ${[...present].map((n) => display(join(dir, n))).join(', ')} already exist — pass --force to replace them; ${nothing}`,
|
|
1407
|
+
);
|
|
1408
|
+
process.exit(1);
|
|
1409
|
+
}
|
|
1410
|
+
const created = [];
|
|
1411
|
+
let tmp = null;
|
|
1412
|
+
try {
|
|
1413
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1414
|
+
for (const [name, mode, data] of files) {
|
|
1415
|
+
const target = join(dir, name);
|
|
1416
|
+
if (present.has(name)) {
|
|
1417
|
+
// rename replaces a planted symlink itself; it never writes through it
|
|
1418
|
+
tmp = `${target}.sakk-tmp-${process.pid}`;
|
|
1419
|
+
writeFileSync(tmp, data, { flag: 'wx', mode });
|
|
1420
|
+
chmodSync(tmp, mode);
|
|
1421
|
+
renameSync(tmp, target);
|
|
1422
|
+
tmp = null;
|
|
1423
|
+
} else {
|
|
1424
|
+
writeFileSync(target, data, { flag: 'wx', mode });
|
|
1425
|
+
created.push(target);
|
|
1426
|
+
chmodSync(target, mode);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
} catch (e) {
|
|
1430
|
+
if (tmp !== null) rmSync(tmp, { force: true });
|
|
1431
|
+
for (const p of created) rmSync(p, { force: true });
|
|
1432
|
+
console.error(
|
|
1433
|
+
`GATEWAY_WRITE_FAILED: ${display(dir)}: ${e.code ?? display(String(e.message))} — the files this run created were removed${present.size > 0 ? '; a file it had already replaced stays replaced, run it again with --force' : ''}`,
|
|
1434
|
+
);
|
|
1435
|
+
process.exit(1);
|
|
1436
|
+
}
|
|
1437
|
+
console.log(
|
|
1438
|
+
[
|
|
1439
|
+
`${present.size > 0 ? 'replaced' : 'wrote'} the gateway files in ${display(dir)}:`,
|
|
1440
|
+
' sakk_gateway.py the Sakk callback LiteLLM loads',
|
|
1441
|
+
' config.yaml models and prices (USD per token); keys are os.environ/ references',
|
|
1442
|
+
` start.sh starts LiteLLM on 127.0.0.1:${port} with SAKK_BIN, SAKK_LEDGER and SAKK_GATEWAY_CONFIG set`,
|
|
1443
|
+
`ledger: ${display(ledger)}`,
|
|
1444
|
+
`litellm: ${display(litellm)} (found; its version was not checked — the gateway is tested with 1.106.2)`,
|
|
1445
|
+
'',
|
|
1446
|
+
'Start it with the keys in your environment (never in these files):',
|
|
1447
|
+
` LITELLM_MASTER_KEY=<a long random key> DEEPSEEK_API_KEY=<your key> ${display(q(start))}`,
|
|
1448
|
+
'',
|
|
1449
|
+
'Then change one line in your app, and send LITELLM_MASTER_KEY as its API key:',
|
|
1450
|
+
`OPENAI_BASE_URL=http://127.0.0.1:${port}/v1`,
|
|
1451
|
+
'',
|
|
1452
|
+
`Refusals, prices and limits: ${join(shipped, 'README.md')}`,
|
|
1453
|
+
].join('\n'),
|
|
1454
|
+
);
|
|
1455
|
+
} else if (cmd === '--version' || cmd === '-v') {
|
|
1456
|
+
console.log(pkg.version);
|
|
1457
|
+
} else {
|
|
1458
|
+
console.log(USAGE);
|
|
1459
|
+
process.exit(cmd === undefined || cmd === '--help' || cmd === '-h' ? 0 : 2);
|
|
1460
|
+
}
|