@xhulz/nina 0.31.0 → 0.33.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 +7 -5
- package/bin/nina.mjs +4 -0
- package/package.json +2 -1
- package/releases/0.32.0/core/GAPS.md +125 -0
- package/releases/0.32.0/core/answered.json +29 -0
- package/releases/0.32.0/core/defaults/tree/.claude/agents/architect.md +1 -0
- package/releases/0.32.0/core/defaults/tree/.claude/agents/dba.md +3 -0
- package/releases/0.32.0/core/defaults/tree/.claude/agents/devops.md +1 -0
- package/releases/0.32.0/core/defaults/tree/.claude/agents/implementer.md +1 -0
- package/releases/0.32.0/core/defaults/tree/.claude/agents/integration-tester.md +3 -0
- package/releases/0.32.0/core/defaults/tree/.claude/agents/qa.md +2 -0
- package/releases/0.32.0/core/defaults/tree/.claude/agents/reviewer.md +3 -0
- package/releases/0.32.0/core/defaults/tree/.claude/agents/secops.md +3 -0
- package/releases/0.32.0/core/defaults/tree/.claude/agents/solidity-auditor.md +3 -0
- package/releases/0.32.0/core/defaults/tree/.claude/agents/solidity-dev.md +3 -0
- package/releases/0.32.0/core/tree/.claude/agents/architect.md +126 -0
- package/releases/0.32.0/core/tree/.claude/agents/dba.md +110 -0
- package/releases/0.32.0/core/tree/.claude/agents/devops.md +114 -0
- package/releases/0.32.0/core/tree/.claude/agents/implementer.md +155 -0
- package/releases/0.32.0/core/tree/.claude/agents/integration-tester.md +168 -0
- package/releases/0.32.0/core/tree/.claude/agents/planner.md +132 -0
- package/releases/0.32.0/core/tree/.claude/agents/qa.md +148 -0
- package/releases/0.32.0/core/tree/.claude/agents/reviewer.md +183 -0
- package/releases/0.32.0/core/tree/.claude/agents/secops.md +121 -0
- package/releases/0.32.0/core/tree/.claude/agents/solidity-auditor.md +126 -0
- package/releases/0.32.0/core/tree/.claude/agents/solidity-dev.md +123 -0
- package/releases/0.32.0/core/tree/.claude/agents-overview.md +64 -0
- package/releases/0.32.0/core/tree/.claude/frontend.md +10 -0
- package/releases/0.32.0/core/tree/.claude/graph.md +75 -0
- package/releases/0.32.0/core/tree/.claude/patterns.md +213 -0
- package/releases/0.32.0/core/tree/.claude/pills/README.md +146 -0
- package/releases/0.32.0/core/tree/.claude/pipeline.md +101 -0
- package/releases/0.32.0/core/tree/.claude/retrieval.md +103 -0
- package/releases/0.32.0/core/tree/.claude/router.md +195 -0
- package/releases/0.32.0/core/tree/.claude/templates/integration.md +45 -0
- package/releases/0.32.0/core/tree/CLAUDE.md +160 -0
- package/releases/0.32.0/core/tree/scripts/cost-watch.mjs +18 -0
- package/releases/0.32.0/core/tree/scripts/edit-guard.mjs +16 -0
- package/releases/0.32.0/core/tree/scripts/harness-check.mjs +71 -0
- package/releases/0.32.0/core/tree/scripts/loop-gate.mjs +19 -0
- package/releases/0.32.0/core/vocabulary.json +13 -0
- package/releases/0.32.0/surfaces/blockchain/tree/.claude/agents/architect.md +5 -0
- package/releases/0.32.0/surfaces/blockchain/tree/.claude/agents/devops.md +5 -0
- package/releases/0.32.0/surfaces/blockchain/tree/.claude/agents/reviewer.md +5 -0
- package/releases/0.32.0/surfaces/blockchain/tree/.claude/agents/solidity-auditor.md +18 -0
- package/releases/0.32.0/surfaces/blockchain/tree/.claude/agents/solidity-dev.md +16 -0
- package/releases/0.32.0/surfaces/blockchain/tree/.claude/agents-overview.md +6 -0
- package/releases/0.32.0/surfaces/blockchain/tree/.claude/graph.md +11 -0
- package/releases/0.32.0/surfaces/blockchain/tree/.claude/router.md +6 -0
- package/releases/0.32.0/surfaces/blockchain/tree/CLAUDE.md +12 -0
- package/releases/0.32.0/surfaces/db/tree/.claude/agents/architect.md +5 -0
- package/releases/0.32.0/surfaces/db/tree/.claude/agents/dba.md +50 -0
- package/releases/0.32.0/surfaces/db/tree/.claude/agents/devops.md +5 -0
- package/releases/0.32.0/surfaces/db/tree/.claude/agents/implementer.md +2 -0
- package/releases/0.32.0/surfaces/db/tree/.claude/agents/integration-tester.md +10 -0
- package/releases/0.32.0/surfaces/db/tree/.claude/agents/planner.md +2 -0
- package/releases/0.32.0/surfaces/db/tree/.claude/agents/reviewer.md +22 -0
- package/releases/0.32.0/surfaces/db/tree/.claude/agents/secops.md +2 -0
- package/releases/0.32.0/surfaces/db/tree/.claude/agents-overview.md +5 -0
- package/releases/0.32.0/surfaces/db/tree/.claude/graph.md +8 -0
- package/releases/0.32.0/surfaces/db/tree/.claude/patterns.md +19 -0
- package/releases/0.32.0/surfaces/db/tree/.claude/pipeline.md +13 -0
- package/releases/0.32.0/surfaces/db/tree/.claude/retrieval.md +5 -0
- package/releases/0.32.0/surfaces/db/tree/.claude/router.md +4 -0
- package/releases/0.32.0/surfaces/db/tree/CLAUDE.md +17 -0
- package/releases/0.32.0/surfaces/edge-cf/tree/.claude/agents/architect.md +14 -0
- package/releases/0.32.0/surfaces/edge-cf/tree/.claude/agents/dba.md +2 -0
- package/releases/0.32.0/surfaces/edge-cf/tree/.claude/agents/devops.md +32 -0
- package/releases/0.32.0/surfaces/edge-cf/tree/.claude/agents/implementer.md +14 -0
- package/releases/0.32.0/surfaces/edge-cf/tree/.claude/agents/integration-tester.md +11 -0
- package/releases/0.32.0/surfaces/edge-cf/tree/.claude/agents/qa.md +3 -0
- package/releases/0.32.0/surfaces/edge-cf/tree/.claude/agents/reviewer.md +8 -0
- package/releases/0.32.0/surfaces/edge-cf/tree/.claude/agents/secops.md +8 -0
- package/releases/0.32.0/surfaces/edge-cf/tree/.claude/patterns.md +17 -0
- package/releases/0.32.0/surfaces/edge-cf/tree/.claude/retrieval.md +8 -0
- package/releases/0.32.0/surfaces/edge-cf/tree/CLAUDE.md +27 -0
- package/releases/0.32.0/surfaces/frontend/tree/.claude/agents/architect.md +20 -0
- package/releases/0.32.0/surfaces/frontend/tree/.claude/agents/devops.md +28 -0
- package/releases/0.32.0/surfaces/frontend/tree/.claude/agents/implementer.md +25 -0
- package/releases/0.32.0/surfaces/frontend/tree/.claude/agents/qa.md +17 -0
- package/releases/0.32.0/surfaces/frontend/tree/.claude/agents/reviewer.md +56 -0
- package/releases/0.32.0/surfaces/frontend/tree/.claude/patterns.md +8 -0
- package/releases/0.32.0/surfaces/frontend/tree/.claude/retrieval.md +2 -0
- package/releases/0.32.0/surfaces/frontend/tree/.claude/router.md +14 -0
- package/releases/0.32.0/surfaces/frontend/tree/CLAUDE.md +5 -0
- package/releases/0.32.0/surfaces/integrations/tree/.claude/agents/architect.md +16 -0
- package/releases/0.32.0/surfaces/integrations/tree/.claude/agents/implementer.md +12 -0
- package/releases/0.32.0/surfaces/integrations/tree/.claude/agents/integration-tester.md +20 -0
- package/releases/0.32.0/surfaces/integrations/tree/.claude/agents/planner.md +2 -0
- package/releases/0.32.0/surfaces/integrations/tree/.claude/agents/reviewer.md +21 -0
- package/releases/0.32.0/surfaces/integrations/tree/.claude/agents/secops.md +2 -0
- package/releases/0.32.0/surfaces/integrations/tree/.claude/agents-overview.md +8 -0
- package/releases/0.32.0/surfaces/integrations/tree/.claude/graph.md +10 -0
- package/releases/0.32.0/surfaces/integrations/tree/.claude/patterns.md +38 -0
- package/releases/0.32.0/surfaces/integrations/tree/.claude/pipeline.md +16 -0
- package/releases/0.32.0/surfaces/integrations/tree/.claude/retrieval.md +16 -0
- package/releases/0.32.0/surfaces/integrations/tree/.claude/router.md +6 -0
- package/releases/0.32.0/surfaces/integrations/tree/CLAUDE.md +17 -0
- package/releases/0.32.0/surfaces/money/tree/.claude/agents/architect.md +12 -0
- package/releases/0.32.0/surfaces/money/tree/.claude/agents/dba.md +14 -0
- package/releases/0.32.0/surfaces/money/tree/.claude/agents/implementer.md +11 -0
- package/releases/0.32.0/surfaces/money/tree/.claude/agents/integration-tester.md +3 -0
- package/releases/0.32.0/surfaces/money/tree/.claude/agents/reviewer.md +23 -0
- package/releases/0.32.0/surfaces/money/tree/.claude/agents/secops.md +5 -0
- package/releases/0.32.0/surfaces/money/tree/.claude/agents/solidity-auditor.md +4 -0
- package/releases/0.32.0/surfaces/money/tree/.claude/agents/solidity-dev.md +4 -0
- package/releases/0.32.0/surfaces/money/tree/.claude/graph.md +2 -0
- package/releases/0.32.0/surfaces/money/tree/.claude/patterns.md +49 -0
- package/releases/0.32.0/surfaces/money/tree/.claude/pipeline.md +2 -0
- package/releases/0.32.0/surfaces/money/tree/.claude/retrieval.md +2 -0
- package/releases/0.32.0/surfaces/money/tree/CLAUDE.md +11 -0
- package/releases/0.32.0/surfaces/pii/tree/.claude/agents/dba.md +2 -0
- package/releases/0.32.0/surfaces/pii/tree/.claude/agents/devops.md +8 -0
- package/releases/0.32.0/surfaces/pii/tree/.claude/agents/reviewer.md +8 -0
- package/releases/0.32.0/surfaces/pii/tree/.claude/agents/secops.md +2 -0
- package/releases/0.32.0/surfaces/pii/tree/.claude/agents/solidity-auditor.md +5 -0
- package/releases/0.32.0/surfaces/pii/tree/CLAUDE.md +5 -0
- package/releases/0.32.0/surfaces/prisma/tree/.claude/agents/architect.md +2 -0
- package/releases/0.32.0/surfaces/prisma/tree/.claude/agents/dba.md +39 -0
- package/releases/0.32.0/surfaces/prisma/tree/.claude/agents/devops.md +11 -0
- package/releases/0.32.0/surfaces/prisma/tree/.claude/agents/implementer.md +2 -0
- package/releases/0.32.0/surfaces/prisma/tree/.claude/agents/integration-tester.md +2 -0
- package/releases/0.32.0/surfaces/prisma/tree/.claude/agents/reviewer.md +2 -0
- package/releases/0.32.0/surfaces/prisma/tree/.claude/patterns.md +15 -0
- package/releases/0.32.0/surfaces/prisma/tree/CLAUDE.md +5 -0
- package/releases/0.33.0/core/GAPS.md +125 -0
- package/releases/0.33.0/core/answered.json +29 -0
- package/releases/0.33.0/core/defaults/tree/.claude/agents/architect.md +1 -0
- package/releases/0.33.0/core/defaults/tree/.claude/agents/dba.md +3 -0
- package/releases/0.33.0/core/defaults/tree/.claude/agents/devops.md +1 -0
- package/releases/0.33.0/core/defaults/tree/.claude/agents/implementer.md +1 -0
- package/releases/0.33.0/core/defaults/tree/.claude/agents/integration-tester.md +3 -0
- package/releases/0.33.0/core/defaults/tree/.claude/agents/qa.md +2 -0
- package/releases/0.33.0/core/defaults/tree/.claude/agents/reviewer.md +3 -0
- package/releases/0.33.0/core/defaults/tree/.claude/agents/secops.md +3 -0
- package/releases/0.33.0/core/defaults/tree/.claude/agents/solidity-auditor.md +3 -0
- package/releases/0.33.0/core/defaults/tree/.claude/agents/solidity-dev.md +3 -0
- package/releases/0.33.0/core/tree/.claude/agents/architect.md +127 -0
- package/releases/0.33.0/core/tree/.claude/agents/dba.md +110 -0
- package/releases/0.33.0/core/tree/.claude/agents/devops.md +114 -0
- package/releases/0.33.0/core/tree/.claude/agents/implementer.md +156 -0
- package/releases/0.33.0/core/tree/.claude/agents/integration-tester.md +168 -0
- package/releases/0.33.0/core/tree/.claude/agents/planner.md +132 -0
- package/releases/0.33.0/core/tree/.claude/agents/qa.md +148 -0
- package/releases/0.33.0/core/tree/.claude/agents/reviewer.md +183 -0
- package/releases/0.33.0/core/tree/.claude/agents/secops.md +121 -0
- package/releases/0.33.0/core/tree/.claude/agents/solidity-auditor.md +126 -0
- package/releases/0.33.0/core/tree/.claude/agents/solidity-dev.md +123 -0
- package/releases/0.33.0/core/tree/.claude/agents-overview.md +64 -0
- package/releases/0.33.0/core/tree/.claude/frontend.md +10 -0
- package/releases/0.33.0/core/tree/.claude/graph.md +75 -0
- package/releases/0.33.0/core/tree/.claude/patterns.md +213 -0
- package/releases/0.33.0/core/tree/.claude/pills/README.md +146 -0
- package/releases/0.33.0/core/tree/.claude/pipeline.md +101 -0
- package/releases/0.33.0/core/tree/.claude/retrieval.md +103 -0
- package/releases/0.33.0/core/tree/.claude/router.md +198 -0
- package/releases/0.33.0/core/tree/.claude/templates/integration.md +45 -0
- package/releases/0.33.0/core/tree/CLAUDE.md +161 -0
- package/releases/0.33.0/core/tree/scripts/cost-watch.mjs +18 -0
- package/releases/0.33.0/core/tree/scripts/edit-guard.mjs +16 -0
- package/releases/0.33.0/core/tree/scripts/harness-check.mjs +71 -0
- package/releases/0.33.0/core/tree/scripts/loop-gate.mjs +19 -0
- package/releases/0.33.0/core/vocabulary.json +13 -0
- package/releases/0.33.0/surfaces/blockchain/tree/.claude/agents/architect.md +5 -0
- package/releases/0.33.0/surfaces/blockchain/tree/.claude/agents/devops.md +5 -0
- package/releases/0.33.0/surfaces/blockchain/tree/.claude/agents/reviewer.md +5 -0
- package/releases/0.33.0/surfaces/blockchain/tree/.claude/agents/solidity-auditor.md +18 -0
- package/releases/0.33.0/surfaces/blockchain/tree/.claude/agents/solidity-dev.md +16 -0
- package/releases/0.33.0/surfaces/blockchain/tree/.claude/agents-overview.md +6 -0
- package/releases/0.33.0/surfaces/blockchain/tree/.claude/graph.md +11 -0
- package/releases/0.33.0/surfaces/blockchain/tree/.claude/router.md +6 -0
- package/releases/0.33.0/surfaces/blockchain/tree/CLAUDE.md +12 -0
- package/releases/0.33.0/surfaces/db/tree/.claude/agents/architect.md +5 -0
- package/releases/0.33.0/surfaces/db/tree/.claude/agents/dba.md +50 -0
- package/releases/0.33.0/surfaces/db/tree/.claude/agents/devops.md +5 -0
- package/releases/0.33.0/surfaces/db/tree/.claude/agents/implementer.md +2 -0
- package/releases/0.33.0/surfaces/db/tree/.claude/agents/integration-tester.md +10 -0
- package/releases/0.33.0/surfaces/db/tree/.claude/agents/planner.md +2 -0
- package/releases/0.33.0/surfaces/db/tree/.claude/agents/reviewer.md +22 -0
- package/releases/0.33.0/surfaces/db/tree/.claude/agents/secops.md +2 -0
- package/releases/0.33.0/surfaces/db/tree/.claude/agents-overview.md +5 -0
- package/releases/0.33.0/surfaces/db/tree/.claude/graph.md +8 -0
- package/releases/0.33.0/surfaces/db/tree/.claude/patterns.md +19 -0
- package/releases/0.33.0/surfaces/db/tree/.claude/pipeline.md +13 -0
- package/releases/0.33.0/surfaces/db/tree/.claude/retrieval.md +5 -0
- package/releases/0.33.0/surfaces/db/tree/.claude/router.md +4 -0
- package/releases/0.33.0/surfaces/db/tree/CLAUDE.md +17 -0
- package/releases/0.33.0/surfaces/edge-cf/tree/.claude/agents/architect.md +14 -0
- package/releases/0.33.0/surfaces/edge-cf/tree/.claude/agents/dba.md +2 -0
- package/releases/0.33.0/surfaces/edge-cf/tree/.claude/agents/devops.md +32 -0
- package/releases/0.33.0/surfaces/edge-cf/tree/.claude/agents/implementer.md +14 -0
- package/releases/0.33.0/surfaces/edge-cf/tree/.claude/agents/integration-tester.md +11 -0
- package/releases/0.33.0/surfaces/edge-cf/tree/.claude/agents/qa.md +3 -0
- package/releases/0.33.0/surfaces/edge-cf/tree/.claude/agents/reviewer.md +8 -0
- package/releases/0.33.0/surfaces/edge-cf/tree/.claude/agents/secops.md +8 -0
- package/releases/0.33.0/surfaces/edge-cf/tree/.claude/patterns.md +17 -0
- package/releases/0.33.0/surfaces/edge-cf/tree/.claude/retrieval.md +8 -0
- package/releases/0.33.0/surfaces/edge-cf/tree/CLAUDE.md +27 -0
- package/releases/0.33.0/surfaces/frontend/tree/.claude/agents/architect.md +20 -0
- package/releases/0.33.0/surfaces/frontend/tree/.claude/agents/devops.md +28 -0
- package/releases/0.33.0/surfaces/frontend/tree/.claude/agents/implementer.md +25 -0
- package/releases/0.33.0/surfaces/frontend/tree/.claude/agents/qa.md +17 -0
- package/releases/0.33.0/surfaces/frontend/tree/.claude/agents/reviewer.md +56 -0
- package/releases/0.33.0/surfaces/frontend/tree/.claude/patterns.md +8 -0
- package/releases/0.33.0/surfaces/frontend/tree/.claude/retrieval.md +2 -0
- package/releases/0.33.0/surfaces/frontend/tree/.claude/router.md +14 -0
- package/releases/0.33.0/surfaces/frontend/tree/CLAUDE.md +5 -0
- package/releases/0.33.0/surfaces/integrations/tree/.claude/agents/architect.md +16 -0
- package/releases/0.33.0/surfaces/integrations/tree/.claude/agents/implementer.md +12 -0
- package/releases/0.33.0/surfaces/integrations/tree/.claude/agents/integration-tester.md +20 -0
- package/releases/0.33.0/surfaces/integrations/tree/.claude/agents/planner.md +2 -0
- package/releases/0.33.0/surfaces/integrations/tree/.claude/agents/reviewer.md +21 -0
- package/releases/0.33.0/surfaces/integrations/tree/.claude/agents/secops.md +2 -0
- package/releases/0.33.0/surfaces/integrations/tree/.claude/agents-overview.md +8 -0
- package/releases/0.33.0/surfaces/integrations/tree/.claude/graph.md +10 -0
- package/releases/0.33.0/surfaces/integrations/tree/.claude/patterns.md +38 -0
- package/releases/0.33.0/surfaces/integrations/tree/.claude/pipeline.md +16 -0
- package/releases/0.33.0/surfaces/integrations/tree/.claude/retrieval.md +16 -0
- package/releases/0.33.0/surfaces/integrations/tree/.claude/router.md +6 -0
- package/releases/0.33.0/surfaces/integrations/tree/CLAUDE.md +17 -0
- package/releases/0.33.0/surfaces/money/tree/.claude/agents/architect.md +12 -0
- package/releases/0.33.0/surfaces/money/tree/.claude/agents/dba.md +14 -0
- package/releases/0.33.0/surfaces/money/tree/.claude/agents/implementer.md +11 -0
- package/releases/0.33.0/surfaces/money/tree/.claude/agents/integration-tester.md +3 -0
- package/releases/0.33.0/surfaces/money/tree/.claude/agents/reviewer.md +23 -0
- package/releases/0.33.0/surfaces/money/tree/.claude/agents/secops.md +5 -0
- package/releases/0.33.0/surfaces/money/tree/.claude/agents/solidity-auditor.md +4 -0
- package/releases/0.33.0/surfaces/money/tree/.claude/agents/solidity-dev.md +4 -0
- package/releases/0.33.0/surfaces/money/tree/.claude/graph.md +2 -0
- package/releases/0.33.0/surfaces/money/tree/.claude/patterns.md +49 -0
- package/releases/0.33.0/surfaces/money/tree/.claude/pipeline.md +2 -0
- package/releases/0.33.0/surfaces/money/tree/.claude/retrieval.md +2 -0
- package/releases/0.33.0/surfaces/money/tree/CLAUDE.md +11 -0
- package/releases/0.33.0/surfaces/pii/tree/.claude/agents/dba.md +2 -0
- package/releases/0.33.0/surfaces/pii/tree/.claude/agents/devops.md +8 -0
- package/releases/0.33.0/surfaces/pii/tree/.claude/agents/reviewer.md +8 -0
- package/releases/0.33.0/surfaces/pii/tree/.claude/agents/secops.md +2 -0
- package/releases/0.33.0/surfaces/pii/tree/.claude/agents/solidity-auditor.md +5 -0
- package/releases/0.33.0/surfaces/pii/tree/CLAUDE.md +5 -0
- package/releases/0.33.0/surfaces/prisma/tree/.claude/agents/architect.md +2 -0
- package/releases/0.33.0/surfaces/prisma/tree/.claude/agents/dba.md +39 -0
- package/releases/0.33.0/surfaces/prisma/tree/.claude/agents/devops.md +11 -0
- package/releases/0.33.0/surfaces/prisma/tree/.claude/agents/implementer.md +2 -0
- package/releases/0.33.0/surfaces/prisma/tree/.claude/agents/integration-tester.md +2 -0
- package/releases/0.33.0/surfaces/prisma/tree/.claude/agents/reviewer.md +2 -0
- package/releases/0.33.0/surfaces/prisma/tree/.claude/patterns.md +15 -0
- package/releases/0.33.0/surfaces/prisma/tree/CLAUDE.md +5 -0
- package/src/commands/check.mjs +39 -7
- package/src/commands/compose.mjs +5 -0
- package/src/commands/learn.mjs +27 -7
- package/src/commands/pills.mjs +54 -2
- package/src/commands/runs.mjs +129 -0
- package/src/commands/snapshot.mjs +80 -30
- package/src/commands/stats.mjs +70 -21
- package/src/commands/status.mjs +150 -0
- package/src/commands/upgrade.mjs +18 -4
- package/src/cost.mjs +141 -0
- package/src/detectors.mjs +84 -14
- package/src/expected.mjs +4 -3
- package/src/transcripts.mjs +27 -1
- package/src/wiring.mjs +3 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nina status` — one screen for one project: what it pins and what is installed, whether a newer release
|
|
3
|
+
* is out, whether its hooks are wired and its gate holds, what `check` says, how fresh its measured
|
|
4
|
+
* history is and what the last week cost, and where its learning stands.
|
|
5
|
+
*
|
|
6
|
+
* Each of these had its own command, and the question a person brings — is this project's harness in order,
|
|
7
|
+
* and is it current — needed six of them. Every line here reads what those commands read, or asks them, so
|
|
8
|
+
* the screen cannot disagree with them.
|
|
9
|
+
*
|
|
10
|
+
* nina status [--project <dir>] [--offline]
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import { join, resolve } from 'node:path';
|
|
17
|
+
import { HARNESS } from '../paths.mjs';
|
|
18
|
+
import { costOf } from '../prices.mjs';
|
|
19
|
+
import { runOf } from '../transcripts.mjs';
|
|
20
|
+
import { missingWiring, shippedScripts } from '../wiring.mjs';
|
|
21
|
+
import { amber, bold, dim, green, pink } from '../look.mjs';
|
|
22
|
+
import { byVersion, layerRootFor } from './compose.mjs';
|
|
23
|
+
import { filedRequests } from './learn.mjs';
|
|
24
|
+
import { frontmatter, pillFiles } from './pills.mjs';
|
|
25
|
+
import { storedRecords } from './snapshot.mjs';
|
|
26
|
+
|
|
27
|
+
/** How long the registry gets to say which version is newest; a status is not worth waiting on. */
|
|
28
|
+
const REGISTRY_MS = 3000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The version of NINA the project has installed, or null.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} target
|
|
34
|
+
* @returns {string|null}
|
|
35
|
+
*/
|
|
36
|
+
function installedVersion(target) {
|
|
37
|
+
try {
|
|
38
|
+
const path = createRequire(join(target, 'package.json')).resolve('@xhulz/nina/package.json');
|
|
39
|
+
return JSON.parse(readFileSync(path, 'utf8')).version ?? null;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The newest version on the registry, or null when it cannot be asked.
|
|
47
|
+
*
|
|
48
|
+
* @returns {Promise<string|null>}
|
|
49
|
+
*/
|
|
50
|
+
async function latestVersion() {
|
|
51
|
+
try {
|
|
52
|
+
const answer = await fetch('https://registry.npmjs.org/@xhulz%2Fnina/latest', { signal: AbortSignal.timeout(REGISTRY_MS) });
|
|
53
|
+
return answer.ok ? ((await answer.json()).version ?? null) : null;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The last line a command printed, and whether it passed: what `check` and `gate --selftest` conclude.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} bin - This install's entry point.
|
|
63
|
+
* @param {string[]} args
|
|
64
|
+
* @returns {{ok: boolean, line: string}}
|
|
65
|
+
*/
|
|
66
|
+
function verdictOf(bin, args) {
|
|
67
|
+
const run = spawnSync(process.execPath, [bin, ...args, '--quiet'], { encoding: 'utf8', timeout: 120_000 });
|
|
68
|
+
const lines = `${run.stdout ?? ''}`.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
69
|
+
return { ok: run.status === 0, line: lines.at(-1) ?? `exit ${run.status}` };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** How long ago a moment was, in words. */
|
|
73
|
+
function ago(iso, now) {
|
|
74
|
+
const ms = now - Date.parse(iso);
|
|
75
|
+
if (!Number.isFinite(ms)) return 'at an unknown time';
|
|
76
|
+
const h = ms / 3_600_000;
|
|
77
|
+
return h < 1 ? `${Math.max(1, Math.round(ms / 60_000))} min ago` : h < 48 ? `${Math.round(h)}h ago` : `${Math.round(h / 24)} days ago`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Runs the command.
|
|
82
|
+
*
|
|
83
|
+
* @param {string[]} argv
|
|
84
|
+
* @param {{root: string}} ctx
|
|
85
|
+
* @returns {Promise<number>}
|
|
86
|
+
*/
|
|
87
|
+
export async function status(argv, ctx) {
|
|
88
|
+
const target = resolve(argv.includes('--project') ? argv[argv.indexOf('--project') + 1] : '.');
|
|
89
|
+
const profilePath = join(target, HARNESS, 'profile.json');
|
|
90
|
+
if (!existsSync(profilePath)) {
|
|
91
|
+
console.error(` no ${HARNESS}/profile.json under ${target} — run it inside a project, or name one with --project\n`);
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
const profile = JSON.parse(readFileSync(profilePath, 'utf8'));
|
|
95
|
+
const bin = join(ctx.root, 'bin', 'nina.mjs');
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
const row = (label, text) => console.log(` ${pink(label.padEnd(10))} ${text}`);
|
|
98
|
+
let attention = 0;
|
|
99
|
+
const warn = (text) => {
|
|
100
|
+
attention += 1;
|
|
101
|
+
return amber(text);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const installed = installedVersion(target);
|
|
105
|
+
const latest = argv.includes('--offline') ? null : await latestVersion();
|
|
106
|
+
const pinned = profile.core;
|
|
107
|
+
const behind = latest && pinned !== 'dev' && byVersion(pinned, latest) < 0;
|
|
108
|
+
row(
|
|
109
|
+
'version',
|
|
110
|
+
`pins ${bold(pinned)} · installed ${installed ? bold(installed) : warn('none — pnpm add -D -E @xhulz/nina')}` +
|
|
111
|
+
(latest ? ` · newest ${bold(latest)}${behind ? warn(` — \`nina upgrade --to ${latest}\` says what the move costs`) : green(' — current')}` : dim(' · newest unknown (offline)')),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const layers = layerRootFor(ctx.root, pinned);
|
|
115
|
+
if (!layers.error) {
|
|
116
|
+
const unwired = await missingWiring(target, await shippedScripts(layers.dir, profile.surfaces ?? []));
|
|
117
|
+
row('hooks', unwired.length === 0 ? green('every hook its release needs is wired') : warn(`${unwired.length} missing — \`nina wire --apply\` merges them`));
|
|
118
|
+
} else {
|
|
119
|
+
row('hooks', warn(layers.error));
|
|
120
|
+
}
|
|
121
|
+
const gate = verdictOf(bin, ['gate', '--selftest', '--project', target]);
|
|
122
|
+
row('gate', gate.ok ? green(gate.line) : warn(`${gate.line} — \`nina gate --selftest\` says what`));
|
|
123
|
+
const check = verdictOf(bin, ['check', '--project', target]);
|
|
124
|
+
row('check', check.ok ? green(check.line) : warn(`${check.line} — \`nina check\` lists them`));
|
|
125
|
+
|
|
126
|
+
const records = storedRecords(target) ?? [];
|
|
127
|
+
if (records.length === 0) {
|
|
128
|
+
row('measured', dim('nothing yet — `nina snapshot` captures it'));
|
|
129
|
+
} else {
|
|
130
|
+
const latestAt = records.map((r) => String(r.result_ts ?? r.ts)).sort().at(-1);
|
|
131
|
+
const week = new Date(now - 7 * 86_400_000).toISOString();
|
|
132
|
+
const recent = records.filter((r) => String(r.ts) >= week);
|
|
133
|
+
const cost = recent.reduce((a, r) => a + ((r.tokens && costOf(r.tokens, r.usage_model)) || 0), 0);
|
|
134
|
+
row(
|
|
135
|
+
'measured',
|
|
136
|
+
`${new Set(records.map(runOf)).size} runs in ${records.length} rounds, the latest ${ago(latestAt, now)} · last 7 days: ` +
|
|
137
|
+
`${new Set(recent.map(runOf)).size} runs, ${bold(`$${cost.toFixed(cost >= 100 ? 0 : 2)}`)} at API list prices — \`nina runs\` itemizes it`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const pillsDir = join(target, '.claude', 'pills');
|
|
142
|
+
const pills = existsSync(pillsDir) ? await pillFiles(pillsDir) : [];
|
|
143
|
+
let active = 0;
|
|
144
|
+
for (const p of pills) if (frontmatter(readFileSync(p.path, 'utf8'))?.status !== 'retired') active += 1;
|
|
145
|
+
const open = (await filedRequests(target)).filter((r) => r.status === 'open').length;
|
|
146
|
+
row('learning', `${pills.length} pill(s), ${active} active · ${open} request(s) open with the harness${open ? dim(' — `nina upgrade` to the release that answers one closes it') : ''}`);
|
|
147
|
+
|
|
148
|
+
console.log(attention === 0 ? `\n status: ${green('in order')}\n` : `\n status: ${attention} thing(s) to look at\n`);
|
|
149
|
+
return attention === 0 ? 0 : 1;
|
|
150
|
+
}
|
package/src/commands/upgrade.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { NEEDS, detected } from '../surfaces.mjs';
|
|
|
20
20
|
import { filledSlots, projectSlots, referencedVocabulary } from './check.mjs';
|
|
21
21
|
import { EXPECT_ENV } from '../expected.mjs';
|
|
22
22
|
import { closeAnswered } from './learn.mjs';
|
|
23
|
+
import { tidyRetired } from './pills.mjs';
|
|
23
24
|
import { missingFragment, missingWiring, shippedScripts } from '../wiring.mjs';
|
|
24
25
|
import { runSteps } from '../steps.mjs';
|
|
25
26
|
import { defaultVocabulary } from '../vocabulary.mjs';
|
|
@@ -36,7 +37,8 @@ const BANNER = /^[\s█╗╔╝║═╚▄▀]*$|harness orchestration ·/;
|
|
|
36
37
|
* fault — everything else it finds still rolls the move back.
|
|
37
38
|
*
|
|
38
39
|
* @param {string} target - The project.
|
|
39
|
-
* @param {string[]} newSlots - Project slots this move introduces, as `<file> <slot id
|
|
40
|
+
* @param {string[]} newSlots - Project slots this move introduces, as `<file> <slot id>`, and the
|
|
41
|
+
* vocabulary names it introduces, as `{{NAME}}`.
|
|
40
42
|
* @returns {{label: string, args: string[]}[]}
|
|
41
43
|
*/
|
|
42
44
|
function validationsFor(target, newSlots = []) {
|
|
@@ -235,11 +237,16 @@ export async function upgrade(argv, ctx) {
|
|
|
235
237
|
const declared = profile.vocabulary ?? {};
|
|
236
238
|
// A name the new release supplies a default for is not owed: it composes as the default until declared.
|
|
237
239
|
const defaultsAfter = defaultVocabulary(onto.dir);
|
|
238
|
-
const
|
|
240
|
+
const waits = (key) => typeof profile.deferred?.[key] === 'string' && profile.deferred[key].trim() !== '';
|
|
241
|
+
const needed = [...vocabAfter].filter((v) => ((!(v in declared) && !(v in defaultsAfter)) || declared[v] === null) && !waits(v)).sort();
|
|
239
242
|
const unused = Object.keys(declared).filter((v) => !vocabAfter.has(v)).sort();
|
|
240
243
|
// A slot the new release fills itself is not asked of the project: it composes as the release wrote it.
|
|
241
244
|
const defaultedAfter = await defaultedSlots(onto.dir);
|
|
242
245
|
const newSlots = [...slotsAfter].filter((s) => !slotsBefore.has(s) && !filled.has(s) && !defaultedAfter.has(s)).sort();
|
|
246
|
+
// A name only the new release uses is owed the way a new slot is: it cannot be filled with meaning
|
|
247
|
+
// before the core that asks for it is pinned, and rolling back for it left no order the move completes in.
|
|
248
|
+
const newVocab = needed.filter((v) => !vocabBefore.has(v));
|
|
249
|
+
const awaited = [...newSlots, ...newVocab.map((v) => `{{${v}}}`)];
|
|
243
250
|
const stranded = [...filled].filter((s) => !slotsAfter.has(s)).sort();
|
|
244
251
|
|
|
245
252
|
console.log(` ${profile.core} → ${to}\n`);
|
|
@@ -355,7 +362,7 @@ export async function upgrade(argv, ctx) {
|
|
|
355
362
|
// What was already failing is not this upgrade's doing, and rolling back for it would blame
|
|
356
363
|
// the move for a problem the project brought with it. Measured before anything is written,
|
|
357
364
|
// so the only rollback is for something that WAS passing and now is not.
|
|
358
|
-
const VALIDATIONS = validationsFor(target,
|
|
365
|
+
const VALIDATIONS = validationsFor(target, awaited);
|
|
359
366
|
const DETECTORS = "running the project's own detectors";
|
|
360
367
|
const hasDetectors = await declares(target, 'harness:check');
|
|
361
368
|
|
|
@@ -414,7 +421,7 @@ export async function upgrade(argv, ctx) {
|
|
|
414
421
|
// `harness:check` — an npm script whose detector list this command does not own and cannot
|
|
415
422
|
// pass an argument to. The environment is what crosses that boundary, and setting it once
|
|
416
423
|
// means a reporter added to the chain later is told without anyone remembering to tell it.
|
|
417
|
-
if (
|
|
424
|
+
if (awaited.length > 0) process.env[EXPECT_ENV] = awaited.join(',');
|
|
418
425
|
|
|
419
426
|
// Compose writes and never deletes. So the files the new version stops composing are removed right
|
|
420
427
|
// after it composes — before anything is verified, so what is verified is what stays — unless they
|
|
@@ -499,6 +506,9 @@ export async function upgrade(argv, ctx) {
|
|
|
499
506
|
}
|
|
500
507
|
}
|
|
501
508
|
}
|
|
509
|
+
// Retired pills still where agents open them, from before they had a place of their own.
|
|
510
|
+
const shelved = await tidyRetired(target).catch(() => []);
|
|
511
|
+
if (shelved.length > 0) console.log(`\n moved ${shelved.length} retired pill(s) to .claude/pills/retired/, where no agent opens them: ${shelved.join(', ')}`);
|
|
502
512
|
const lessons = nina(ctx, target, ['learn', '--project', target, '--check']);
|
|
503
513
|
if (!lessons.ok && lessons.out.trim()) {
|
|
504
514
|
console.log('\n the learning cycle has something to act on — not caused by this move, now visible:');
|
|
@@ -511,6 +521,10 @@ export async function upgrade(argv, ctx) {
|
|
|
511
521
|
console.log(`\n ${newSlots.length} project slot(s) still to fill — only this project can write them:`);
|
|
512
522
|
for (const s of newSlots) console.log(` ${s}`);
|
|
513
523
|
}
|
|
524
|
+
if (newVocab.length > 0) {
|
|
525
|
+
console.log(`\n ${newVocab.length} vocabulary name(s) ${to} asks for and this profile does not answer — fill each, or defer it with why:`);
|
|
526
|
+
console.log(` ${newVocab.join(', ')}`);
|
|
527
|
+
}
|
|
514
528
|
console.log('');
|
|
515
529
|
return 0;
|
|
516
530
|
}
|
package/src/cost.mjs
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cost watch: tells a subagent, while it runs, what its own context has come to cost.
|
|
3
|
+
*
|
|
4
|
+
* What a run spends is mostly its context read again: every turn re-reads everything the run has built,
|
|
5
|
+
* so its cost grows faster than its work. Over the first new project's first days, three quarters of the
|
|
6
|
+
* implementer's spend was cache read, a median 36M tokens a round and 159M at most, and nothing said so
|
|
7
|
+
* until `stats` read the transcripts afterwards. This runs on every tool call a subagent makes — Claude Code
|
|
8
|
+
* hands a PostToolUse hook's `additionalContext` to the subagent that made the call — and, each time the
|
|
9
|
+
* tokens its run has re-read cross a threshold and then each doubling of it, tells the run what each turn
|
|
10
|
+
* now costs and what to do about it, and tells the person in one line.
|
|
11
|
+
*
|
|
12
|
+
* It reads only what the run's transcript gained since its last call, keeps counts per message and
|
|
13
|
+
* nothing else, under the project's gate directory, and lets every call through if anything goes wrong.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
import { projectGateDir } from './gate.mjs';
|
|
19
|
+
|
|
20
|
+
/** Where a run's counts are kept between calls, one file per agent. */
|
|
21
|
+
const stateDir = (root) => join(projectGateDir(root), 'cost');
|
|
22
|
+
|
|
23
|
+
/** Runs' counts are dropped this long after their last call: a run does not come back after a week. */
|
|
24
|
+
const KEEP_MS = 7 * 86_400_000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Reads what a file gained after an offset, up to its last complete line.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} path - The transcript.
|
|
30
|
+
* @param {number} from - The offset already read.
|
|
31
|
+
* @returns {{text: string, to: number}}
|
|
32
|
+
*/
|
|
33
|
+
function gained(path, from) {
|
|
34
|
+
const size = statSync(path).size;
|
|
35
|
+
if (size <= from) return { text: '', to: size < from ? 0 : from };
|
|
36
|
+
const buffer = Buffer.alloc(size - from);
|
|
37
|
+
const fd = openSync(path, 'r');
|
|
38
|
+
try {
|
|
39
|
+
readSync(fd, buffer, 0, buffer.length, from);
|
|
40
|
+
} finally {
|
|
41
|
+
closeSync(fd);
|
|
42
|
+
}
|
|
43
|
+
const end = buffer.lastIndexOf(0x0a);
|
|
44
|
+
if (end === -1) return { text: '', to: from };
|
|
45
|
+
return { text: buffer.subarray(0, end + 1).toString('utf8'), to: from + end + 1 };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What one call of the watch says, or null: the counts are brought up to date from the run's transcript,
|
|
50
|
+
* and a warning is due each time the total re-read crosses `warnAt`, then twice it, four times it, and on.
|
|
51
|
+
*
|
|
52
|
+
* @param {object} input - The PostToolUse hook's input.
|
|
53
|
+
* @param {{root: string, warnAt: number, now?: number}} options
|
|
54
|
+
* @returns {object|null} The hook's answer.
|
|
55
|
+
*/
|
|
56
|
+
export function watch(input, { root, warnAt, now = Date.now() }) {
|
|
57
|
+
// Only inside a subagent, which is where `agent_id` is set; and an id that could not be a file name is not one.
|
|
58
|
+
if (input?.hook_event_name !== 'PostToolUse' || !/^[A-Za-z0-9_-]{1,64}$/.test(String(input.agent_id ?? ''))) return null;
|
|
59
|
+
if (typeof input.transcript_path !== 'string' || typeof input.session_id !== 'string' || !(warnAt > 0)) return null;
|
|
60
|
+
const transcript = join(dirname(input.transcript_path), input.session_id, 'subagents', `agent-${input.agent_id}.jsonl`);
|
|
61
|
+
if (!existsSync(transcript)) return null;
|
|
62
|
+
const dir = stateDir(root);
|
|
63
|
+
const file = join(dir, `${input.agent_id}.json`);
|
|
64
|
+
let state = { offset: 0, read: {}, warned: 0 };
|
|
65
|
+
try {
|
|
66
|
+
state = { ...state, ...JSON.parse(readFileSync(file, 'utf8')) };
|
|
67
|
+
} catch {
|
|
68
|
+
// A run's first call, or its counts lost: read its transcript from the start.
|
|
69
|
+
}
|
|
70
|
+
const { text, to } = gained(transcript, state.offset);
|
|
71
|
+
let last = null;
|
|
72
|
+
for (const line of text.split('\n')) {
|
|
73
|
+
if (!line.includes('"cache_read_input_tokens"')) continue;
|
|
74
|
+
try {
|
|
75
|
+
const message = JSON.parse(line)?.message;
|
|
76
|
+
const read = message?.usage?.cache_read_input_tokens;
|
|
77
|
+
// A streamed message is written once per content block under one id: its last copy counts.
|
|
78
|
+
if (message?.id && typeof read === 'number') {
|
|
79
|
+
state.read[message.id] = read;
|
|
80
|
+
last = read;
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// A torn line loses one message's count.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
state.offset = to;
|
|
87
|
+
state.at = now;
|
|
88
|
+
const total = Object.values(state.read).reduce((a, b) => a + b, 0);
|
|
89
|
+
let crossed = state.warned;
|
|
90
|
+
while (total >= warnAt * 2 ** crossed) crossed += 1;
|
|
91
|
+
const due = crossed > state.warned;
|
|
92
|
+
state.warned = crossed;
|
|
93
|
+
mkdirSync(dir, { recursive: true });
|
|
94
|
+
writeFileSync(file, JSON.stringify(state));
|
|
95
|
+
if (!due) return null;
|
|
96
|
+
const m = (n) => `${Math.round(n / 1e6)}M`;
|
|
97
|
+
const size = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : `${Math.round(n / 1000)}k`);
|
|
98
|
+
const turn = last === null ? '' : `, and each turn now re-reads about ${size(last)} tokens more`;
|
|
99
|
+
return {
|
|
100
|
+
systemMessage: `NINA: ${input.agent_type ?? 'a subagent'} ${input.agent_id.slice(0, 8)} has re-read ${m(total)} tokens of its own context${turn}.`,
|
|
101
|
+
hookSpecificOutput: {
|
|
102
|
+
hookEventName: 'PostToolUse',
|
|
103
|
+
additionalContext:
|
|
104
|
+
`NINA cost watch: this run has re-read ${m(total)} tokens of its own context${turn}; every further turn costs more than the last. ` +
|
|
105
|
+
'If what is left is more than one run should carry, stop where your work is whole, hand back what you have, and name what is left, so the orchestrator can send it as its own step. Otherwise finish without widening the work.',
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Drops the counts of runs not heard from in a week. */
|
|
111
|
+
function prune(root, now) {
|
|
112
|
+
const dir = stateDir(root);
|
|
113
|
+
for (const name of existsSync(dir) ? readdirSync(dir) : []) {
|
|
114
|
+
try {
|
|
115
|
+
if (now - statSync(join(dir, name)).mtimeMs > KEEP_MS) rmSync(join(dir, name), { force: true });
|
|
116
|
+
} catch {
|
|
117
|
+
// Another call's, going as this one looks.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The hook: reads the event from stdin, answers on stdout when a warning is due, and exits 0 whatever
|
|
124
|
+
* happens — a hook that fails must not stop the run it watches.
|
|
125
|
+
*
|
|
126
|
+
* @param {{root: string, warnAt: number, stdin?: NodeJS.ReadableStream, stdout?: NodeJS.WritableStream}} options
|
|
127
|
+
* @returns {Promise<number>}
|
|
128
|
+
*/
|
|
129
|
+
export async function runCostWatch({ root, warnAt, stdin = process.stdin, stdout = process.stdout }) {
|
|
130
|
+
try {
|
|
131
|
+
const chunks = [];
|
|
132
|
+
for await (const chunk of stdin) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
const answer = watch(JSON.parse(Buffer.concat(chunks).toString('utf8')), { root, warnAt, now });
|
|
135
|
+
if (answer) stdout.write(`${JSON.stringify(answer)}\n`);
|
|
136
|
+
if (Math.random() < 0.01) prune(root, now);
|
|
137
|
+
} catch {
|
|
138
|
+
// Fails open: the run goes on unwatched.
|
|
139
|
+
}
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
package/src/detectors.mjs
CHANGED
|
@@ -24,7 +24,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
24
24
|
import { createHash } from 'node:crypto';
|
|
25
25
|
import { hookStateDir, slugFor } from './paths.mjs';
|
|
26
26
|
import { CHECK } from './wiring.mjs';
|
|
27
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
28
28
|
import { join } from 'node:path';
|
|
29
29
|
|
|
30
30
|
/**
|
|
@@ -157,20 +157,82 @@ const stopState = (root) => join(hookStateDir(), `${slugFor(root)}.stop.json`);
|
|
|
157
157
|
/** Sessions remembered per project; the oldest is forgotten past this. */
|
|
158
158
|
const SESSIONS_KEPT = 20;
|
|
159
159
|
|
|
160
|
+
/** What Claude Code wrote to the hook's stdin, read once: stdin can be read only once. */
|
|
161
|
+
let input;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The payload Claude Code writes to a hook's stdin, or an empty object run by hand.
|
|
165
|
+
*
|
|
166
|
+
* @returns {{session_id?: string, prompt?: string, transcript_path?: string}}
|
|
167
|
+
*/
|
|
168
|
+
function hookInput() {
|
|
169
|
+
if (input === undefined) {
|
|
170
|
+
try {
|
|
171
|
+
input = process.stdin.isTTY ? {} : (JSON.parse(readFileSync(0, 'utf8')) ?? {});
|
|
172
|
+
} catch {
|
|
173
|
+
input = {};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return input;
|
|
177
|
+
}
|
|
178
|
+
|
|
160
179
|
/**
|
|
161
|
-
* The session a
|
|
162
|
-
*
|
|
163
|
-
*
|
|
180
|
+
* The session a hook runs in. What was said is kept per session: kept per project, a second session open
|
|
181
|
+
* on the same project found the finding already told — to the first session's person — and told its own
|
|
182
|
+
* nothing.
|
|
164
183
|
*
|
|
165
184
|
* @returns {string}
|
|
166
185
|
*/
|
|
167
|
-
|
|
168
|
-
|
|
186
|
+
const hookSession = () => String(hookInput().session_id ?? '-');
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Whether the prompt the hook runs before is a subagent's completion notification rather than something a
|
|
190
|
+
* person said. Claude Code submits each as a prompt, and every one ran the detectors — a second or more
|
|
191
|
+
* before the model read it — and put their findings beside it: one session of the first new project did so
|
|
192
|
+
* 181 times over 23 of its owner's messages.
|
|
193
|
+
*
|
|
194
|
+
* @returns {boolean}
|
|
195
|
+
*/
|
|
196
|
+
const notification = () => String(hookInput().prompt ?? '').trimStart().startsWith('<task-notification>');
|
|
197
|
+
|
|
198
|
+
/** How much of a transcript's end is read for a compaction since the last hand-over: it is the latest line. */
|
|
199
|
+
const TAIL = 1024 * 1024;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Whether the session was compacted after a moment: a compaction drops what the model was handed, so what it
|
|
203
|
+
* was handed is handed again.
|
|
204
|
+
*
|
|
205
|
+
* @param {unknown} path - The session's transcript.
|
|
206
|
+
* @param {unknown} since - An ISO time.
|
|
207
|
+
* @returns {boolean}
|
|
208
|
+
*/
|
|
209
|
+
function compactedSince(path, since) {
|
|
210
|
+
if (typeof path !== 'string' || typeof since !== 'string' || !existsSync(path)) return false;
|
|
211
|
+
let text;
|
|
169
212
|
try {
|
|
170
|
-
|
|
213
|
+
const size = statSync(path).size;
|
|
214
|
+
const length = Math.min(size, TAIL);
|
|
215
|
+
const buffer = Buffer.alloc(length);
|
|
216
|
+
const fd = openSync(path, 'r');
|
|
217
|
+
try {
|
|
218
|
+
readSync(fd, buffer, 0, length, size - length);
|
|
219
|
+
} finally {
|
|
220
|
+
closeSync(fd);
|
|
221
|
+
}
|
|
222
|
+
text = buffer.toString('utf8');
|
|
171
223
|
} catch {
|
|
172
|
-
return
|
|
224
|
+
return false;
|
|
173
225
|
}
|
|
226
|
+
for (const line of text.split('\n')) {
|
|
227
|
+
if (!line.includes('"compact_boundary"')) continue;
|
|
228
|
+
try {
|
|
229
|
+
const row = JSON.parse(line);
|
|
230
|
+
if (row?.subtype === 'compact_boundary' && String(row.timestamp) > since) return true;
|
|
231
|
+
} catch {
|
|
232
|
+
// A torn line at the head of the tail.
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return false;
|
|
174
236
|
}
|
|
175
237
|
|
|
176
238
|
/** What was said, per session, for a project. */
|
|
@@ -237,8 +299,9 @@ function reportOf(drift, errored, missing, detectors) {
|
|
|
237
299
|
/**
|
|
238
300
|
* What the prompt hook hands the model, or null for nothing. A finding not handed over at the last
|
|
239
301
|
* message goes in full, with the instruction to act on it or say it is pending. One that was, and has
|
|
240
|
-
* not changed, goes as its summary, with the instruction not to say it again
|
|
241
|
-
* to say it was pending, the model closed every answer with the same line
|
|
302
|
+
* not changed, goes as its summary beside a new one, with the instruction not to say it again — told
|
|
303
|
+
* before every message to say it was pending, the model closed every answer with the same line — and on
|
|
304
|
+
* its own not at all: the model has it from the first time, until a compaction drops it.
|
|
242
305
|
*
|
|
243
306
|
* @returns {string|null}
|
|
244
307
|
*/
|
|
@@ -317,6 +380,7 @@ function remember(root, session, change) {
|
|
|
317
380
|
*/
|
|
318
381
|
export function runDetectors(detectors, options) {
|
|
319
382
|
const { root, hook = false, context = false } = options;
|
|
383
|
+
if (context && notification()) return 0;
|
|
320
384
|
const declared = declaredIn(root);
|
|
321
385
|
const results = detectors.map((d) => runOne(d, root, declared, context ? 'context' : hook ? 'stop' : ''));
|
|
322
386
|
const applicable = results.filter((r) => r.state !== 'n/a');
|
|
@@ -358,11 +422,17 @@ export function runDetectors(detectors, options) {
|
|
|
358
422
|
// again: it has it already, and every copy stays in the conversation.
|
|
359
423
|
if (context) {
|
|
360
424
|
const session = hookSession();
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
425
|
+
const state = readStops(root)[session] ?? {};
|
|
426
|
+
// A compaction since the last hand-over dropped it from the model's context: everything goes again.
|
|
427
|
+
const compacted = compactedSince(hookInput().transcript_path, state.handed_at);
|
|
428
|
+
const handed = new Set(!compacted && Array.isArray(state.given) ? state.given : []);
|
|
364
429
|
const known = drift.filter((r) => findingKeys(r).every((k) => handed.has(k)));
|
|
365
|
-
const
|
|
430
|
+
const fresh = drift.filter((r) => !known.includes(r));
|
|
431
|
+
remember(root, session, { given: drift.flatMap(findingKeys), ...(fresh.length > 0 ? { handed_at: new Date().toISOString() } : {}) });
|
|
432
|
+
// What the model was handed and has not changed is said only beside something new. Said on its own
|
|
433
|
+
// before every message, it went into the conversation on every prompt the session had.
|
|
434
|
+
const speaking = fresh.length > 0 || errored.length > 0 || missing.length > 0;
|
|
435
|
+
const said = contextOf(fresh, speaking ? known : [], errored, missing, detectors);
|
|
366
436
|
if (said) {
|
|
367
437
|
process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: said } })}\n`);
|
|
368
438
|
}
|
package/src/expected.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The project slots a caller has declared it already knows are open.
|
|
2
|
+
* The project slots, and vocabulary names, a caller has declared it already knows are open.
|
|
3
3
|
*
|
|
4
4
|
* `nina upgrade --apply` creates slots: a new core can introduce one, and it cannot be filled
|
|
5
|
-
* before the core that introduces it is pinned.
|
|
5
|
+
* before the core that introduces it is pinned. A name only the new core uses is the same case,
|
|
6
|
+
* named as `{{NAME}}`: `check` rolled a move back for one the preview had just listed as needed. Every reporter that fails on an unfilled slot
|
|
6
7
|
* therefore has to be told, or the move deadlocks — there is no order in which it completes.
|
|
7
8
|
*
|
|
8
9
|
* Two reporters say this same thing, and the first fix taught only one: `check` counts a slot
|
|
@@ -21,7 +22,7 @@ export const EXPECT_ENV = 'NINA_EXPECT_UNFILLED';
|
|
|
21
22
|
/**
|
|
22
23
|
* @param {string[]} argv - The command's arguments; `--expect-unfilled <a,b>` names slots.
|
|
23
24
|
* @param {NodeJS.ProcessEnv} [env] - Where to read `NINA_EXPECT_UNFILLED` from.
|
|
24
|
-
* @returns {Set<string>} Slot ids as `<relative path> <slot id
|
|
25
|
+
* @returns {Set<string>} Slot ids as `<relative path> <slot id>`, names as `{{NAME}}`.
|
|
25
26
|
*/
|
|
26
27
|
export function expectedUnfilled(argv = [], env = process.env) {
|
|
27
28
|
const fromArgv = argv.includes('--expect-unfilled') ? argv[argv.indexOf('--expect-unfilled') + 1] ?? '' : '';
|
package/src/transcripts.mjs
CHANGED
|
@@ -626,9 +626,13 @@ async function attachAgentDetail(projectDir, dispatches) {
|
|
|
626
626
|
// 744 of one project's 826 in full on every turn, two seconds before the person saw an answer.
|
|
627
627
|
const size = (await stat(join(dir, file)).catch(() => null))?.size ?? null;
|
|
628
628
|
const settled = (r) => r.handed_back === false || String(r.verdict_source ?? '').startsWith('handback');
|
|
629
|
+
// `resumes` is written only by a reader from before rounds, which bills the whole run to its first
|
|
630
|
+
// record. A store both versions wrote to — one project on the old pin, a hook elsewhere on the new —
|
|
631
|
+
// had its runs marked read by the old one and skipped here, their first round billed twice.
|
|
629
632
|
if (
|
|
630
633
|
run.agent_read_bytes === size &&
|
|
631
|
-
|
|
634
|
+
!('resumes' in run) &&
|
|
635
|
+
[...records, ...later].every((r) => settled(r) && 'round' in r && 'lessons_read' in r && 'tokens' in r && 'files_touched' in r && 'effort' in r && 'turns' in r)
|
|
632
636
|
) {
|
|
633
637
|
continue;
|
|
634
638
|
}
|
|
@@ -656,6 +660,8 @@ async function attachAgentDetail(projectDir, dispatches) {
|
|
|
656
660
|
usage: new Map(),
|
|
657
661
|
/** The files the round wrote through the edit tools. Only their number is kept. */
|
|
658
662
|
written: new Set(),
|
|
663
|
+
/** The tool calls the round made, by id: over its turns, how many calls each turn sent at once. */
|
|
664
|
+
calls: new Set(),
|
|
659
665
|
});
|
|
660
666
|
const rounds = [fresh()];
|
|
661
667
|
const roundOf = roundsOf();
|
|
@@ -677,6 +683,7 @@ async function attachAgentDetail(projectDir, dispatches) {
|
|
|
677
683
|
const round = rounds[n - 1];
|
|
678
684
|
const hasSkill = line.includes('"Skill"');
|
|
679
685
|
const hasHandback = line.includes('SubagentHandback');
|
|
686
|
+
for (const m of line.matchAll(/"type":"tool_use","id":"([^"]+)"/g)) round.calls.add(m[1]);
|
|
680
687
|
if (/"name":"(Edit|Write|MultiEdit|NotebookEdit)"/.test(line)) {
|
|
681
688
|
try {
|
|
682
689
|
for (const block of JSON.parse(line)?.message?.content ?? []) {
|
|
@@ -755,9 +762,14 @@ async function attachAgentDetail(projectDir, dispatches) {
|
|
|
755
762
|
targets = [dispatches.get(id)];
|
|
756
763
|
}
|
|
757
764
|
const spent = tokensOf(round.usage);
|
|
765
|
+
const shape = contextOf(round.usage);
|
|
758
766
|
targets.forEach((record, j) => {
|
|
759
767
|
// One transcript, one bill: were two records ever to share an agent, the second would count it again.
|
|
760
768
|
record.tokens = j === 0 ? spent.tokens : null;
|
|
769
|
+
record.turns = shape.turns;
|
|
770
|
+
record.tool_calls = round.calls.size;
|
|
771
|
+
record.context_start = shape.start;
|
|
772
|
+
record.context_peak = shape.peak;
|
|
761
773
|
record.usage_model = spent.model;
|
|
762
774
|
record.effort = spent.effort;
|
|
763
775
|
record.files_touched = round.written.size;
|
|
@@ -790,6 +802,20 @@ async function attachAgentDetail(projectDir, dispatches) {
|
|
|
790
802
|
}
|
|
791
803
|
}
|
|
792
804
|
|
|
805
|
+
/**
|
|
806
|
+
* How a round spent its context: how many turns it took, and how large the context each turn re-read was
|
|
807
|
+
* when the round began and at its largest. Every turn is billed the whole context again, so a round that
|
|
808
|
+
* begins where a long one ended — a run resumed for a fix — pays for everything before it on each turn.
|
|
809
|
+
*
|
|
810
|
+
* @param {Map<string, {usage: object}>} usage - Message id → its final usage, in the order the round sent them.
|
|
811
|
+
* @returns {{turns: number, start: number|null, peak: number|null}} Context sizes in tokens.
|
|
812
|
+
*/
|
|
813
|
+
export function contextOf(usage) {
|
|
814
|
+
const n = (v) => (typeof v === 'number' ? v : 0);
|
|
815
|
+
const sizes = [...usage.values()].map(({ usage: u }) => n(u.input_tokens) + n(u.cache_read_input_tokens) + n(u.cache_creation_input_tokens));
|
|
816
|
+
return { turns: sizes.length, start: sizes[0] ?? null, peak: sizes.length > 0 ? Math.max(...sizes) : null };
|
|
817
|
+
}
|
|
818
|
+
|
|
793
819
|
/**
|
|
794
820
|
* What a run spent, from each of its messages' final usage: tokens by kind, and the model and effort
|
|
795
821
|
* level that most of its messages ran at. Metadata — counts, a model id and a level, never a message.
|
package/src/wiring.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { join } from 'node:path';
|
|
|
23
23
|
export const CHECK = 'scripts/harness-check.mjs';
|
|
24
24
|
export const GATE = 'scripts/loop-gate.mjs';
|
|
25
25
|
export const GUARD = 'scripts/edit-guard.mjs';
|
|
26
|
+
export const COST = 'scripts/cost-watch.mjs';
|
|
26
27
|
|
|
27
28
|
/** The npm scripts the detectors hang off: the reviewer runs one, the other is a detector itself. */
|
|
28
29
|
export const SCRIPTS = {
|
|
@@ -135,6 +136,8 @@ export const HOOKS = [
|
|
|
135
136
|
event: 'PreToolUse', matcher: 'Edit|Write|MultiEdit|NotebookEdit', script: GUARD, timeout: 10, why: 'a hand edit to a composed file is found only after the turn, as drift, and the next compose overwrites it',
|
|
136
137
|
crash: (cannot) => ({ systemMessage: `NINA: the edit guard could not start, so composed files can be edited in place — ${cannot}` }),
|
|
137
138
|
},
|
|
139
|
+
// Every tool, since what it watches is a subagent's every turn; a call outside a subagent returns at once.
|
|
140
|
+
{ event: 'PostToolUse', script: COST, timeout: 10, why: 'a run that keeps re-reading its own context is told what it cost only afterwards, by `stats`' },
|
|
138
141
|
];
|
|
139
142
|
|
|
140
143
|
/** Where a project's hooks can live: Claude Code reads both, so a hook kept in the local file counts. */
|