@xhulz/nina 0.28.21 → 0.29.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/README.md +2 -2
- package/package.json +1 -1
- package/releases/0.29.0/core/GAPS.md +125 -0
- package/releases/0.29.0/core/answered.json +29 -0
- package/releases/0.29.0/core/defaults/tree/.claude/agents/architect.md +1 -0
- package/releases/0.29.0/core/defaults/tree/.claude/agents/dba.md +3 -0
- package/releases/0.29.0/core/defaults/tree/.claude/agents/devops.md +1 -0
- package/releases/0.29.0/core/defaults/tree/.claude/agents/implementer.md +1 -0
- package/releases/0.29.0/core/defaults/tree/.claude/agents/integration-tester.md +3 -0
- package/releases/0.29.0/core/defaults/tree/.claude/agents/qa.md +2 -0
- package/releases/0.29.0/core/defaults/tree/.claude/agents/reviewer.md +3 -0
- package/releases/0.29.0/core/defaults/tree/.claude/agents/secops.md +3 -0
- package/releases/0.29.0/core/defaults/tree/.claude/agents/solidity-auditor.md +3 -0
- package/releases/0.29.0/core/defaults/tree/.claude/agents/solidity-dev.md +3 -0
- package/releases/0.29.0/core/tree/.claude/agents/architect.md +125 -0
- package/releases/0.29.0/core/tree/.claude/agents/dba.md +107 -0
- package/releases/0.29.0/core/tree/.claude/agents/devops.md +113 -0
- package/releases/0.29.0/core/tree/.claude/agents/implementer.md +155 -0
- package/releases/0.29.0/core/tree/.claude/agents/integration-tester.md +160 -0
- package/releases/0.29.0/core/tree/.claude/agents/planner.md +132 -0
- package/releases/0.29.0/core/tree/.claude/agents/qa.md +146 -0
- package/releases/0.29.0/core/tree/.claude/agents/reviewer.md +180 -0
- package/releases/0.29.0/core/tree/.claude/agents/secops.md +121 -0
- package/releases/0.29.0/core/tree/.claude/agents/solidity-auditor.md +126 -0
- package/releases/0.29.0/core/tree/.claude/agents/solidity-dev.md +123 -0
- package/releases/0.29.0/core/tree/.claude/agents-overview.md +64 -0
- package/releases/0.29.0/core/tree/.claude/graph.md +73 -0
- package/releases/0.29.0/core/tree/.claude/patterns.md +212 -0
- package/releases/0.29.0/core/tree/.claude/pills/README.md +145 -0
- package/releases/0.29.0/core/tree/.claude/pipeline.md +102 -0
- package/releases/0.29.0/core/tree/.claude/retrieval.md +104 -0
- package/releases/0.29.0/core/tree/.claude/router.md +201 -0
- package/releases/0.29.0/core/tree/.claude/templates/integration.md +45 -0
- package/releases/0.29.0/core/tree/CLAUDE.md +155 -0
- package/releases/0.29.0/core/tree/scripts/edit-guard.mjs +16 -0
- package/releases/0.29.0/core/tree/scripts/harness-check.mjs +71 -0
- package/releases/0.29.0/core/tree/scripts/loop-gate.mjs +19 -0
- package/releases/0.29.0/core/vocabulary.json +12 -0
- package/releases/0.29.0/surfaces/blockchain/tree/.claude/agents/architect.md +5 -0
- package/releases/0.29.0/surfaces/blockchain/tree/.claude/agents/devops.md +5 -0
- package/releases/0.29.0/surfaces/blockchain/tree/.claude/agents/reviewer.md +5 -0
- package/releases/0.29.0/surfaces/blockchain/tree/.claude/agents/solidity-auditor.md +18 -0
- package/releases/0.29.0/surfaces/blockchain/tree/.claude/agents/solidity-dev.md +16 -0
- package/releases/0.29.0/surfaces/blockchain/tree/.claude/agents-overview.md +6 -0
- package/releases/0.29.0/surfaces/blockchain/tree/.claude/graph.md +11 -0
- package/releases/0.29.0/surfaces/blockchain/tree/.claude/router.md +9 -0
- package/releases/0.29.0/surfaces/blockchain/tree/CLAUDE.md +12 -0
- package/releases/0.29.0/surfaces/db/tree/.claude/agents/architect.md +8 -0
- package/releases/0.29.0/surfaces/db/tree/.claude/agents/dba.md +84 -0
- package/releases/0.29.0/surfaces/db/tree/.claude/agents/devops.md +14 -0
- package/releases/0.29.0/surfaces/db/tree/.claude/agents/implementer.md +5 -0
- package/releases/0.29.0/surfaces/db/tree/.claude/agents/integration-tester.md +13 -0
- package/releases/0.29.0/surfaces/db/tree/.claude/agents/planner.md +2 -0
- package/releases/0.29.0/surfaces/db/tree/.claude/agents/reviewer.md +25 -0
- package/releases/0.29.0/surfaces/db/tree/.claude/agents/secops.md +2 -0
- package/releases/0.29.0/surfaces/db/tree/.claude/agents-overview.md +5 -0
- package/releases/0.29.0/surfaces/db/tree/.claude/graph.md +8 -0
- package/releases/0.29.0/surfaces/db/tree/.claude/patterns.md +29 -0
- package/releases/0.29.0/surfaces/db/tree/.claude/pipeline.md +13 -0
- package/releases/0.29.0/surfaces/db/tree/.claude/retrieval.md +5 -0
- package/releases/0.29.0/surfaces/db/tree/.claude/router.md +10 -0
- package/releases/0.29.0/surfaces/db/tree/CLAUDE.md +23 -0
- package/releases/0.29.0/surfaces/edge-cf/tree/.claude/agents/architect.md +14 -0
- package/releases/0.29.0/surfaces/edge-cf/tree/.claude/agents/dba.md +2 -0
- package/releases/0.29.0/surfaces/edge-cf/tree/.claude/agents/devops.md +31 -0
- package/releases/0.29.0/surfaces/edge-cf/tree/.claude/agents/implementer.md +14 -0
- package/releases/0.29.0/surfaces/edge-cf/tree/.claude/agents/integration-tester.md +11 -0
- package/releases/0.29.0/surfaces/edge-cf/tree/.claude/agents/qa.md +3 -0
- package/releases/0.29.0/surfaces/edge-cf/tree/.claude/agents/reviewer.md +8 -0
- package/releases/0.29.0/surfaces/edge-cf/tree/.claude/agents/secops.md +8 -0
- package/releases/0.29.0/surfaces/edge-cf/tree/.claude/patterns.md +17 -0
- package/releases/0.29.0/surfaces/edge-cf/tree/.claude/retrieval.md +8 -0
- package/releases/0.29.0/surfaces/edge-cf/tree/.claude/router.md +21 -0
- package/releases/0.29.0/surfaces/edge-cf/tree/CLAUDE.md +27 -0
- package/releases/0.29.0/surfaces/frontend/tree/.claude/agents/architect.md +20 -0
- package/releases/0.29.0/surfaces/frontend/tree/.claude/agents/devops.md +28 -0
- package/releases/0.29.0/surfaces/frontend/tree/.claude/agents/implementer.md +25 -0
- package/releases/0.29.0/surfaces/frontend/tree/.claude/agents/qa.md +17 -0
- package/releases/0.29.0/surfaces/frontend/tree/.claude/agents/reviewer.md +62 -0
- package/releases/0.29.0/surfaces/frontend/tree/.claude/patterns.md +15 -0
- package/releases/0.29.0/surfaces/frontend/tree/.claude/retrieval.md +5 -0
- package/releases/0.29.0/surfaces/frontend/tree/.claude/router.md +17 -0
- package/releases/0.29.0/surfaces/frontend/tree/CLAUDE.md +5 -0
- package/releases/0.29.0/surfaces/integrations/tree/.claude/agents/architect.md +16 -0
- package/releases/0.29.0/surfaces/integrations/tree/.claude/agents/implementer.md +12 -0
- package/releases/0.29.0/surfaces/integrations/tree/.claude/agents/integration-tester.md +20 -0
- package/releases/0.29.0/surfaces/integrations/tree/.claude/agents/planner.md +2 -0
- package/releases/0.29.0/surfaces/integrations/tree/.claude/agents/reviewer.md +21 -0
- package/releases/0.29.0/surfaces/integrations/tree/.claude/agents/secops.md +2 -0
- package/releases/0.29.0/surfaces/integrations/tree/.claude/agents-overview.md +8 -0
- package/releases/0.29.0/surfaces/integrations/tree/.claude/graph.md +9 -0
- package/releases/0.29.0/surfaces/integrations/tree/.claude/patterns.md +38 -0
- package/releases/0.29.0/surfaces/integrations/tree/.claude/pipeline.md +16 -0
- package/releases/0.29.0/surfaces/integrations/tree/.claude/retrieval.md +16 -0
- package/releases/0.29.0/surfaces/integrations/tree/.claude/router.md +6 -0
- package/releases/0.29.0/surfaces/integrations/tree/CLAUDE.md +17 -0
- package/releases/0.29.0/surfaces/money/tree/.claude/agents/architect.md +12 -0
- package/releases/0.29.0/surfaces/money/tree/.claude/agents/dba.md +14 -0
- package/releases/0.29.0/surfaces/money/tree/.claude/agents/implementer.md +11 -0
- package/releases/0.29.0/surfaces/money/tree/.claude/agents/integration-tester.md +3 -0
- package/releases/0.29.0/surfaces/money/tree/.claude/agents/reviewer.md +23 -0
- package/releases/0.29.0/surfaces/money/tree/.claude/agents/secops.md +5 -0
- package/releases/0.29.0/surfaces/money/tree/.claude/agents/solidity-auditor.md +4 -0
- package/releases/0.29.0/surfaces/money/tree/.claude/agents/solidity-dev.md +4 -0
- package/releases/0.29.0/surfaces/money/tree/.claude/graph.md +2 -0
- package/releases/0.29.0/surfaces/money/tree/.claude/patterns.md +49 -0
- package/releases/0.29.0/surfaces/money/tree/.claude/pipeline.md +2 -0
- package/releases/0.29.0/surfaces/money/tree/.claude/retrieval.md +2 -0
- package/releases/0.29.0/surfaces/money/tree/CLAUDE.md +11 -0
- package/releases/0.29.0/surfaces/pii/tree/.claude/agents/dba.md +2 -0
- package/releases/0.29.0/surfaces/pii/tree/.claude/agents/devops.md +8 -0
- package/releases/0.29.0/surfaces/pii/tree/.claude/agents/reviewer.md +8 -0
- package/releases/0.29.0/surfaces/pii/tree/.claude/agents/secops.md +2 -0
- package/releases/0.29.0/surfaces/pii/tree/.claude/agents/solidity-auditor.md +5 -0
- package/releases/0.29.0/surfaces/pii/tree/CLAUDE.md +5 -0
- package/src/agentrun.mjs +12 -4
- package/src/commands/compose.mjs +45 -1
- package/src/commands/export.mjs +15 -4
- package/src/commands/gate.mjs +91 -8
- package/src/commands/init.mjs +34 -15
- package/src/commands/learn.mjs +22 -9
- package/src/commands/pipeline.mjs +17 -42
- package/src/commands/snapshot.mjs +35 -4
- package/src/commands/stats.mjs +96 -53
- package/src/commands/upgrade.mjs +3 -0
- package/src/deep.mjs +4 -31
- package/src/detectors.mjs +33 -4
- package/src/gate.mjs +1 -1
- package/src/langfuse.mjs +2 -0
- package/src/transcripts.mjs +232 -81
package/src/commands/compose.mjs
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
20
20
|
import { HARNESS, legacyHint } from '../paths.mjs';
|
|
21
21
|
import { expectedUnfilled } from '../expected.mjs';
|
|
22
|
-
import { existsSync } from 'node:fs';
|
|
22
|
+
import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
23
23
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
24
24
|
import { defaultVocabulary } from '../vocabulary.mjs';
|
|
25
25
|
|
|
@@ -327,6 +327,34 @@ export async function composedPaths(layerRoot, surfaces = []) {
|
|
|
327
327
|
return out;
|
|
328
328
|
}
|
|
329
329
|
|
|
330
|
+
/**
|
|
331
|
+
* The files of the project's own that sit where the harness composes one: a CLAUDE.md or an agent spec
|
|
332
|
+
* written by hand before the harness arrived, or a symlink, which a compose would write through. A file
|
|
333
|
+
* that carries a notice can say it was composed; one with no notice where every composed file of its kind
|
|
334
|
+
* has one is the project's, and composing over it would destroy it. `init` refused to compose over one and
|
|
335
|
+
* told the owner to move it aside and run `compose` — which then wrote over whatever was still there, with
|
|
336
|
+
* no copy kept; so did every compose after a surface was added to a profile by hand.
|
|
337
|
+
*
|
|
338
|
+
* @param {string} target - The project.
|
|
339
|
+
* @param {Iterable<string>} paths - Paths the harness composes, relative to it.
|
|
340
|
+
* @returns {string[]} The ones in the way, sorted.
|
|
341
|
+
*/
|
|
342
|
+
export function projectOwned(target, paths) {
|
|
343
|
+
const out = [];
|
|
344
|
+
for (const p of paths) {
|
|
345
|
+
let entry;
|
|
346
|
+
try {
|
|
347
|
+
entry = lstatSync(join(target, p));
|
|
348
|
+
} catch {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const stamped = p.endsWith('.md') || SCRIPT.test(p);
|
|
352
|
+
// Read without following links, a link is not a file: it is the project's, whatever it points at.
|
|
353
|
+
if (!entry.isFile() || (stamped && !readFileSync(join(target, p), 'utf8').includes('nina:generated'))) out.push(p);
|
|
354
|
+
}
|
|
355
|
+
return out.sort();
|
|
356
|
+
}
|
|
357
|
+
|
|
330
358
|
/**
|
|
331
359
|
* Composes one project, and reports what it did.
|
|
332
360
|
*
|
|
@@ -368,6 +396,22 @@ export async function composeProject(target, ctx, options = {}) {
|
|
|
368
396
|
/** Layers whose fragments are expected to exist: the declared surfaces, plus the project's own. */
|
|
369
397
|
const declared = new Set([...surfaces, 'project']);
|
|
370
398
|
|
|
399
|
+
// Nothing is written while a file of the project's own sits where one would go, so a compose that
|
|
400
|
+
// refuses leaves the project as it found it.
|
|
401
|
+
const owned = check ? [] : projectOwned(target, await composedPaths(layerRoot, surfaces));
|
|
402
|
+
if (owned.length > 0) {
|
|
403
|
+
return {
|
|
404
|
+
error:
|
|
405
|
+
`${owned.length} file(s) of this project's own sit where the harness composes one, and composing would destroy them — ` +
|
|
406
|
+
`nothing was written:\n${owned.map((p) => ` ${p}`).join('\n')}\n` +
|
|
407
|
+
`Move each aside, or put its text in ${HARNESS}/project/tree/<the same path> as the fragment that fills its slot, then compose again.`,
|
|
408
|
+
written,
|
|
409
|
+
differ,
|
|
410
|
+
unfilled: [],
|
|
411
|
+
skipped,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
371
415
|
/** Slots the core expects a declared layer to fill, and no layer did. */
|
|
372
416
|
const unfilled = [];
|
|
373
417
|
/** Files a `nina:why` marker composed into, unclosed. */
|
package/src/commands/export.mjs
CHANGED
|
@@ -26,10 +26,11 @@ import { join } from 'node:path';
|
|
|
26
26
|
import { fileURLToPath } from 'node:url';
|
|
27
27
|
import { readRun, runFile } from '../agentrun.mjs';
|
|
28
28
|
import { BATCH, SETTLE_MINUTES, postScore, postScores, postSpans, scoreOf, settled, spanOf, spansOf } from '../langfuse.mjs';
|
|
29
|
-
import { transcriptsOf } from '../transcripts.mjs';
|
|
29
|
+
import { runOf, transcriptsOf } from '../transcripts.mjs';
|
|
30
30
|
import { exportsDir, snapshotsDir } from '../paths.mjs';
|
|
31
31
|
import { readConfig, statusPath, targetOf } from './langfuse.mjs';
|
|
32
32
|
import { projectName } from './stats.mjs';
|
|
33
|
+
import { handsToModel } from '../detectors.mjs';
|
|
33
34
|
|
|
34
35
|
/** The CLI, for the export the detector starts in the background. */
|
|
35
36
|
const BIN = fileURLToPath(new URL('../../bin/nina.mjs', import.meta.url));
|
|
@@ -140,7 +141,16 @@ async function keep(slug, sent) {
|
|
|
140
141
|
* sends what it does from then on, and its history only when asked.
|
|
141
142
|
*/
|
|
142
143
|
export function due(records, sent, now = new Date(), since = '') {
|
|
143
|
-
|
|
144
|
+
// A round split from a run captured before rounds were, whose first round went while it still held the
|
|
145
|
+
// whole run's spend: sent now, its tokens would be counted twice. Its first round's record has changed
|
|
146
|
+
// since it went, which is how it is told.
|
|
147
|
+
const byId = new Map(records.map((r) => [r.dispatch_id, r]));
|
|
148
|
+
const sentWhole = (r) => {
|
|
149
|
+
if (!r.backfilled) return false;
|
|
150
|
+
const first = byId.get(runOf(r));
|
|
151
|
+
return Boolean(first && sent.spans[first.dispatch_id] && sent.spans[first.dispatch_id] !== digest(first));
|
|
152
|
+
};
|
|
153
|
+
const spans = records.filter((r) => r.dispatch_id && !sent.spans[r.dispatch_id] && String(r.ts) >= since && settled(r, now) && !sentWhole(r) && spanOf(r, ''));
|
|
144
154
|
const going = new Set(spans.map((r) => r.dispatch_id));
|
|
145
155
|
const scores = records.filter((r) => (sent.spans[r.dispatch_id] || going.has(r.dispatch_id)) && !sent.scores[r.dispatch_id] && scoreOf(r));
|
|
146
156
|
const changed = records.filter((r) => sent.spans[r.dispatch_id] && sent.spans[r.dispatch_id] !== digest(r)).length;
|
|
@@ -168,7 +178,8 @@ export async function autoExport(slug, records, now = new Date()) {
|
|
|
168
178
|
const last = JSON.parse(await readFile(statusPath(slug), 'utf8').catch(() => 'null'));
|
|
169
179
|
if (last?.error && !last.reported) {
|
|
170
180
|
finding = `the export to Langfuse failed (${last.at}) — ${last.error}; \`nina langfuse status\` says where it stands`;
|
|
171
|
-
|
|
181
|
+
// Said on every run until the prompt hook hands it to the model, and not after (`handsToModel`).
|
|
182
|
+
if (handsToModel()) await writeFile(statusPath(slug), JSON.stringify({ ...last, reported: true }));
|
|
172
183
|
}
|
|
173
184
|
const sent = await sentOf(slug);
|
|
174
185
|
if (typeof sent === 'string') return finding ?? sent;
|
|
@@ -306,7 +317,7 @@ async function exportProject({ slug, name, file, target, dry, now, since = '', q
|
|
|
306
317
|
let bytes = 0;
|
|
307
318
|
for (const record of todo.spans) {
|
|
308
319
|
if (error) break;
|
|
309
|
-
const run = content ? await readRun(runFile(transcriptsOf(slug), record)) : null;
|
|
320
|
+
const run = content ? await readRun(runFile(transcriptsOf(slug), record), record.round ?? 1) : null;
|
|
310
321
|
if (content && !run) bare += 1;
|
|
311
322
|
let built = spansOf(record, name, run);
|
|
312
323
|
let size = JSON.stringify(built).length;
|
package/src/commands/gate.mjs
CHANGED
|
@@ -15,18 +15,22 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { spawnSync } from 'node:child_process';
|
|
18
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
19
19
|
import { tmpdir } from 'node:os';
|
|
20
20
|
import { join, resolve } from 'node:path';
|
|
21
21
|
import { HARNESS, legacyHint, slugFor } from '../paths.mjs';
|
|
22
22
|
import { GATE, hookCommand, missingWiring, shippedScripts } from '../wiring.mjs';
|
|
23
|
-
import { loadProject, projectGateDir, readLedger, runGate } from '../gate.mjs';
|
|
23
|
+
import { LEDGER_TAIL, ledgerPath, loadProject, projectGateDir, readLedger, runGate } from '../gate.mjs';
|
|
24
|
+
import { storedRecords } from './snapshot.mjs';
|
|
24
25
|
import { layerRootFor } from './compose.mjs';
|
|
26
|
+
import { handsToModel } from '../detectors.mjs';
|
|
25
27
|
|
|
26
28
|
/**
|
|
27
|
-
* The gate's failures
|
|
28
|
-
* reported
|
|
29
|
-
* detectors exist to remove, and the gate's own log keeps
|
|
29
|
+
* The gate's failures the model has not been told about yet, as one sentence, or null. Each failure is
|
|
30
|
+
* reported until the prompt hook hands it over, and not after: a detector that repeats one transient
|
|
31
|
+
* error on every turn for a day is the noise the detectors exist to remove, and the gate's own log keeps
|
|
32
|
+
* the history. Spent by the first run that saw it, it was usually spent by the Stop hook, whose line the
|
|
33
|
+
* model never reads, or by a check run by hand.
|
|
30
34
|
*
|
|
31
35
|
* @param {string} target - The project directory.
|
|
32
36
|
* @returns {string|null}
|
|
@@ -50,7 +54,7 @@ function newErrors(target) {
|
|
|
50
54
|
if (fresh.length === 0) return null;
|
|
51
55
|
const last = fresh.at(-1);
|
|
52
56
|
try {
|
|
53
|
-
writeFileSync(seenFile, `${last.at}\n`);
|
|
57
|
+
if (handsToModel()) writeFileSync(seenFile, `${last.at}\n`);
|
|
54
58
|
} catch {
|
|
55
59
|
// Unwritable: it is said again next time, which is better than never.
|
|
56
60
|
}
|
|
@@ -60,6 +64,12 @@ function newErrors(target) {
|
|
|
60
64
|
);
|
|
61
65
|
}
|
|
62
66
|
|
|
67
|
+
/**
|
|
68
|
+
* When this check first ran in the project, kept beside its ledgers: what the pipeline did before then
|
|
69
|
+
* was never the gate's to see, so the live check asks only about what came after.
|
|
70
|
+
*/
|
|
71
|
+
const SINCE = 'checked-since';
|
|
72
|
+
|
|
63
73
|
/** Whether the gate can write its ledger here. It lets every call through when it cannot. */
|
|
64
74
|
function writable(target) {
|
|
65
75
|
const dir = projectGateDir(target);
|
|
@@ -68,12 +78,70 @@ function writable(target) {
|
|
|
68
78
|
const probe = join(dir, `.selftest-${process.pid}`);
|
|
69
79
|
writeFileSync(probe, '');
|
|
70
80
|
rmSync(probe);
|
|
81
|
+
if (!existsSync(join(dir, SINCE))) writeFileSync(join(dir, SINCE), `${new Date().toISOString()}\n`);
|
|
71
82
|
return null;
|
|
72
83
|
} catch (error) {
|
|
73
84
|
return `the gate cannot write its ledger in ${dir} — ${error.message}. It lets every call through when it cannot, so no cap is held`;
|
|
74
85
|
}
|
|
75
86
|
}
|
|
76
87
|
|
|
88
|
+
/** How long after a report the gate is asked whether it saw it: the two are written moments apart. */
|
|
89
|
+
const SETTLE_MS = 120_000;
|
|
90
|
+
|
|
91
|
+
/** Fewer reports than this in a session, and a miss is not yet a pattern. */
|
|
92
|
+
const LIVE_SAMPLE = 3;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Whether the gate saw what the pipeline actually did — what the dry run cannot answer, since it feeds the
|
|
96
|
+
* gate events written here, in the shape Claude Code sent when they were written. The gate reads fields
|
|
97
|
+
* of what Claude Code sends each hook, and the transcripts it writes have changed shape twice under this
|
|
98
|
+
* harness; were the hooks' payloads to change, the gate would record nothing and let everything through,
|
|
99
|
+
* and the dry run would still pass. So the reports of the last session with enough of them — each round
|
|
100
|
+
* whose first line declares a verdict, as the snapshot read it from the transcripts — are set against the
|
|
101
|
+
* verdicts that session's ledger holds. The gate recording fewer than half of them is a finding.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} target - The project directory.
|
|
104
|
+
* @param {{graph: {stages: Set<string>}, tokens: Map<string, Set<string>>}} project - Its composed graph.
|
|
105
|
+
* @returns {string|null}
|
|
106
|
+
*/
|
|
107
|
+
function liveness(target, project) {
|
|
108
|
+
const dir = projectGateDir(target);
|
|
109
|
+
let since;
|
|
110
|
+
try {
|
|
111
|
+
since = readFileSync(join(dir, SINCE), 'utf8').trim();
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
const reports = (storedRecords(target) ?? []).filter(
|
|
117
|
+
(r) =>
|
|
118
|
+
r.verdict_source === 'handback' &&
|
|
119
|
+
r.session &&
|
|
120
|
+
project.graph.stages.has(r.role) &&
|
|
121
|
+
project.tokens.get(r.role)?.has(r.verdict) &&
|
|
122
|
+
String(r.result_ts) >= since &&
|
|
123
|
+
now - Date.parse(r.result_ts) > SETTLE_MS,
|
|
124
|
+
);
|
|
125
|
+
const bySession = new Map();
|
|
126
|
+
for (const r of reports) bySession.set(r.session, [...(bySession.get(r.session) ?? []), r]);
|
|
127
|
+
const last = [...bySession.values()]
|
|
128
|
+
.filter((rs) => rs.length >= LIVE_SAMPLE)
|
|
129
|
+
.sort((a, b) => String(a.map((r) => r.result_ts).sort().at(-1)).localeCompare(String(b.map((r) => r.result_ts).sort().at(-1))))
|
|
130
|
+
.at(-1);
|
|
131
|
+
if (!last) return null;
|
|
132
|
+
const path = ledgerPath(target, last[0].session);
|
|
133
|
+
const entries = readLedger(path);
|
|
134
|
+
// A ledger too large to read whole is read from its tail; what came before the tail is not asked about.
|
|
135
|
+
const from = entries.length > 0 && statSync(path).size > LEDGER_TAIL ? String(entries[0].at) : '';
|
|
136
|
+
const shown = last.filter((r) => String(r.result_ts) >= from).length;
|
|
137
|
+
const recorded = entries.filter((e) => e.k === 'verdict').length;
|
|
138
|
+
if (shown < LIVE_SAMPLE || recorded * 2 >= shown) return null;
|
|
139
|
+
return (
|
|
140
|
+
`in session ${last[0].session.slice(0, 8)}, the transcripts show ${shown} report(s) with a verdict line and the gate recorded ${recorded}: ` +
|
|
141
|
+
`Claude Code may have changed what it sends to the hooks, and a loop the gate does not see is not capped (${path})`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
77
145
|
/**
|
|
78
146
|
* Runs the gate the way Claude Code runs it — the command in this project's own settings, through
|
|
79
147
|
* `sh` and whatever `node` is on the PATH — over a whole loop in the shapes Claude Code sends: each
|
|
@@ -135,6 +203,15 @@ function dryRun(target) {
|
|
|
135
203
|
// Reported by the wiring check; the script is run directly instead.
|
|
136
204
|
}
|
|
137
205
|
const command = hookCommand(settings, 'PreToolUse', GATE) ?? `node "${join(target, GATE)}"`;
|
|
206
|
+
// A hook that runs past its timeout is killed before `finally` can remove its directory; one left by a
|
|
207
|
+
// run an hour gone is such a one, and goes now.
|
|
208
|
+
for (const left of readdirSync(tmpdir()).filter((f) => f.startsWith('nina-gate-'))) {
|
|
209
|
+
try {
|
|
210
|
+
if (Date.now() - statSync(join(tmpdir(), left)).mtimeMs > 3_600_000) rmSync(join(tmpdir(), left), { recursive: true, force: true });
|
|
211
|
+
} catch {
|
|
212
|
+
// Another run's, going as this one looks.
|
|
213
|
+
}
|
|
214
|
+
}
|
|
138
215
|
const data = mkdtempSync(join(tmpdir(), 'nina-gate-'));
|
|
139
216
|
try {
|
|
140
217
|
const run = spawnSync('sh', ['-c', command], {
|
|
@@ -209,7 +286,8 @@ export async function gate(argv, ctx) {
|
|
|
209
286
|
const problems = [];
|
|
210
287
|
const composed = existsSync(join(target, GATE));
|
|
211
288
|
if (!composed) problems.push(`${GATE} is not composed — run \`nina compose\``);
|
|
212
|
-
|
|
289
|
+
const unwired = await missingWiring(target, new Set([GATE]));
|
|
290
|
+
problems.push(...unwired);
|
|
213
291
|
const cannotWrite = writable(target);
|
|
214
292
|
if (cannotWrite) problems.push(cannotWrite);
|
|
215
293
|
const failing = newErrors(target);
|
|
@@ -218,9 +296,14 @@ export async function gate(argv, ctx) {
|
|
|
218
296
|
const dry = dryRun(target);
|
|
219
297
|
if (dry) problems.push(dry);
|
|
220
298
|
}
|
|
299
|
+
// A gate that is not wired sees nothing, and says so above; asked whether it saw the pipeline, it would
|
|
300
|
+
// say the same thing twice.
|
|
301
|
+
const project = composed && unwired.length === 0 ? loadProject(target) : null;
|
|
302
|
+
const blind = project ? liveness(target, project) : null;
|
|
303
|
+
if (blind) problems.push(blind);
|
|
221
304
|
|
|
222
305
|
if (problems.length === 0) {
|
|
223
|
-
console.log(` wired, writable, no new failure,
|
|
306
|
+
console.log(` wired, writable, no new failure, a dry run through the hook command sent the round past a cap to the owner, and it recorded the reports the transcripts show — ledgers in ${projectGateDir(target)}`);
|
|
224
307
|
console.log('gate: current');
|
|
225
308
|
return 0;
|
|
226
309
|
}
|
package/src/commands/init.mjs
CHANGED
|
@@ -16,9 +16,9 @@ import { createInterface } from 'node:readline/promises';
|
|
|
16
16
|
import { HARNESS } from '../paths.mjs';
|
|
17
17
|
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
18
18
|
import { applyWiring, missingWiring, shippedScripts } from '../wiring.mjs';
|
|
19
|
-
import { existsSync,
|
|
19
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
20
20
|
import { join, resolve } from 'node:path';
|
|
21
|
-
import { REQUIRES, SLOT, byVersion, composeProject, composedPaths, defaultedSlots, layerRootFor, walk } from './compose.mjs';
|
|
21
|
+
import { REQUIRES, SLOT, byVersion, composeProject, composedPaths, defaultedSlots, layerRootFor, projectOwned, walk } from './compose.mjs';
|
|
22
22
|
import { owedDocuments } from './check.mjs';
|
|
23
23
|
import { defaultVocabulary } from '../vocabulary.mjs';
|
|
24
24
|
import { PINK, useColor } from '../banner.mjs';
|
|
@@ -269,6 +269,20 @@ export async function init(argv, ctx) {
|
|
|
269
269
|
console.error(` ${target} already has a .nina/profile.json — pass --force to start over.\n`);
|
|
270
270
|
return 1;
|
|
271
271
|
}
|
|
272
|
+
// Started over, a project keeps what it had declared and asks again only about its surfaces: `init`
|
|
273
|
+
// itself says to re-run with --force to add one, and doing so used to write a profile from nothing —
|
|
274
|
+
// every vocabulary value and integration gone, and the pin moved to the newest release past every
|
|
275
|
+
// check `upgrade` makes.
|
|
276
|
+
let previous = null;
|
|
277
|
+
try {
|
|
278
|
+
previous = JSON.parse(readFileSync(join(harness, 'profile.json'), 'utf8'));
|
|
279
|
+
} catch {
|
|
280
|
+
// None, or unreadable: there is nothing to keep.
|
|
281
|
+
}
|
|
282
|
+
if (previous?.core && arg('--core') && arg('--core') !== previous.core) {
|
|
283
|
+
console.error(` ${target} pins ${previous.core}. A pin moves with \`nina upgrade --to ${arg('--core')}\`, which says what the move costs and rolls it back when it fails.\n`);
|
|
284
|
+
return 1;
|
|
285
|
+
}
|
|
272
286
|
|
|
273
287
|
const tree = await walk(target);
|
|
274
288
|
// Kept as the matched entries, not just their names: the summary prints WHY each surface was
|
|
@@ -284,7 +298,7 @@ export async function init(argv, ctx) {
|
|
|
284
298
|
.filter((e) => e.isDirectory())
|
|
285
299
|
.map((e) => e.name)
|
|
286
300
|
.sort(byVersion);
|
|
287
|
-
const core = arg('--core') ?? releases.at(-1) ?? 'dev';
|
|
301
|
+
const core = arg('--core') ?? previous?.core ?? releases.at(-1) ?? 'dev';
|
|
288
302
|
const resolved = layerRootFor(ctx.root, core);
|
|
289
303
|
if (resolved.error) {
|
|
290
304
|
console.error(` ${resolved.error}\n`);
|
|
@@ -357,12 +371,19 @@ export async function init(argv, ctx) {
|
|
|
357
371
|
const defaults = defaultVocabulary(resolved.dir);
|
|
358
372
|
const defaulted = [...vocabulary].filter((name) => name in defaults).sort();
|
|
359
373
|
for (const name of defaulted) vocabulary.delete(name);
|
|
374
|
+
// What the profile being replaced had filled in, for a name these layers still use or one the release
|
|
375
|
+
// defaults and the project had chosen its own value for.
|
|
376
|
+
const kept = Object.entries(previous?.vocabulary ?? {}).filter(([name, value]) => value !== null && value !== undefined && (vocabulary.has(name) || name in defaults));
|
|
377
|
+
for (const [name] of kept) vocabulary.delete(name);
|
|
378
|
+
const keptIntegrations = surfaces.includes('integrations') && Array.isArray(previous?.integrations) ? previous.integrations : [];
|
|
360
379
|
|
|
361
380
|
const profile = {
|
|
362
381
|
core,
|
|
363
382
|
surfaces,
|
|
364
|
-
...(surfaces.includes('integrations') ? { integrations:
|
|
365
|
-
vocabulary: Object.fromEntries([...vocabulary].
|
|
383
|
+
...(surfaces.includes('integrations') ? { integrations: keptIntegrations } : {}),
|
|
384
|
+
vocabulary: Object.fromEntries([...[...vocabulary].map((name) => [name, null]), ...kept].sort(([a], [b]) => a.localeCompare(b))),
|
|
385
|
+
// Anything else the profile said is the project's, and stays.
|
|
386
|
+
...Object.fromEntries(Object.entries(previous ?? {}).filter(([key]) => !['core', 'surfaces', 'integrations', 'vocabulary'].includes(key))),
|
|
366
387
|
};
|
|
367
388
|
|
|
368
389
|
await mkdir(join(harness, 'project', 'tree'), { recursive: true });
|
|
@@ -379,16 +400,7 @@ export async function init(argv, ctx) {
|
|
|
379
400
|
// Files of the project's own where the harness composes one: a CLAUDE.md or an agent spec written by
|
|
380
401
|
// hand before the harness arrived — the adoption case — or a symlink, which compose would write
|
|
381
402
|
// through. Composing over them would destroy them, so they hold the compose back and are named.
|
|
382
|
-
const inTheWay =
|
|
383
|
-
for (const p of await composedPaths(resolved.dir, surfaces)) {
|
|
384
|
-
let entry = null;
|
|
385
|
-
try {
|
|
386
|
-
entry = lstatSync(join(target, p));
|
|
387
|
-
} catch {
|
|
388
|
-
continue;
|
|
389
|
-
}
|
|
390
|
-
if (entry.isSymbolicLink() || !entry.isFile() || !readFileSync(join(target, p), 'utf8').includes('nina:generated')) inTheWay.push(p);
|
|
391
|
-
}
|
|
403
|
+
const inTheWay = projectOwned(target, await composedPaths(resolved.dir, surfaces));
|
|
392
404
|
|
|
393
405
|
const cut = (s) => (s.length > 96 ? `${s.slice(0, 96)}…` : s);
|
|
394
406
|
const owed = await owedDocuments(resolved.dir, surfaces);
|
|
@@ -532,6 +544,13 @@ export async function init(argv, ctx) {
|
|
|
532
544
|
}
|
|
533
545
|
|
|
534
546
|
console.log(` initialised ${target}\n`);
|
|
547
|
+
if (previous) {
|
|
548
|
+
console.log(
|
|
549
|
+
` kept from the profile it replaced: the pin ${core}, ${kept.length} vocabulary value(s)` +
|
|
550
|
+
(surfaces.includes('integrations') ? `, ${keptIntegrations.length} integration(s)` : '') +
|
|
551
|
+
'\n',
|
|
552
|
+
);
|
|
553
|
+
}
|
|
535
554
|
if (!asked && !canAsk) {
|
|
536
555
|
const greenfield = tree.filter((f) => !f.startsWith('.git') && !f.startsWith(HARNESS)).length === 0;
|
|
537
556
|
if (greenfield) {
|
package/src/commands/learn.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import { existsSync, readdirSync } from 'node:fs';
|
|
|
26
26
|
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
|
27
27
|
import { join, relative, resolve } from 'node:path';
|
|
28
28
|
import { HARNESS, legacyHint, slugFor, snapshotsDir } from '../paths.mjs';
|
|
29
|
-
import { isLoopBack } from '../transcripts.mjs';
|
|
29
|
+
import { isLoopBack, runOf } from '../transcripts.mjs';
|
|
30
30
|
import { REQUIRES, byVersion, layerRootFor } from './compose.mjs';
|
|
31
31
|
import { GRADUATION_AT, frontmatter, graduationTarget, list, pillFiles, roleGates } from './pills.mjs';
|
|
32
32
|
import { decodeProjectDir } from './stats.mjs';
|
|
@@ -35,6 +35,7 @@ import { loadProject, projectGateDir, readLedger, replay } from '../gate.mjs';
|
|
|
35
35
|
import { GATE, shippedScripts } from '../wiring.mjs';
|
|
36
36
|
import { deepLearn, transcriptsOf } from '../deep.mjs';
|
|
37
37
|
import { autoExport } from './export.mjs';
|
|
38
|
+
import { beforeModel } from '../detectors.mjs';
|
|
38
39
|
|
|
39
40
|
/** Loop-backs from one role, since its newest lesson, that make a lesson overdue. */
|
|
40
41
|
export const CAPTURE_AT = 3;
|
|
@@ -172,13 +173,17 @@ export function overdue(records, known, now = new Date(), days = CAPTURE_DAYS) {
|
|
|
172
173
|
*/
|
|
173
174
|
export function applied(records, known) {
|
|
174
175
|
const taught = new Set(known.filter((l) => !l.retired).flatMap((l) => l.roles));
|
|
175
|
-
// Only records captured after the field existed can answer; the rest are unknown, not "no".
|
|
176
|
-
|
|
176
|
+
// Only records captured after the field existed can answer; the rest are unknown, not "no". Asked of
|
|
177
|
+
// runs, not rounds: a lesson a run read in its first round is in front of it in every round after.
|
|
178
|
+
const runs = new Map();
|
|
179
|
+
for (const r of records.filter((r) => taught.has(r.role) && 'lessons_read' in r)) runs.set(runOf(r), [...(runs.get(runOf(r)) ?? []), r]);
|
|
180
|
+
const measured = [...runs.values()];
|
|
181
|
+
const read = (rounds) => rounds.some((r) => r.lessons_read > 0);
|
|
177
182
|
return {
|
|
178
183
|
measured: measured.length,
|
|
179
|
-
read: measured.filter(
|
|
180
|
-
listed: measured.filter((
|
|
181
|
-
from: measured.map((
|
|
184
|
+
read: measured.filter(read).length,
|
|
185
|
+
listed: measured.filter((rounds) => !read(rounds) && rounds.some((r) => r.lessons_listed)).length,
|
|
186
|
+
from: measured.map((rounds) => day(rounds[0].ts)).sort()[0] ?? null,
|
|
182
187
|
};
|
|
183
188
|
}
|
|
184
189
|
|
|
@@ -598,7 +603,11 @@ export async function learn(argv, ctx) {
|
|
|
598
603
|
const stuck = owed
|
|
599
604
|
.filter((l) => l.roles.length === 0)
|
|
600
605
|
.map((lesson) => ({ lesson, why: 'names no role in applies_to, so no layer can take it', fix: 'fix its frontmatter — `nina pills` says what is wrong' }));
|
|
601
|
-
|
|
606
|
+
// Sent from the Stop hook, a request was news only the person heard: its one line said the model would
|
|
607
|
+
// be told, and by the next message the lesson was no longer owed, so nothing told it. It is sent by the
|
|
608
|
+
// run that hands its findings to the model, or by hand; the Stop hook says it is about to go.
|
|
609
|
+
const due = beforeModel() ? owed.filter((l) => l.roles.length > 0) : [];
|
|
610
|
+
for (const lesson of beforeModel() ? [] : owed.filter((l) => l.roles.length > 0)) {
|
|
602
611
|
// A detector that throws is reported as one that could not run, every turn, and takes the
|
|
603
612
|
// rest of its findings with it — so a request that cannot be written is a finding instead.
|
|
604
613
|
try {
|
|
@@ -607,7 +616,7 @@ export async function learn(argv, ctx) {
|
|
|
607
616
|
stuck.push({ lesson, why: `could not be written to ${HARNESS}/requests/ — ${error.message}`, fix: `make it writable, or send it by hand: \`nina learn --graduate ${lesson.rel}\`` });
|
|
608
617
|
}
|
|
609
618
|
}
|
|
610
|
-
if (late.length === 0 && sent.length === 0 && stuck.length === 0 && !exported) {
|
|
619
|
+
if (late.length === 0 && sent.length === 0 && due.length === 0 && stuck.length === 0 && !exported) {
|
|
611
620
|
console.log('learn: current');
|
|
612
621
|
return 0;
|
|
613
622
|
}
|
|
@@ -624,6 +633,9 @@ export async function learn(argv, ctx) {
|
|
|
624
633
|
console.log(` ${s.lesson.rel} has recurred ${s.lesson.occurrences} times — sent to the harness as ${HARNESS}/requests/${s.file}, proposing a rule in ${s.layer}`);
|
|
625
634
|
console.log(' → commit it with the pill; nothing else is owed here. `nina upgrade` to the release that answers it closes it and retires the pill');
|
|
626
635
|
}
|
|
636
|
+
for (const lesson of due) {
|
|
637
|
+
console.log(` ${lesson.rel} has recurred ${lesson.occurrences} times — it goes to the harness as a request before your next message`);
|
|
638
|
+
}
|
|
627
639
|
for (const s of stuck) {
|
|
628
640
|
console.log(` ${s.lesson.rel} has recurred ${s.lesson.occurrences} times and ${s.why}`);
|
|
629
641
|
console.log(` → ${s.fix}`);
|
|
@@ -633,6 +645,7 @@ export async function learn(argv, ctx) {
|
|
|
633
645
|
`learn: ${[
|
|
634
646
|
late.length && `${late.length} role(s) keep being sent back with nothing written down`,
|
|
635
647
|
sent.length && `${sent.length} lesson(s) sent to the harness`,
|
|
648
|
+
due.length && `${due.length} lesson(s) to send to the harness`,
|
|
636
649
|
stuck.length && `${stuck.length} lesson(s) cannot be sent`,
|
|
637
650
|
exported && 'the Langfuse export needs a look',
|
|
638
651
|
]
|
|
@@ -652,7 +665,7 @@ export async function learn(argv, ctx) {
|
|
|
652
665
|
console.log(`\n learning — core ${profile.core}\n`);
|
|
653
666
|
console.log(
|
|
654
667
|
records.length
|
|
655
|
-
? ` observe ${records.length}
|
|
668
|
+
? ` observe ${new Set(records.map(runOf)).size} run(s) in ${records.length} round(s) recorded, ${span}`
|
|
656
669
|
: ' observe ✗ nothing recorded — no transcripts found for this project, so no link below can be measured',
|
|
657
670
|
);
|
|
658
671
|
if (late.length === 0) console.log(` capture ✓ no role has looped back ${CAPTURE_AT}+ times without a lesson in the last ${days} days`);
|
|
@@ -10,14 +10,15 @@
|
|
|
10
10
|
* back and draws it.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { existsSync, readFileSync, readdirSync
|
|
13
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
14
14
|
import { basename, join, resolve } from 'node:path';
|
|
15
15
|
import { dim, pink } from '../look.mjs';
|
|
16
|
-
import { HARNESS
|
|
16
|
+
import { HARNESS } from '../paths.mjs';
|
|
17
17
|
import { TERMINALS, parseGraph } from '../graph.mjs';
|
|
18
18
|
import { required } from '../tools.mjs';
|
|
19
|
-
import { isLoopBack } from '../transcripts.mjs';
|
|
19
|
+
import { isLoopBack, runOf } from '../transcripts.mjs';
|
|
20
20
|
import { frontmatter } from './pills.mjs';
|
|
21
|
+
import { storedRecords } from './snapshot.mjs';
|
|
21
22
|
|
|
22
23
|
/** A stage line under "## Stages": "- `name` — what it does". */
|
|
23
24
|
const STAGE_LINE = /^- `([a-z][a-z-]*)` — (.*)$/;
|
|
@@ -133,19 +134,24 @@ export function pickShape(shapes, query) {
|
|
|
133
134
|
}
|
|
134
135
|
|
|
135
136
|
/**
|
|
136
|
-
* What each stage did since a date, from the measurement store: its runs, the verdicts that
|
|
137
|
-
* and how many of those sent work back. A run a hook denied never ran, and is not counted.
|
|
137
|
+
* What each stage did since a date, from the measurement store: its runs, their rounds, the verdicts that
|
|
138
|
+
* could be read, and how many of those sent work back. A run a hook denied never ran, and is not counted.
|
|
138
139
|
*
|
|
139
|
-
* @param {object[]} records - The project's records.
|
|
140
|
+
* @param {object[]} records - The project's records, one per round.
|
|
140
141
|
* @param {string} since - An ISO date.
|
|
141
|
-
* @returns {Map<string, {runs: number, read: number, back: number}>}
|
|
142
|
+
* @returns {Map<string, {runs: number, rounds: number, read: number, back: number}>}
|
|
142
143
|
*/
|
|
143
144
|
export function historyOf(records, since) {
|
|
144
145
|
const byRole = new Map();
|
|
146
|
+
const seen = new Map();
|
|
145
147
|
for (const r of records) {
|
|
146
148
|
if (!r.role || r.status === 'denied' || String(r.ts ?? '') < since) continue;
|
|
147
|
-
const s = byRole.get(r.role) ?? { runs: 0, read: 0, back: 0 };
|
|
148
|
-
|
|
149
|
+
const s = byRole.get(r.role) ?? { runs: 0, rounds: 0, read: 0, back: 0 };
|
|
150
|
+
const runs = seen.get(r.role) ?? new Set();
|
|
151
|
+
runs.add(runOf(r));
|
|
152
|
+
seen.set(r.role, runs);
|
|
153
|
+
s.runs = runs.size;
|
|
154
|
+
s.rounds += 1;
|
|
149
155
|
if (r.verdict && r.verdict !== 'UNCLEAR' && r.verdict !== 'NONE') {
|
|
150
156
|
s.read += 1;
|
|
151
157
|
if (isLoopBack(r.verdict)) s.back += 1;
|
|
@@ -155,37 +161,6 @@ export function historyOf(records, since) {
|
|
|
155
161
|
return byRole;
|
|
156
162
|
}
|
|
157
163
|
|
|
158
|
-
/**
|
|
159
|
-
* The project's records in the measurement store, under the name Claude Code gives its directory — as
|
|
160
|
-
* given, or with its links resolved, since either may be the one the sessions ran in.
|
|
161
|
-
*
|
|
162
|
-
* @param {string} dir - The project.
|
|
163
|
-
* @returns {object[]|null} Null when the store has nothing for it.
|
|
164
|
-
*/
|
|
165
|
-
function storedRuns(dir) {
|
|
166
|
-
const names = new Set([slugFor(dir)]);
|
|
167
|
-
try {
|
|
168
|
-
names.add(slugFor(realpathSync(dir)));
|
|
169
|
-
} catch {
|
|
170
|
-
// A directory that cannot be resolved is looked up as given.
|
|
171
|
-
}
|
|
172
|
-
for (const name of names) {
|
|
173
|
-
const file = join(snapshotsDir(), `${name}.jsonl`);
|
|
174
|
-
if (!existsSync(file)) continue;
|
|
175
|
-
return readFileSync(file, 'utf8')
|
|
176
|
-
.split('\n')
|
|
177
|
-
.filter(Boolean)
|
|
178
|
-
.flatMap((l) => {
|
|
179
|
-
try {
|
|
180
|
-
return [JSON.parse(l)];
|
|
181
|
-
} catch {
|
|
182
|
-
return [];
|
|
183
|
-
}
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
return null;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
164
|
/**
|
|
190
165
|
* Runs the command.
|
|
191
166
|
*
|
|
@@ -309,7 +284,7 @@ export async function pipeline(argv) {
|
|
|
309
284
|
specs.set(file.replace(/\.md$/, ''), readFileSync(join(agentsDir, file), 'utf8'));
|
|
310
285
|
}
|
|
311
286
|
const since = arg('--since') || new Date(Date.now() - HISTORY_DAYS * 86_400_000).toISOString().slice(0, 10);
|
|
312
|
-
const runs =
|
|
287
|
+
const runs = storedRecords(dir);
|
|
313
288
|
const history = runs ? historyOf(runs, since) : null;
|
|
314
289
|
const stages = [...graph.stages].filter((s) => covered.has(s));
|
|
315
290
|
const col = Math.max(...stages.map((s) => s.length), 10) + 2;
|
|
@@ -323,7 +298,7 @@ export async function pipeline(argv) {
|
|
|
323
298
|
const h = history.get(stage);
|
|
324
299
|
const said = !h
|
|
325
300
|
? 'no run'
|
|
326
|
-
: `${h.runs} run(s)${h.read === 0 ? ', no verdict read' : `, sent back ${h.back} of ${h.read} (${Math.round((100 * h.back) / h.read)}%)`}`;
|
|
301
|
+
: `${h.runs} run(s)${h.rounds > h.runs ? ` in ${h.rounds} rounds` : ''}${h.read === 0 ? ', no verdict read' : `, sent back ${h.back} of ${h.read} (${Math.round((100 * h.back) / h.read)}%)`}`;
|
|
327
302
|
console.log(` ${' '.repeat(col)}${dim('ran ')}${said}`);
|
|
328
303
|
}
|
|
329
304
|
const skills = [...required(spec).skills].sort();
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
12
|
-
import { snapshotsDir } from '../paths.mjs';
|
|
13
|
-
import { createReadStream } from 'node:fs';
|
|
12
|
+
import { slugFor, snapshotsDir } from '../paths.mjs';
|
|
13
|
+
import { createReadStream, existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
14
14
|
import { createInterface } from 'node:readline';
|
|
15
15
|
import { join, resolve } from 'node:path';
|
|
16
16
|
import { listProjects, scanProject } from '../transcripts.mjs';
|
|
@@ -86,6 +86,37 @@ async function writeAtomic(file, records) {
|
|
|
86
86
|
await rename(tmp, file);
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* A project's records in the measurement store, under the name Claude Code gives its directory — as
|
|
91
|
+
* given, or with its links resolved, since either may be the one the sessions ran in.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} dir - The project.
|
|
94
|
+
* @returns {object[]|null} Null when the store has nothing for it.
|
|
95
|
+
*/
|
|
96
|
+
export function storedRecords(dir) {
|
|
97
|
+
const names = new Set([slugFor(dir)]);
|
|
98
|
+
try {
|
|
99
|
+
names.add(slugFor(realpathSync(dir)));
|
|
100
|
+
} catch {
|
|
101
|
+
// A directory that cannot be resolved is looked up as given.
|
|
102
|
+
}
|
|
103
|
+
for (const name of names) {
|
|
104
|
+
const file = join(snapshotsDir(), `${name}.jsonl`);
|
|
105
|
+
if (!existsSync(file)) continue;
|
|
106
|
+
return readFileSync(file, 'utf8')
|
|
107
|
+
.split('\n')
|
|
108
|
+
.filter(Boolean)
|
|
109
|
+
.flatMap((l) => {
|
|
110
|
+
try {
|
|
111
|
+
return [JSON.parse(l)];
|
|
112
|
+
} catch {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
89
120
|
/**
|
|
90
121
|
* Runs the snapshot.
|
|
91
122
|
*
|
|
@@ -149,7 +180,7 @@ export async function snapshot(argv, ctx) {
|
|
|
149
180
|
if (!quiet) {
|
|
150
181
|
const span = `${String(records[0].ts).slice(0, 10)} → ${String(records.at(-1).ts).slice(0, 10)}`;
|
|
151
182
|
console.log(
|
|
152
|
-
` ${project.slug.padEnd(48)} ${String(records.length).padStart(5)}
|
|
183
|
+
` ${project.slug.padEnd(48)} ${String(records.length).padStart(5)} rounds ` +
|
|
153
184
|
`${String(fresh).padStart(5)} new ${span}`,
|
|
154
185
|
);
|
|
155
186
|
}
|
|
@@ -157,7 +188,7 @@ export async function snapshot(argv, ctx) {
|
|
|
157
188
|
|
|
158
189
|
|
|
159
190
|
if (!quiet) {
|
|
160
|
-
console.log(`\n ${totalNew} new
|
|
191
|
+
console.log(`\n ${totalNew} new round${totalNew === 1 ? '' : 's'} captured → ${outDir}`);
|
|
161
192
|
}
|
|
162
193
|
return 0;
|
|
163
194
|
}
|