@codedology/code-forge 0.3.2 → 0.5.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/CHANGELOG.md +85 -0
- package/README.md +88 -21
- package/bin/code-forge.mjs +6 -1
- package/docs/README.md +5 -4
- package/docs/reference/blocks.json +236 -1
- package/docs/reference/config.md +13 -1
- package/package.json +6 -2
- package/schema/code-forge.schema.json +26 -4
- package/skill/SKILL.md +8 -7
- package/skill/references/adapters/claude-code.md +5 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/adapters/subprocess.md +1 -1
- package/skill/references/code.md +4 -3
- package/skill/references/continuity.md +4 -0
- package/skill/references/decisions.md +5 -2
- package/skill/references/ledger.md +10 -2
- package/skill/references/plan.md +12 -3
- package/skill/references/review.md +13 -6
- package/skill/references/security.md +3 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +36 -0
- package/skill/templates/plan.md +2 -0
- package/src/cli/block.mjs +51 -8
- package/src/cli/facts.mjs +16 -1
- package/src/cli/jev.mjs +114 -17
- package/src/cli/ledger.mjs +99 -8
- package/src/cli/logs.mjs +193 -58
- package/src/cli/plan.mjs +27 -11
- package/src/cli/report.mjs +15 -1
- package/src/cli/resolve.mjs +16 -1
- package/src/cli/review-file.mjs +10 -5
- package/src/cli/review.mjs +40 -9
- package/src/cli/run.mjs +109 -8
- package/src/config/closed-book.mjs +75 -0
- package/src/config/validate.mjs +128 -7
- package/src/decide/escalation.mjs +112 -11
- package/src/decide/questions.mjs +27 -4
- package/src/doctor/index.mjs +2 -1
- package/src/doctor/local.mjs +15 -0
- package/src/doctor/probes.mjs +48 -6
- package/src/engines/builders/claude.mjs +4 -1
- package/src/engines/builders/codex.mjs +26 -11
- package/src/engines/builders/grok.mjs +3 -3
- package/src/engines/efforts.mjs +109 -0
- package/src/engines/sentinel.mjs +34 -0
- package/src/keys/onepassword.mjs +3 -0
- package/src/ledger/report.mjs +66 -29
- package/src/ledger/spend.mjs +288 -0
- package/src/review/engine.mjs +57 -16
- package/src/review/fixloop.mjs +29 -6
- package/src/review/session-retry.mjs +69 -0
- package/src/review/triage.mjs +26 -1
- package/src/review/validate-review.mjs +6 -3
- package/src/session/author.mjs +6 -2
- package/src/session/facts.mjs +218 -53
- package/src/session/plan-check.mjs +71 -6
- package/src/session/plan-sections.mjs +98 -0
- package/src/session/spawn.mjs +373 -20
- package/src/state/block.mjs +13 -4
- package/src/state/config-snapshot.mjs +150 -0
- package/src/state/run.mjs +86 -0
- package/src/util/breadcrumbs.mjs +145 -0
- package/src/util/error-hint.mjs +3 -2
- package/src/util/error-kind.mjs +2 -2
- package/src/util/error-log.mjs +109 -18
- package/src/util/forbidden.mjs +3 -1
- package/src/util/known-fixes.json +1 -0
- package/src/util/known-fixes.mjs +153 -0
- package/src/util/locks.mjs +104 -0
- package/src/worker/engine.mjs +122 -40
- package/src/worker/loop.mjs +326 -78
- package/src/worker/queue.mjs +68 -7
package/src/cli/facts.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { buildFacts, FactsError } from '../session/facts.mjs';
|
|
|
16
16
|
import { runRootFor, SessionError } from '../session/spawn.mjs';
|
|
17
17
|
import { intFlag, parseFlags } from '../state/cli-args.mjs';
|
|
18
18
|
import { StateError } from '../state/paths.mjs';
|
|
19
|
-
import { writeSafe } from '../util/redact.mjs';
|
|
19
|
+
import { redact, writeSafe } from '../util/redact.mjs';
|
|
20
20
|
import { slugFor } from './run.mjs';
|
|
21
21
|
import { exitCodeFor } from './spawn.mjs';
|
|
22
22
|
|
|
@@ -34,6 +34,19 @@ function readable(file, { fileOnly = false } = {}) {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* One stderr line for a downgraded row: the id must be `F<n>` (else `F?`), the reason is redacted
|
|
39
|
+
* and every control character (newlines included) becomes a space, so a row is always one line.
|
|
40
|
+
* @param {{fact_id: string, reason: string}} d
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
export function downgradeLine(d) {
|
|
44
|
+
const id = /^F\d+$/.test(String(d.fact_id)) ? d.fact_id : 'F?';
|
|
45
|
+
// eslint-disable-next-line no-control-regex
|
|
46
|
+
const reason = redact(String(d.reason).replace(/[\u0000-\u001f\u007f-\u009f]/g, ' '));
|
|
47
|
+
return `facts: ${id} not counted — ${reason}\n`;
|
|
48
|
+
}
|
|
49
|
+
|
|
37
50
|
const USAGE = 'usage: code-forge facts --brief <file> [--sources <path>...] [--out <file>] [--run <id>] [--timeout <seconds>]\n';
|
|
38
51
|
|
|
39
52
|
/**
|
|
@@ -107,6 +120,8 @@ export async function runFactsVerb(args, deps = {}) {
|
|
|
107
120
|
return exitCodeFor(result);
|
|
108
121
|
}
|
|
109
122
|
const facts = result.facts ?? [];
|
|
123
|
+
// B39: a row whose check broke a rule is kept as UNVERIFIABLE (not counted); say so, exit 0
|
|
124
|
+
for (const d of result.downgraded ?? []) writeSafe(stderr, downgradeLine(d));
|
|
110
125
|
writeSafe(stdout, `${JSON.stringify({ out: outPath, claims: result.claims.length, verified: facts.filter((f) => f.tag === 'VERIFIED').length })}\n`);
|
|
111
126
|
return 0;
|
|
112
127
|
} catch (err) {
|
package/src/cli/jev.mjs
CHANGED
|
@@ -5,6 +5,19 @@
|
|
|
5
5
|
* `src/decide/**` directly rather than shelling out to this verb).
|
|
6
6
|
*
|
|
7
7
|
* jev ask <id> --state <file.json> [--cwd <dir>] [--slug <slug>] [--key-ref <ref>]
|
|
8
|
+
* [--block <id>] [--plan <file>] [--rules]
|
|
9
|
+
*
|
|
10
|
+
* B36: `--block` records the block the answer is for (and `--plan` the plan file's base name) on
|
|
11
|
+
* the decision row — `plan check` refuses a block whose level is not the lane recorded this way.
|
|
12
|
+
* `--rules` answers `lane`, `risk` or `security_sensitive` with the deterministic fallback rules
|
|
13
|
+
* (`decide/fallback-rules.mjs`, for a run without Jev): the state file is then an object carrying
|
|
14
|
+
* all six fallback facts (`filesChanged`, `linesAdded` numbers; `touchesMigration`,
|
|
15
|
+
* `touchesPolicyOrMiddleware`, `pathFloorHit` booleans; `keywordsFound` a string list) — a missing
|
|
16
|
+
* or mistyped one is a usage error naming it, with nothing recorded. `system1.disable` still
|
|
17
|
+
* applies. No key is resolved, no request is sent, and the row says `source: 'rules'`.
|
|
18
|
+
* The ledger slug is `--slug`, else `project.slug`, else the directory-name slug `run` and
|
|
19
|
+
* `author` use (`slugFor`; B36 — it used to fall back to `code-forge`). An unknown flag is a usage
|
|
20
|
+
* error.
|
|
8
21
|
*
|
|
9
22
|
* Resolves the Jev key through B2's chain (`resolveKey`, earlier batch — the seam rule allows a
|
|
10
23
|
* Wave 1 module to import a block in an earlier batch statically), asks Jev, runs the answer
|
|
@@ -23,24 +36,74 @@ import path from 'node:path';
|
|
|
23
36
|
import { buildQuestionPayload, isKnownQuestion, isQuestionDisabled } from '../decide/questions.mjs';
|
|
24
37
|
import { askJev as realAskJev } from '../decide/jev-client.mjs';
|
|
25
38
|
import { decide } from '../decide/thresholds.mjs';
|
|
39
|
+
import { resolveDispatchQuestionsByRules } from '../decide/fallback-rules.mjs';
|
|
26
40
|
import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
|
|
27
41
|
import { appendRow as realAppendRow } from '../ledger/write.mjs';
|
|
28
42
|
import { writeSafe } from '../util/redact.mjs';
|
|
43
|
+
import { slugFor } from './run.mjs';
|
|
44
|
+
|
|
45
|
+
const USAGE = 'usage: code-forge jev ask <question-id> --state <file> [--cwd <dir>] [--slug <slug>] [--key-ref <ref>] [--block <id>] [--plan <file>] [--rules]\n';
|
|
46
|
+
|
|
47
|
+
/** The block id shape the run record uses (`state/block.mjs`). */
|
|
48
|
+
const BLOCK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/;
|
|
49
|
+
|
|
50
|
+
/** Flags that take a value, and the one boolean flag. */
|
|
51
|
+
const VALUE_FLAGS = Object.freeze(['--state', '--cwd', '--slug', '--key-ref', '--block', '--plan']);
|
|
52
|
+
const BOOLEAN_FLAGS = Object.freeze(['--rules']);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Strict flag walk over everything after `ask <id>`: a value flag takes the next token (never one
|
|
56
|
+
* that starts with `--`, so a bare `--slug --cwd x` never reads `--cwd` as the slug), a boolean
|
|
57
|
+
* flag takes none, and an unknown token, a repeated flag or a missing value is a usage error.
|
|
58
|
+
* @param {string[]} rest
|
|
59
|
+
* @returns {{ok: true, values: Record<string, string>, booleans: Set<string>} | {ok: false}}
|
|
60
|
+
*/
|
|
61
|
+
function parseAskFlags(rest) {
|
|
62
|
+
/** @type {Record<string, string>} */
|
|
63
|
+
const values = {};
|
|
64
|
+
const booleans = new Set();
|
|
65
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
66
|
+
const arg = rest[i];
|
|
67
|
+
if (BOOLEAN_FLAGS.includes(arg)) {
|
|
68
|
+
if (booleans.has(arg)) return { ok: false };
|
|
69
|
+
booleans.add(arg);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (!VALUE_FLAGS.includes(arg) || Object.hasOwn(values, arg)) return { ok: false };
|
|
73
|
+
const next = rest[i + 1];
|
|
74
|
+
if (next === undefined || next.startsWith('--')) return { ok: false };
|
|
75
|
+
values[arg] = next;
|
|
76
|
+
i += 1;
|
|
77
|
+
}
|
|
78
|
+
return { ok: true, values, booleans };
|
|
79
|
+
}
|
|
29
80
|
|
|
30
|
-
|
|
81
|
+
/** The six fallback facts `--rules` needs, each with its type (`decide/fallback-rules.mjs`). */
|
|
82
|
+
const RULE_FACTS = Object.freeze({
|
|
83
|
+
filesChanged: 'number',
|
|
84
|
+
linesAdded: 'number',
|
|
85
|
+
touchesMigration: 'boolean',
|
|
86
|
+
touchesPolicyOrMiddleware: 'boolean',
|
|
87
|
+
pathFloorHit: 'boolean',
|
|
88
|
+
keywordsFound: 'string[]',
|
|
89
|
+
});
|
|
31
90
|
|
|
32
91
|
/**
|
|
33
|
-
* @param {
|
|
34
|
-
* @returns {
|
|
35
|
-
*
|
|
36
|
-
* must never silently read `--cwd` as `--slug`'s value.
|
|
92
|
+
* @param {unknown} state
|
|
93
|
+
* @returns {string[]} the fallback facts that are missing or of the wrong type (all six when the
|
|
94
|
+
* state is not a plain object).
|
|
37
95
|
*/
|
|
38
|
-
function
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
96
|
+
function badRuleFacts(state) {
|
|
97
|
+
if (state === null || typeof state !== 'object' || Array.isArray(state)) return Object.keys(RULE_FACTS);
|
|
98
|
+
const facts = /** @type {Record<string, unknown>} */ (state);
|
|
99
|
+
return Object.entries(RULE_FACTS)
|
|
100
|
+
.filter(([name, type]) => {
|
|
101
|
+
const v = facts[name];
|
|
102
|
+
if (type === 'number') return typeof v !== 'number' || !Number.isFinite(v) || v < 0;
|
|
103
|
+
if (type === 'boolean') return typeof v !== 'boolean';
|
|
104
|
+
return !Array.isArray(v) || !v.every((k) => typeof k === 'string');
|
|
105
|
+
})
|
|
106
|
+
.map(([name]) => name);
|
|
44
107
|
}
|
|
45
108
|
|
|
46
109
|
/**
|
|
@@ -82,14 +145,31 @@ export async function runJev(args, deps = {}) {
|
|
|
82
145
|
return 2;
|
|
83
146
|
}
|
|
84
147
|
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
const slugFlag = readOptionalFlag(args, '--slug');
|
|
88
|
-
const keyRefFlag = readOptionalFlag(args, '--key-ref');
|
|
89
|
-
if (!stateFlag.ok || !cwdFlag.ok || !slugFlag.ok || !keyRefFlag.ok || !stateFlag.value) {
|
|
148
|
+
const parsed = parseAskFlags(args.slice(2));
|
|
149
|
+
if (!parsed.ok || parsed.values['--state'] === undefined) {
|
|
90
150
|
err(USAGE);
|
|
91
151
|
return 2;
|
|
92
152
|
}
|
|
153
|
+
const stateFlag = { value: parsed.values['--state'] };
|
|
154
|
+
const cwdFlag = { value: parsed.values['--cwd'] };
|
|
155
|
+
const slugFlag = { value: parsed.values['--slug'] };
|
|
156
|
+
const keyRefFlag = { value: parsed.values['--key-ref'] };
|
|
157
|
+
const blockFlag = { value: parsed.values['--block'] };
|
|
158
|
+
const planFlag = { value: parsed.values['--plan'] };
|
|
159
|
+
if (blockFlag.value !== undefined && !BLOCK_ID.test(blockFlag.value)) {
|
|
160
|
+
err('jev ask: --block must be a block id (letters, digits, . _ -; at most 32 characters)\n');
|
|
161
|
+
return 2;
|
|
162
|
+
}
|
|
163
|
+
const rules = parsed.booleans.has('--rules');
|
|
164
|
+
/** @type {Record<string, unknown>} */
|
|
165
|
+
const scope = {
|
|
166
|
+
...(blockFlag.value !== undefined ? { block: blockFlag.value } : {}),
|
|
167
|
+
...(planFlag.value !== undefined ? { plan: path.basename(planFlag.value) } : {}),
|
|
168
|
+
};
|
|
169
|
+
if (rules && !['lane', 'risk', 'security_sensitive'].includes(id)) {
|
|
170
|
+
err(`jev ask: no rule answers "${id}" (--rules covers lane, risk, security_sensitive)\n`);
|
|
171
|
+
return 2;
|
|
172
|
+
}
|
|
93
173
|
const cwd = cwdFlag.value ?? process.cwd();
|
|
94
174
|
|
|
95
175
|
let state;
|
|
@@ -110,11 +190,28 @@ export async function runJev(args, deps = {}) {
|
|
|
110
190
|
return 1;
|
|
111
191
|
}
|
|
112
192
|
|
|
193
|
+
// the same ledger as the rest of the tool: --slug, else project.slug, else the directory name
|
|
194
|
+
const slug = slugFlag.value ?? slugFor(cfg, cwd);
|
|
195
|
+
|
|
113
196
|
if (isQuestionDisabled(id, cfg)) {
|
|
114
197
|
err(`jev ask: question "${id}" is disabled by system1.disable\n`);
|
|
115
198
|
return 1;
|
|
116
199
|
}
|
|
117
200
|
|
|
201
|
+
if (rules) {
|
|
202
|
+
const bad = badRuleFacts(state);
|
|
203
|
+
if (bad.length > 0) {
|
|
204
|
+
err(`jev ask: --rules needs the fallback facts in the --state object; missing or wrong type: ${bad.join(', ')}\n`);
|
|
205
|
+
return 2;
|
|
206
|
+
}
|
|
207
|
+
const byRules = resolveDispatchQuestionsByRules(state);
|
|
208
|
+
const value = byRules[/** @type {'lane'|'risk'|'security_sensitive'} */ (id)].value;
|
|
209
|
+
const decisionId = makeId();
|
|
210
|
+
await write({ event: 'decision', decision_id: decisionId, question: id, answer: value, source: 'rules', ...scope }, { slug });
|
|
211
|
+
out(`${JSON.stringify({ decision_id: decisionId, question: id, answer: value, source: 'rules', ...scope }, null, 2)}\n`);
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
118
215
|
const keyStore = store ?? (await createDefaultKeyStore(env));
|
|
119
216
|
const keyRef = keyRefFlag.value ?? cfg?.keys?.jev;
|
|
120
217
|
let resolved;
|
|
@@ -146,7 +243,6 @@ export async function runJev(args, deps = {}) {
|
|
|
146
243
|
return 1;
|
|
147
244
|
}
|
|
148
245
|
const decision = decide(id, answer, cfg);
|
|
149
|
-
const slug = slugFlag.value ?? cfg?.project?.slug ?? 'code-forge';
|
|
150
246
|
const decisionId = makeId();
|
|
151
247
|
|
|
152
248
|
await write(
|
|
@@ -159,6 +255,7 @@ export async function runJev(args, deps = {}) {
|
|
|
159
255
|
confidence: decision.confidence,
|
|
160
256
|
margin: decision.margin,
|
|
161
257
|
source: 'jev',
|
|
258
|
+
...scope,
|
|
162
259
|
tokens_in: result.usage?.input_tokens,
|
|
163
260
|
tokens_out: result.usage?.output_tokens,
|
|
164
261
|
},
|
package/src/cli/ledger.mjs
CHANGED
|
@@ -6,13 +6,20 @@
|
|
|
6
6
|
* ledger calibration --slug <slug> [--question <id>]
|
|
7
7
|
* ledger outcome --scan-git --slug <slug> [--cwd <dir>] [--days <n>]
|
|
8
8
|
* ledger outcome --pr <n> --ci red|green|reverted --slug <slug>
|
|
9
|
-
* ledger tail --slug <slug> [--n <count>]
|
|
9
|
+
* ledger tail --slug <slug> [--n <count> | -n <count>]
|
|
10
|
+
* ledger add coder --run <r> --block <b> --usd <n> [--note "..."]
|
|
11
|
+
*
|
|
12
|
+
* B33: `ledger add coder` records manual/cloud coder spend (a session code-forge never saw) as a
|
|
13
|
+
* signed `session` row (`role: coder, manual: true, usd, cost_source: manual`) in the run's
|
|
14
|
+
* ledger, so `report` and the `budget.usd` check count it. The slug is the run record's project.
|
|
10
15
|
*/
|
|
11
16
|
|
|
12
17
|
import { writeSafe } from '../util/redact.mjs';
|
|
13
18
|
import { buildCalibration } from '../ledger/calibration.mjs';
|
|
14
19
|
import { recordCiOutcome, reviewedEntriesFromRows, scanGitAndRecord } from '../ledger/outcome.mjs';
|
|
15
|
-
import {
|
|
20
|
+
import { roundUsd, runSpend } from '../ledger/spend.mjs';
|
|
21
|
+
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
22
|
+
import { readRun, writeSigned } from '../state/run.mjs';
|
|
16
23
|
|
|
17
24
|
/**
|
|
18
25
|
* @param {string[]} args @param {string} flag
|
|
@@ -56,9 +63,10 @@ export async function runLedger(args, deps = {}) {
|
|
|
56
63
|
const { stdout = process.stdout, stderr = process.stderr } = deps;
|
|
57
64
|
const out = (/** @type {string} */ s) => writeSafe(stdout, s);
|
|
58
65
|
const err = (/** @type {string} */ s) => writeSafe(stderr, s);
|
|
59
|
-
const usage = () => err('usage: code-forge ledger calibration|outcome|tail --slug <slug> [...]\n');
|
|
66
|
+
const usage = () => err('usage: code-forge ledger calibration|outcome|tail --slug <slug> [...] (tail: [--n|-n <count>]) | add coder --run <r> --block <b> --usd <n> [--note "..."]\n');
|
|
60
67
|
|
|
61
68
|
const [sub] = args;
|
|
69
|
+
if (sub === 'add') return addSpend(args.slice(1), { out, err });
|
|
62
70
|
const slug = flagValue(args, '--slug');
|
|
63
71
|
if (!slug) {
|
|
64
72
|
usage();
|
|
@@ -126,15 +134,22 @@ export async function runLedger(args, deps = {}) {
|
|
|
126
134
|
}
|
|
127
135
|
|
|
128
136
|
if (sub === 'tail') {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
137
|
+
// `-n` is the spelling everyone types from `tail(1)`; before B31 it was not recognised, so
|
|
138
|
+
// `ledger tail -n 3` silently printed the default 20 rows (issue #2, field note 8).
|
|
139
|
+
const longN = readOptionalFlag(args, '--n');
|
|
140
|
+
const shortN = readOptionalFlag(args, '-n');
|
|
141
|
+
if (!longN.ok || !shortN.ok) {
|
|
142
|
+
err(`ledger tail: ${longN.ok ? '-n' : '--n'} requires a value\n`);
|
|
143
|
+
return 2;
|
|
144
|
+
}
|
|
145
|
+
if (longN.value !== undefined && shortN.value !== undefined) {
|
|
146
|
+
err('ledger tail: give the count once, as --n <count> or -n <count>\n');
|
|
132
147
|
return 2;
|
|
133
148
|
}
|
|
134
|
-
const nArg =
|
|
149
|
+
const nArg = longN.value ?? shortN.value ?? '20';
|
|
135
150
|
const n = parseNonNegativeInt(nArg);
|
|
136
151
|
if (n === null) {
|
|
137
|
-
err(`ledger tail: --n must be a non-negative integer, got "${nArg}"\n`);
|
|
152
|
+
err(`ledger tail: --n/-n must be a non-negative integer, got "${nArg}"\n`);
|
|
138
153
|
return 2;
|
|
139
154
|
}
|
|
140
155
|
const rows = await readAllRows(slug);
|
|
@@ -154,6 +169,82 @@ export async function runLedger(args, deps = {}) {
|
|
|
154
169
|
return 2;
|
|
155
170
|
}
|
|
156
171
|
|
|
172
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
173
|
+
const MAX_NOTE_CHARS = 500;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* `ledger add coder --run <r> --block <b> --usd <n> [--note "..."]` (B33).
|
|
177
|
+
* @param {string[]} args - after `add`.
|
|
178
|
+
* @param {{out: (s: string) => void, err: (s: string) => void}} io
|
|
179
|
+
* @returns {Promise<number>}
|
|
180
|
+
*/
|
|
181
|
+
async function addSpend(args, { out, err }) {
|
|
182
|
+
const usage = () => err('usage: code-forge ledger add coder --run <r> --block <b> --usd <n> [--note "..."]\n');
|
|
183
|
+
const [role] = args;
|
|
184
|
+
const run = flagValue(args, '--run');
|
|
185
|
+
const block = flagValue(args, '--block');
|
|
186
|
+
const usdArg = flagValue(args, '--usd');
|
|
187
|
+
if (role !== 'coder' || !run || !block || usdArg === undefined) {
|
|
188
|
+
usage();
|
|
189
|
+
return 2;
|
|
190
|
+
}
|
|
191
|
+
const note = readOptionalFlag(args, '--note');
|
|
192
|
+
if (!note.ok) {
|
|
193
|
+
err('ledger add: --note requires a value\n');
|
|
194
|
+
return 2;
|
|
195
|
+
}
|
|
196
|
+
if (!ID.test(run)) {
|
|
197
|
+
err('ledger add: --run must be a run id (letters, digits, ".", "_", "-")\n');
|
|
198
|
+
return 2;
|
|
199
|
+
}
|
|
200
|
+
if (!ID.test(block)) {
|
|
201
|
+
err('ledger add: --block must be a block id (letters, digits, ".", "_", "-")\n');
|
|
202
|
+
return 2;
|
|
203
|
+
}
|
|
204
|
+
// rounded to the ledger's 4 decimals FIRST, then checked: 0.00001 rounds to 0 and is refused
|
|
205
|
+
const usd = /^\d+(\.\d+)?$/.test(usdArg) ? roundUsd(Number(usdArg)) : Number.NaN;
|
|
206
|
+
if (!Number.isFinite(usd) || usd <= 0) {
|
|
207
|
+
err(`ledger add: --usd must be a number > 0 (at 4 decimals), got "${usdArg}"\n`);
|
|
208
|
+
return 2;
|
|
209
|
+
}
|
|
210
|
+
if (note.value !== undefined && note.value.length > MAX_NOTE_CHARS) {
|
|
211
|
+
err(`ledger add: --note is longer than ${MAX_NOTE_CHARS} characters\n`);
|
|
212
|
+
return 2;
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const { project } = await readRun(run);
|
|
216
|
+
const writeRow = (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: project });
|
|
217
|
+
const row = {
|
|
218
|
+
event: 'session',
|
|
219
|
+
role: 'coder',
|
|
220
|
+
manual: true,
|
|
221
|
+
block,
|
|
222
|
+
provider: null,
|
|
223
|
+
level: null,
|
|
224
|
+
tokens_in: null,
|
|
225
|
+
tokens_out: null,
|
|
226
|
+
usd,
|
|
227
|
+
cost_source: 'manual',
|
|
228
|
+
...(note.value !== undefined ? { note: note.value } : {}),
|
|
229
|
+
};
|
|
230
|
+
await writeSigned(run, writeRow, row);
|
|
231
|
+
// the row is written: from here on nothing may throw or change the exit code
|
|
232
|
+
/** @type {number | null} */
|
|
233
|
+
let total = null;
|
|
234
|
+
try {
|
|
235
|
+
const spent = runSpend(await readAllRows(project), run).usd;
|
|
236
|
+
total = Number.isFinite(spent) ? spent : null;
|
|
237
|
+
} catch {
|
|
238
|
+
total = null;
|
|
239
|
+
}
|
|
240
|
+
out(`recorded coder spend $${usd.toFixed(4)} for run ${run} block ${block} · run total ${total === null ? 'unknown' : `$${total.toFixed(4)}`}\n`);
|
|
241
|
+
return 0;
|
|
242
|
+
} catch (thrown) {
|
|
243
|
+
err(`ledger add: ${thrown?.message ?? String(thrown)}\n`);
|
|
244
|
+
return 1;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
157
248
|
/** @param {string[]} args @returns {Promise<number>} */
|
|
158
249
|
export default async function ledger(args) {
|
|
159
250
|
return runLedger(args);
|