@ijfw/memory-server 1.4.3 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fixtures/truncation-corpus/_generate-corpus.js +367 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/snapshots/v-midO-1-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/snapshots/v-midO-2-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/snapshots/v-midO-3-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/snapshots/v-midO-4-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/snapshots/v-midO-5-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/snapshots/v-noEv-1-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/snapshots/v-noEv-2-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/snapshots/v-noEv-3-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/snapshots/v-noEv-4-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/snapshots/v-noEv-5-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/snapshots/v-errT-1-partial.json +11 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/snapshots/v-errT-3-partial.json +11 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/snapshots/v-errT-5-partial.json +11 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/target/.ijfw/state/workflow.json +1 -0
- package/package.json +1 -1
- package/src/active-extension-writer.js +144 -64
- package/src/api-client.js +43 -5
- package/src/audit-roster.js +80 -5
- package/src/blackboard.js +298 -6
- package/src/cli-run.js +33 -5
- package/src/codex-agents.js +96 -5
- package/src/cost/aggregator.js +39 -9
- package/src/cost/pricing.js +57 -0
- package/src/cost/readers/gemini.js +1 -1
- package/src/cross-audit-chunker.js +189 -0
- package/src/cross-dispatcher.js +124 -21
- package/src/cross-orchestrator-cli.js +550 -14
- package/src/cross-orchestrator.js +1171 -10
- package/src/cross-project-search.js +195 -9
- package/src/dashboard-client-planning.html +273 -0
- package/src/dashboard-client-waves.html +304 -0
- package/src/dashboard-client.html +17 -2
- package/src/dashboard-server.js +152 -0
- package/src/deploy-alerts.js +150 -0
- package/src/design/iframe-bridge.js +242 -0
- package/src/design-companion.js +144 -0
- package/src/dispatch/checkpoint-cli.js +97 -0
- package/src/dispatch/colon-syntax.js +81 -1
- package/src/dispatch/extension.js +27 -1
- package/src/dispatch/registry-cli.js +4 -1
- package/src/dispatch/wave-cli.js +323 -0
- package/src/dispatch/worktree-cli.js +40 -0
- package/src/dispatch-planner.js +97 -2
- package/src/dream/runner.mjs +47 -11
- package/src/dream/stage-runner.js +40 -0
- package/src/dream/state-file.js +102 -0
- package/src/extension-installer.js +70 -24
- package/src/extension-quota-tracker.js +4 -2
- package/src/extension-registry.js +289 -35
- package/src/feedback-detector.js +26 -0
- package/src/fs-lock.js +259 -7
- package/src/gate-result.js +95 -1
- package/src/hero-line.js +86 -5
- package/src/intent-router.js +35 -0
- package/src/lib/a11y-contract.js +117 -0
- package/src/lib/atomic-io.js +29 -8
- package/src/lib/cache-keepalive.js +150 -0
- package/src/lib/jsonl-rotation.js +104 -0
- package/src/lib/lighthouse-pillar.js +121 -0
- package/src/lib/llm-call.js +121 -0
- package/src/lib/playwright-baseline.js +205 -0
- package/src/lib/rekor-bridge.js +221 -0
- package/src/lib/repo-map.js +392 -0
- package/src/lib/shasum-verify.js +164 -0
- package/src/lib/sketches-gc.js +132 -0
- package/src/lib/tmp-suffix.js +62 -0
- package/src/lib/ui-review-runner.js +554 -0
- package/src/lib/uispec-drift.js +301 -0
- package/src/lib/uispec-intake.js +381 -0
- package/src/lib/worktree-guards.js +118 -0
- package/src/lib/worktree-recovery.js +100 -0
- package/src/memory/auto-linker.js +152 -0
- package/src/memory/benchmark.js +498 -0
- package/src/memory/dedup.js +126 -0
- package/src/memory/embedding-cache.js +136 -0
- package/src/memory/fact-extractor.js +168 -0
- package/src/memory/fts5.js +65 -1
- package/src/memory/migrations/004-bitemporal.js +91 -0
- package/src/memory/migrations/005-vector-cache.js +61 -0
- package/src/memory/migrations/006-obsidian-graph.js +46 -0
- package/src/memory/migrations/007-skill-telemetry.js +24 -0
- package/src/memory/migrations/008-write-provenance.js +41 -0
- package/src/memory/obsidian-parser.js +91 -0
- package/src/memory/query-dataview.js +86 -0
- package/src/memory/search.js +10 -0
- package/src/memory/temporal.js +529 -0
- package/src/memory/tokenize.js +10 -0
- package/src/memory-facts-handler.js +37 -0
- package/src/memory-feedback.js +260 -2
- package/src/model-refresh.js +292 -0
- package/src/observability/cost-anomaly.js +166 -0
- package/src/observability/evaluator-checkpoint-contract.js +117 -0
- package/src/observability/trace-id.js +163 -0
- package/src/orchestrator/agents-md-blackboard.js +152 -0
- package/src/orchestrator/checkpoint-contract.md +140 -0
- package/src/orchestrator/debug-trident.js +570 -0
- package/src/orchestrator/merge-block-aware.js +350 -0
- package/src/orchestrator/plan-checker.js +475 -0
- package/src/orchestrator/post-done-runner.js +249 -0
- package/src/orchestrator/review.js +136 -0
- package/src/orchestrator/runtime-loop.js +430 -0
- package/src/orchestrator/skill-telemetry-sink.js +29 -0
- package/src/orchestrator/skill-telemetry.js +37 -0
- package/src/orchestrator/state-events.js +459 -0
- package/src/orchestrator/state-sdk.js +1764 -0
- package/src/orchestrator/status-protocol.js +235 -0
- package/src/orchestrator/subagent-telemetry.js +452 -0
- package/src/orchestrator/termination.js +160 -0
- package/src/orchestrator/verification-gate.js +281 -0
- package/src/orchestrator/wave-state.js +564 -0
- package/src/orchestrator/worktree-provision.js +77 -0
- package/src/override-use-registry.js +111 -5
- package/src/receipts.js +36 -4
- package/src/recovery/checkpoint.js +56 -3
- package/src/recovery/code-fixer.js +656 -0
- package/src/recovery/truncation.js +317 -0
- package/src/redactor.js +75 -6
- package/src/runtime-mediator.js +15 -0
- package/src/sanitizer.js +10 -0
- package/src/search-hybrid.js +139 -0
- package/src/server.js +603 -59
- package/src/swarm/worktree.js +27 -4
- package/src/swarm-config.js +113 -12
- package/src/team/domain-templates/book.json +51 -0
- package/src/team/domain-templates/business.json +41 -0
- package/src/team/domain-templates/content.json +50 -0
- package/src/team/domain-templates/design.json +44 -0
- package/src/team/domain-templates/research.json +41 -0
- package/src/team/domain-templates/software.json +40 -0
- package/src/team/generator.js +278 -3
- package/src/team/modify.js +203 -0
- package/src/team/schemas.js +48 -0
- package/src/update-apply.js +19 -3
package/src/fs-lock.js
CHANGED
|
@@ -11,15 +11,37 @@
|
|
|
11
11
|
* (single retry after rm).
|
|
12
12
|
*
|
|
13
13
|
* Closes SEC-H-01 (cross-process race) from v1.4.3 cross-audit round 1.
|
|
14
|
+
*
|
|
15
|
+
* v1.5.0 T3 — heartbeat-refreshed locks. The fixed-30s stale window wrongly
|
|
16
|
+
* reclaimed locks held by *live* long-running verbs. `withFsLock` now accepts
|
|
17
|
+
* a `heartbeatMs` option: while `fn` runs, the holder refreshes `holder.json`
|
|
18
|
+
* (and the lock dir mtime) on that interval, so a concurrent caller's stale
|
|
19
|
+
* check sees a *recent* `acquired_at` and keeps waiting. A genuinely dead
|
|
20
|
+
* holder stops refreshing → its lock still ages past `staleMs` and becomes
|
|
21
|
+
* reclaimable. The heartbeat interval is always cleared on release (success
|
|
22
|
+
* or throw), so a leaked timer can never touch a recreated lock dir.
|
|
23
|
+
*
|
|
24
|
+
* v1.5.0 T3 also exports `canonicalLockOrder` — the STATE-SDK-CONTRACT §3
|
|
25
|
+
* lock-hierarchy sort. It is the single source of truth for the coarse-to-fine
|
|
26
|
+
* acquire-order so the state-SDK's `_withLocks` cannot deadlock.
|
|
14
27
|
*/
|
|
15
28
|
|
|
16
|
-
import {
|
|
17
|
-
|
|
29
|
+
import {
|
|
30
|
+
mkdir, writeFile, readFile, rm, stat, utimes,
|
|
31
|
+
} from 'node:fs/promises';
|
|
32
|
+
import { join, dirname, basename } from 'node:path';
|
|
18
33
|
|
|
19
34
|
const DEFAULT_ACQUIRE_TIMEOUT_MS = 5000;
|
|
20
35
|
const DEFAULT_STALE_MS = 30000;
|
|
21
36
|
const BACKOFF_START_MS = 25;
|
|
22
37
|
const BACKOFF_MAX_MS = 250;
|
|
38
|
+
/**
|
|
39
|
+
* Default heartbeat interval. The refresh cadence must be comfortably shorter
|
|
40
|
+
* than any `staleMs` a caller picks, so a live holder always renews the lock
|
|
41
|
+
* before a concurrent caller's stale check fires. Callers needing a smaller
|
|
42
|
+
* `staleMs` (the state-SDK uses ~10s) pass an explicit `heartbeatMs`.
|
|
43
|
+
*/
|
|
44
|
+
const DEFAULT_HEARTBEAT_MS = 5000;
|
|
23
45
|
|
|
24
46
|
export class FsLockBusyError extends Error {
|
|
25
47
|
constructor(lockPath, timeoutMs) {
|
|
@@ -88,9 +110,40 @@ async function tryAcquireOnce(lockPath) {
|
|
|
88
110
|
}
|
|
89
111
|
|
|
90
112
|
/**
|
|
91
|
-
*
|
|
113
|
+
* Refresh a held lock's freshness anchor. Rewrites `holder.json` with a fresh
|
|
114
|
+
* `acquired_at` and bumps the lock directory's own mtime — both are anchors the
|
|
115
|
+
* stale check consults, so refreshing both keeps the holder.json path AND the
|
|
116
|
+
* R12-M-01 mtime-fallback path in agreement. Best-effort: a transient failure
|
|
117
|
+
* (e.g. the dir is mid-release) is swallowed; the next tick retries.
|
|
118
|
+
*/
|
|
119
|
+
async function refreshHolder(lockPath, holder) {
|
|
120
|
+
const now = Date.now();
|
|
121
|
+
holder.acquired_at = now;
|
|
122
|
+
try {
|
|
123
|
+
await writeFile(
|
|
124
|
+
join(lockPath, 'holder.json'),
|
|
125
|
+
JSON.stringify(holder),
|
|
126
|
+
'utf8',
|
|
127
|
+
);
|
|
128
|
+
} catch {
|
|
129
|
+
// Lock dir may be mid-release. Harmless — next tick retries, or the
|
|
130
|
+
// interval is about to be cleared.
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const d = new Date(now);
|
|
134
|
+
await utimes(lockPath, d, d);
|
|
135
|
+
} catch {
|
|
136
|
+
// Same rationale as above.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* withFsLock(lockPath, fn, { staleMs, acquireTimeoutMs, heartbeatMs })
|
|
92
142
|
*
|
|
93
|
-
* See module docstring for contract details.
|
|
143
|
+
* See module docstring for contract details. `heartbeatMs` (v1.5.0 T3): while
|
|
144
|
+
* `fn` runs, refresh the lock's freshness anchor on this interval so a live
|
|
145
|
+
* long-running holder is never wrongly reclaimed as stale. Pass `0` to disable
|
|
146
|
+
* the heartbeat (legacy fixed-window behaviour). Default: 5000ms.
|
|
94
147
|
*/
|
|
95
148
|
export async function withFsLock(lockPath, fn, opts = {}) {
|
|
96
149
|
const staleMs =
|
|
@@ -99,6 +152,10 @@ export async function withFsLock(lockPath, fn, opts = {}) {
|
|
|
99
152
|
typeof opts.acquireTimeoutMs === 'number'
|
|
100
153
|
? opts.acquireTimeoutMs
|
|
101
154
|
: DEFAULT_ACQUIRE_TIMEOUT_MS;
|
|
155
|
+
const heartbeatMs =
|
|
156
|
+
typeof opts.heartbeatMs === 'number'
|
|
157
|
+
? opts.heartbeatMs
|
|
158
|
+
: DEFAULT_HEARTBEAT_MS;
|
|
102
159
|
|
|
103
160
|
// Ensure the lock's parent directory exists. `tryAcquireOnce` uses
|
|
104
161
|
// `mkdir(lockPath, { recursive: false })` which fails with ENOENT when any
|
|
@@ -119,12 +176,16 @@ export async function withFsLock(lockPath, fn, opts = {}) {
|
|
|
119
176
|
let staleRecoveryUsed = false;
|
|
120
177
|
let backoff = BACKOFF_START_MS;
|
|
121
178
|
|
|
179
|
+
// The holder object for THIS acquisition — the heartbeat refreshes it in
|
|
180
|
+
// place so its `acquired_at` stays current while `fn` runs.
|
|
181
|
+
let heldHolder = null;
|
|
182
|
+
|
|
122
183
|
// Acquire loop. We try mkdir; if EEXIST, decide between waiting and stale
|
|
123
184
|
// recovery; otherwise propagate the error.
|
|
124
185
|
// eslint-disable-next-line no-constant-condition
|
|
125
186
|
while (true) {
|
|
126
187
|
try {
|
|
127
|
-
await tryAcquireOnce(lockPath);
|
|
188
|
+
heldHolder = await tryAcquireOnce(lockPath);
|
|
128
189
|
break;
|
|
129
190
|
} catch (err) {
|
|
130
191
|
if (err && err.code !== 'EEXIST') {
|
|
@@ -161,7 +222,7 @@ export async function withFsLock(lockPath, fn, opts = {}) {
|
|
|
161
222
|
throw new FsLockStaleError(lockPath, rmErr);
|
|
162
223
|
}
|
|
163
224
|
try {
|
|
164
|
-
await tryAcquireOnce(lockPath);
|
|
225
|
+
heldHolder = await tryAcquireOnce(lockPath);
|
|
165
226
|
break;
|
|
166
227
|
} catch (retryErr) {
|
|
167
228
|
if (retryErr && retryErr.code === 'EEXIST') {
|
|
@@ -184,13 +245,33 @@ export async function withFsLock(lockPath, fn, opts = {}) {
|
|
|
184
245
|
}
|
|
185
246
|
}
|
|
186
247
|
|
|
187
|
-
// Lock acquired — run fn and ALWAYS release.
|
|
248
|
+
// Lock acquired — start the heartbeat, run fn, and ALWAYS clear + release.
|
|
249
|
+
//
|
|
250
|
+
// The heartbeat keeps a *live* long-running holder's lock fresh so a
|
|
251
|
+
// concurrent caller never wrongly stale-reclaims it. The interval is cleared
|
|
252
|
+
// in the `finally` before the lock dir is removed, so a leaked timer can
|
|
253
|
+
// never touch a recreated lock dir. `unref()` ensures the interval cannot
|
|
254
|
+
// keep the process alive on its own.
|
|
255
|
+
let heartbeat = null;
|
|
256
|
+
if (heartbeatMs > 0 && heldHolder) {
|
|
257
|
+
heartbeat = setInterval(() => {
|
|
258
|
+
// Fire-and-forget — refreshHolder swallows its own transient errors.
|
|
259
|
+
refreshHolder(lockPath, heldHolder);
|
|
260
|
+
}, heartbeatMs);
|
|
261
|
+
if (typeof heartbeat.unref === 'function') heartbeat.unref();
|
|
262
|
+
}
|
|
263
|
+
|
|
188
264
|
let fnResult;
|
|
189
265
|
let fnError;
|
|
190
266
|
try {
|
|
191
267
|
fnResult = await fn();
|
|
192
268
|
} catch (err) {
|
|
193
269
|
fnError = err;
|
|
270
|
+
} finally {
|
|
271
|
+
if (heartbeat) {
|
|
272
|
+
clearInterval(heartbeat);
|
|
273
|
+
heartbeat = null;
|
|
274
|
+
}
|
|
194
275
|
}
|
|
195
276
|
|
|
196
277
|
try {
|
|
@@ -203,3 +284,174 @@ export async function withFsLock(lockPath, fn, opts = {}) {
|
|
|
203
284
|
if (fnError !== undefined) throw fnError;
|
|
204
285
|
return fnResult;
|
|
205
286
|
}
|
|
287
|
+
|
|
288
|
+
// ===========================================================================
|
|
289
|
+
// v1.5.0 T3 — STATE-SDK-CONTRACT §3 canonical lock-hierarchy ordering.
|
|
290
|
+
//
|
|
291
|
+
// `canonicalLockOrder(targets)` sorts an arbitrary set of physical state-file
|
|
292
|
+
// paths into the exact coarse-to-fine acquire-order from §3 of
|
|
293
|
+
// `.planning/v150-gap-closure/STATE-SDK-CONTRACT.md`. A verb touching N files
|
|
294
|
+
// acquires its locks in this order and releases in reverse — because the order
|
|
295
|
+
// is total and deterministic, no two verbs can form a lock-ordering cycle, so
|
|
296
|
+
// the state-SDK is deadlock-free by construction.
|
|
297
|
+
//
|
|
298
|
+
// This lives in fs-lock.js (not state-sdk.js) so the ordering rule sits next
|
|
299
|
+
// to the lock primitive it governs — one module owns "how locks behave".
|
|
300
|
+
// ===========================================================================
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* §3 tier table. Each entry: a tier number (1-based, the §3 list position) and
|
|
304
|
+
* a `match(path)` predicate. The FIRST matching entry wins, so more-specific
|
|
305
|
+
* patterns are listed where ambiguity could arise (none currently overlap).
|
|
306
|
+
*
|
|
307
|
+
* `sub(path)` extracts the same-tier discriminator (`waveId` / `subId`) so
|
|
308
|
+
* multiple files at one tier sort deterministically by their natural ascending
|
|
309
|
+
* order — §3 "sub-orders them by the natural ascending sort of the
|
|
310
|
+
* discriminator". `null` when a tier has no discriminator.
|
|
311
|
+
*/
|
|
312
|
+
const LOCK_TIERS = [
|
|
313
|
+
// #1 — intent journal (always first)
|
|
314
|
+
{ tier: 1, match: (p) => /[\\/]\.ijfw[\\/]state[\\/]intent-journal\.jsonl$/.test(p) },
|
|
315
|
+
// #2 — workflow phase state
|
|
316
|
+
{ tier: 2, match: (p) => /[\\/]\.ijfw[\\/]state[\\/]workflow\.json$/.test(p) },
|
|
317
|
+
// #3 — wave index
|
|
318
|
+
{ tier: 3, match: (p) => /[\\/]\.ijfw[\\/]state[\\/]waves\.json$/.test(p) },
|
|
319
|
+
// #4 — per-wave STATE.md (sub-ordered by waveId)
|
|
320
|
+
{
|
|
321
|
+
tier: 4,
|
|
322
|
+
match: (p) => /[\\/]\.ijfw[\\/]wave-[^\\/]+[\\/]STATE\.md$/.test(p),
|
|
323
|
+
sub: (p) => {
|
|
324
|
+
const m = /[\\/]\.ijfw[\\/]wave-([^\\/]+)[\\/]STATE\.md$/.exec(p);
|
|
325
|
+
return m ? m[1] : null;
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
// #5 — per-subagent checkpoint (sub-ordered by subId, then waveId)
|
|
329
|
+
{
|
|
330
|
+
tier: 5,
|
|
331
|
+
match: (p) => /[\\/]\.ijfw[\\/]wave-[^\\/]+[\\/]subagent-[^\\/]+\.checkpoint\.json$/.test(p),
|
|
332
|
+
sub: (p) => {
|
|
333
|
+
const m = /[\\/]\.ijfw[\\/]wave-([^\\/]+)[\\/]subagent-([^\\/]+)\.checkpoint\.json$/.exec(p);
|
|
334
|
+
// Discriminator: "<subId> <waveId>" — subId is the primary key per
|
|
335
|
+
// §3, waveId breaks ties when one verb touches the same subId in two
|
|
336
|
+
// waves (no current verb does, but the order stays total).
|
|
337
|
+
return m ? `${m[2]} ${m[1]}` : null;
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
// #6 — generated roster
|
|
341
|
+
{ tier: 6, match: (p) => /[\\/]\.ijfw[\\/]team[\\/]workflow\.json$/.test(p) },
|
|
342
|
+
// #7 — decision / blocker append log
|
|
343
|
+
{ tier: 7, match: (p) => /[\\/]\.ijfw[\\/]blackboard[\\/]decisions\.jsonl$/.test(p) },
|
|
344
|
+
// #8 — AGENTS.md blackboard rollup
|
|
345
|
+
{ tier: 8, match: (p) => /(^|[\\/])AGENTS\.md$/.test(p) },
|
|
346
|
+
// #9 — Trident convergence telemetry
|
|
347
|
+
{ tier: 9, match: (p) => /[\\/]\.ijfw[\\/]telemetry[\\/]convergence\.json$/.test(p) },
|
|
348
|
+
// #10 — per-subagent event log (sub-ordered by subId, then waveId)
|
|
349
|
+
{
|
|
350
|
+
tier: 10,
|
|
351
|
+
match: (p) => /[\\/]\.ijfw[\\/]wave-[^\\/]+[\\/]events-[^\\/]+\.jsonl$/.test(p),
|
|
352
|
+
sub: (p) => {
|
|
353
|
+
const m = /[\\/]\.ijfw[\\/]wave-([^\\/]+)[\\/]events-([^\\/]+)\.jsonl$/.exec(p);
|
|
354
|
+
return m ? `${m[2]} ${m[1]}` : null;
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
// #11 — homedir active-extension state (always last — different fs root)
|
|
358
|
+
{ tier: 11, match: (p) => /[\\/]\.ijfw[\\/]state[\\/]active-extension\.json$/.test(p) },
|
|
359
|
+
];
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Natural ascending comparison for same-tier discriminators: numeric runs
|
|
363
|
+
* compare numerically (so `W2 < W10`), non-numeric runs compare
|
|
364
|
+
* lexicographically. Makes `wave-W1, wave-W2, wave-W10` sort in human order.
|
|
365
|
+
*/
|
|
366
|
+
function naturalCompare(a, b) {
|
|
367
|
+
if (a == null && b == null) return 0;
|
|
368
|
+
if (a == null) return -1;
|
|
369
|
+
if (b == null) return 1;
|
|
370
|
+
const ra = String(a).match(/\d+|\D+/g) || [];
|
|
371
|
+
const rb = String(b).match(/\d+|\D+/g) || [];
|
|
372
|
+
const n = Math.min(ra.length, rb.length);
|
|
373
|
+
for (let i = 0; i < n; i += 1) {
|
|
374
|
+
const sa = ra[i];
|
|
375
|
+
const sb = rb[i];
|
|
376
|
+
const na = /^\d+$/.test(sa);
|
|
377
|
+
const nb = /^\d+$/.test(sb);
|
|
378
|
+
if (na && nb) {
|
|
379
|
+
const d = Number(sa) - Number(sb);
|
|
380
|
+
if (d !== 0) return d < 0 ? -1 : 1;
|
|
381
|
+
} else if (sa !== sb) {
|
|
382
|
+
return sa < sb ? -1 : 1;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (ra.length !== rb.length) return ra.length < rb.length ? -1 : 1;
|
|
386
|
+
return 0;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Classify one path -> its §3 tier descriptor. Unknown paths land at tier 99. */
|
|
390
|
+
function classify(path) {
|
|
391
|
+
for (const t of LOCK_TIERS) {
|
|
392
|
+
if (t.match(path)) {
|
|
393
|
+
return { tier: t.tier, sub: t.sub ? t.sub(path) : null };
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// An unknown path is acquired AFTER every known tier — still deterministic
|
|
397
|
+
// (sorts by path string), so a future/typo path can never wedge the order.
|
|
398
|
+
return { tier: 99, sub: null };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* canonicalLockOrder(targets) — return `targets` sorted into the §3 canonical
|
|
403
|
+
* coarse-to-fine acquire-order, de-duplicated. The total order is:
|
|
404
|
+
*
|
|
405
|
+
* 1. tier number ascending (§3 list position #1 … #11)
|
|
406
|
+
* 2. within a tier, the natural ascending sort of the discriminator
|
|
407
|
+
* (`waveId` / `subId`) — §3 same-tier sub-ordering
|
|
408
|
+
* 3. final tie-break on the full path string (keeps the order total even
|
|
409
|
+
* for paths a tier cannot otherwise distinguish)
|
|
410
|
+
*
|
|
411
|
+
* Pure + idempotent: `canonicalLockOrder(canonicalLockOrder(x))` deep-equals
|
|
412
|
+
* `canonicalLockOrder(x)`. Callers are NOT trusted to pre-sort — the state-SDK
|
|
413
|
+
* always routes its lock-target list through here (defense in depth).
|
|
414
|
+
*
|
|
415
|
+
* @param {string[]} targets physical state-file paths (any order, may repeat)
|
|
416
|
+
* @returns {string[]} the §3-ordered, de-duplicated path list
|
|
417
|
+
*/
|
|
418
|
+
export function canonicalLockOrder(targets) {
|
|
419
|
+
if (!Array.isArray(targets)) {
|
|
420
|
+
throw new TypeError('canonicalLockOrder: targets must be a string[]');
|
|
421
|
+
}
|
|
422
|
+
const seen = new Set();
|
|
423
|
+
const decorated = [];
|
|
424
|
+
for (const path of targets) {
|
|
425
|
+
if (typeof path !== 'string' || path.length === 0) {
|
|
426
|
+
throw new TypeError(
|
|
427
|
+
`canonicalLockOrder: every target must be a non-empty string (got ${JSON.stringify(path)})`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
if (seen.has(path)) continue;
|
|
431
|
+
seen.add(path);
|
|
432
|
+
decorated.push({ path, ...classify(path) });
|
|
433
|
+
}
|
|
434
|
+
decorated.sort((a, b) => {
|
|
435
|
+
if (a.tier !== b.tier) return a.tier - b.tier;
|
|
436
|
+
const s = naturalCompare(a.sub, b.sub);
|
|
437
|
+
if (s !== 0) return s;
|
|
438
|
+
if (a.path === b.path) return 0;
|
|
439
|
+
return a.path < b.path ? -1 : 1;
|
|
440
|
+
});
|
|
441
|
+
return decorated.map((d) => d.path);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* lockPathFor(targetPath) — the dotfile-sibling lock path for a target, per
|
|
446
|
+
* STATE-SDK-CONTRACT §1 ("Lock files are the dotfile sibling of each target",
|
|
447
|
+
* e.g. `.ijfw/state/workflow.json` -> `.ijfw/state/.workflow.json.lock`).
|
|
448
|
+
*
|
|
449
|
+
* @param {string} targetPath a physical state file path
|
|
450
|
+
* @returns {string} the lock directory path to pass to `withFsLock`
|
|
451
|
+
*/
|
|
452
|
+
export function lockPathFor(targetPath) {
|
|
453
|
+
if (typeof targetPath !== 'string' || targetPath.length === 0) {
|
|
454
|
+
throw new TypeError('lockPathFor: targetPath must be a non-empty string');
|
|
455
|
+
}
|
|
456
|
+
return join(dirname(targetPath), `.${basename(targetPath)}.lock`);
|
|
457
|
+
}
|
package/src/gate-result.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* - Receipt writes MUST NOT throw — the gate's hot path is the priority.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
17
|
+
import { mkdir, writeFile, readdir, stat, unlink, appendFile } from 'node:fs/promises';
|
|
18
18
|
import { basename, dirname, join } from 'node:path';
|
|
19
19
|
|
|
20
20
|
import {
|
|
@@ -155,6 +155,18 @@ export async function makeReceipt(gateResult, opts = {}) {
|
|
|
155
155
|
await mkdir(dirname(receiptPath), { recursive: true });
|
|
156
156
|
const body = JSON.stringify(gateResult, null, 2) + '\n';
|
|
157
157
|
await writeFile(receiptPath, body, 'utf8');
|
|
158
|
+
|
|
159
|
+
// v1.5.0 audit MED #9 (memory-engine.md F-PRF-2): bounded LRU on the
|
|
160
|
+
// gate-receipts dir. Without eviction this directory grew unbounded
|
|
161
|
+
// (one file per gate run forever); on long-lived projects this hit
|
|
162
|
+
// hundreds of MB and slowed the memory-feedback scan in
|
|
163
|
+
// ijfw_memory_prelude. We keep the newest N=1000 *.json files and
|
|
164
|
+
// append the rest to .archive.jsonl (one JSON line per old receipt),
|
|
165
|
+
// then unlink the originals. Atomic-ish: we write the archive line
|
|
166
|
+
// before unlinking so a crash mid-eviction leaves the data preserved
|
|
167
|
+
// in the archive *and* the receipt -- worst case is a single dup
|
|
168
|
+
// entry in .archive.jsonl, never data loss.
|
|
169
|
+
await evictOldReceipts(dirname(receiptPath));
|
|
158
170
|
} catch (err) {
|
|
159
171
|
// Fire-and-forget: log and move on. The gate hot path must not fail.
|
|
160
172
|
const msg = err && err.message ? err.message : String(err);
|
|
@@ -174,6 +186,88 @@ export async function makeReceipt(gateResult, opts = {}) {
|
|
|
174
186
|
// filesystem call.
|
|
175
187
|
const RECEIPT_GATE_ID_PATTERN = /^[a-z][a-z0-9-]+$/;
|
|
176
188
|
|
|
189
|
+
// v1.5.0 audit MED #9 -- bounded LRU constants. Pulled out so tests can
|
|
190
|
+
// dial the cap down to keep the test fixtures cheap.
|
|
191
|
+
export const RECEIPTS_KEEP = 1000;
|
|
192
|
+
export const RECEIPTS_ARCHIVE = '.archive.jsonl';
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* evictOldReceipts(dir, opts) -- bounded LRU on the gate-receipts directory.
|
|
196
|
+
*
|
|
197
|
+
* Keeps the newest `keep` *.json files; older files are appended to
|
|
198
|
+
* `.archive.jsonl` as JSONL and then unlinked. Never throws -- the gate's
|
|
199
|
+
* hot path is the priority and a logging-channel directory should not
|
|
200
|
+
* propagate I/O errors into the gate result.
|
|
201
|
+
*
|
|
202
|
+
* @param {string} dir
|
|
203
|
+
* @param {{keep?: number}} [opts]
|
|
204
|
+
* @returns {Promise<{evicted: number}>}
|
|
205
|
+
*/
|
|
206
|
+
export async function evictOldReceipts(dir, opts = {}) {
|
|
207
|
+
const keep = Number.isFinite(opts.keep) && opts.keep > 0 ? opts.keep | 0 : RECEIPTS_KEEP;
|
|
208
|
+
try {
|
|
209
|
+
let entries;
|
|
210
|
+
try {
|
|
211
|
+
entries = await readdir(dir);
|
|
212
|
+
} catch {
|
|
213
|
+
return { evicted: 0 };
|
|
214
|
+
}
|
|
215
|
+
const jsonFiles = entries.filter((f) => f.endsWith('.json'));
|
|
216
|
+
if (jsonFiles.length <= keep) return { evicted: 0 };
|
|
217
|
+
|
|
218
|
+
// Stat each candidate; sort by mtime descending (newest first).
|
|
219
|
+
const stamped = [];
|
|
220
|
+
for (const f of jsonFiles) {
|
|
221
|
+
const full = join(dir, f);
|
|
222
|
+
try {
|
|
223
|
+
const s = await stat(full);
|
|
224
|
+
stamped.push({ full, name: f, mtimeMs: s.mtimeMs });
|
|
225
|
+
} catch {
|
|
226
|
+
// Cannot stat -- treat as oldest so it gets evicted/cleaned up.
|
|
227
|
+
stamped.push({ full, name: f, mtimeMs: 0 });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
stamped.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
231
|
+
const toEvict = stamped.slice(keep);
|
|
232
|
+
if (toEvict.length === 0) return { evicted: 0 };
|
|
233
|
+
|
|
234
|
+
const archivePath = join(dir, RECEIPTS_ARCHIVE);
|
|
235
|
+
let evicted = 0;
|
|
236
|
+
for (const victim of toEvict) {
|
|
237
|
+
try {
|
|
238
|
+
// Read + append to archive *before* unlinking so a mid-eviction crash
|
|
239
|
+
// leaves the receipt either intact OR in the archive (never lost).
|
|
240
|
+
let body;
|
|
241
|
+
try {
|
|
242
|
+
const { readFile } = await import('node:fs/promises');
|
|
243
|
+
body = await readFile(victim.full, 'utf8');
|
|
244
|
+
} catch {
|
|
245
|
+
// Already gone -- skip without inflating the evicted counter.
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
// Normalize: archive entries are one JSON object per line. The
|
|
249
|
+
// receipt body is pretty-printed JSON -- collapse via parse/stringify.
|
|
250
|
+
let archiveLine;
|
|
251
|
+
try {
|
|
252
|
+
archiveLine = JSON.stringify(JSON.parse(body)) + '\n';
|
|
253
|
+
} catch {
|
|
254
|
+
// Malformed body -- preserve raw bytes inside a wrapper line so
|
|
255
|
+
// operators can still inspect what was there.
|
|
256
|
+
archiveLine = JSON.stringify({ raw: body, evicted_from: victim.name }) + '\n';
|
|
257
|
+
}
|
|
258
|
+
await appendFile(archivePath, archiveLine, 'utf8');
|
|
259
|
+
await unlink(victim.full);
|
|
260
|
+
evicted++;
|
|
261
|
+
} catch {
|
|
262
|
+
// Per-file failures don't abort the eviction -- next call retries.
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return { evicted };
|
|
266
|
+
} catch {
|
|
267
|
+
return { evicted: 0 };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
177
271
|
async function resolveProjectType(projectRoot) {
|
|
178
272
|
try {
|
|
179
273
|
const root = typeof projectRoot === 'string' && projectRoot.length > 0
|
package/src/hero-line.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// Codex U1 caveat: delta is NEVER fabricated. If real data is insufficient,
|
|
3
3
|
// the delta suffix is omitted entirely.
|
|
4
4
|
|
|
5
|
+
import { getPricing, getPricesTable } from './cost/pricing.js';
|
|
6
|
+
|
|
5
7
|
// Format duration in whole seconds (or ms if <1000ms total).
|
|
6
8
|
function fmtDuration(ms) {
|
|
7
9
|
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
@@ -29,8 +31,85 @@ function countFindings(f) {
|
|
|
29
31
|
return { total: consensus + contested + unique, consensus };
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
// Anthropic cache-read savings rate: full input $3/M,
|
|
33
|
-
|
|
34
|
+
// Anthropic cache-read savings rate FALLBACK (Sonnet): full input $3/M,
|
|
35
|
+
// cache-read $0.30/M -> $2.70/M saved. Used when a receipt has no model id
|
|
36
|
+
// (or its model is unknown). Per-receipt rates come from cost/pricing.js so
|
|
37
|
+
// Opus/Haiku users see the correct dollar figure.
|
|
38
|
+
const SONNET_FALLBACK_SAVINGS_PER_TOKEN = 2.70 / 1_000_000;
|
|
39
|
+
|
|
40
|
+
// Known-model detector: mirrors pricing.js's match-then-fuzzy-then-family
|
|
41
|
+
// logic but answers a yes/no question instead of returning a price entry.
|
|
42
|
+
// We need this because getPricing() silently falls back to Sonnet for unknown
|
|
43
|
+
// ids, so we cannot tell "Opus matched" from "garbage -> Sonnet fallback" by
|
|
44
|
+
// looking at the returned rate alone.
|
|
45
|
+
function isKnownModel(modelId) {
|
|
46
|
+
if (!modelId || typeof modelId !== 'string') return false;
|
|
47
|
+
let table;
|
|
48
|
+
try {
|
|
49
|
+
table = getPricesTable()?.models || {};
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const id = modelId.toLowerCase().trim();
|
|
54
|
+
if (table[id] || table[modelId]) return true;
|
|
55
|
+
for (const key of Object.keys(table)) {
|
|
56
|
+
const k = key.toLowerCase();
|
|
57
|
+
if (k.startsWith(id) || id.startsWith(k)) return true;
|
|
58
|
+
}
|
|
59
|
+
// Family prefixes (must mirror pricing.js fallbacks table).
|
|
60
|
+
const familyPrefixes = [
|
|
61
|
+
'claude-opus-4', 'claude-sonnet-4', 'claude-haiku-4',
|
|
62
|
+
'claude-3-5-sonnet', 'claude-3-5-haiku', 'claude-3-opus',
|
|
63
|
+
'gpt-5', 'gpt-4o', 'o3', 'o4', 'gemini-2', 'gemini-1.5',
|
|
64
|
+
];
|
|
65
|
+
for (const prefix of familyPrefixes) {
|
|
66
|
+
if (id.includes(prefix)) return true;
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Compute per-token cache-read savings for a given model id by consulting
|
|
72
|
+
// the vendored pricing table. Returns { perToken, isFallback, model }.
|
|
73
|
+
// Falls back to Sonnet rate when the model is missing/unknown.
|
|
74
|
+
function cacheSavingsForModel(modelId) {
|
|
75
|
+
if (!modelId || typeof modelId !== 'string') {
|
|
76
|
+
return { perToken: SONNET_FALLBACK_SAVINGS_PER_TOKEN, isFallback: true, model: modelId || null };
|
|
77
|
+
}
|
|
78
|
+
if (!isKnownModel(modelId)) {
|
|
79
|
+
return { perToken: SONNET_FALLBACK_SAVINGS_PER_TOKEN, isFallback: true, model: modelId };
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const p = getPricing(modelId);
|
|
83
|
+
const perToken = Math.max(0, (p?.in || 0) - (p?.cache_read || 0));
|
|
84
|
+
if (perToken > 0) {
|
|
85
|
+
return { perToken, isFallback: false, model: modelId };
|
|
86
|
+
}
|
|
87
|
+
return { perToken: SONNET_FALLBACK_SAVINGS_PER_TOKEN, isFallback: true, model: modelId };
|
|
88
|
+
} catch {
|
|
89
|
+
return { perToken: SONNET_FALLBACK_SAVINGS_PER_TOKEN, isFallback: true, model: modelId };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// One-time-per-session stderr advisory for unknown-model fallbacks.
|
|
94
|
+
// Reset only by reloading the module, which matches "per session" semantics
|
|
95
|
+
// for the CLI entrypoint and MCP server.
|
|
96
|
+
let _unknownModelWarned = false;
|
|
97
|
+
export function _resetUnknownModelWarningForTests() {
|
|
98
|
+
_unknownModelWarned = false;
|
|
99
|
+
}
|
|
100
|
+
function warnUnknownModelOnce(modelId) {
|
|
101
|
+
if (_unknownModelWarned) return;
|
|
102
|
+
_unknownModelWarned = true;
|
|
103
|
+
try {
|
|
104
|
+
const label = modelId ? `"${modelId}"` : '(missing)';
|
|
105
|
+
process.stderr.write(
|
|
106
|
+
`[ijfw hero-line] unknown model ${label} on receipt -- falling back to Sonnet cache-savings rate. ` +
|
|
107
|
+
`Set receipt.model to a known id for accurate dollar figures.\n`
|
|
108
|
+
);
|
|
109
|
+
} catch {
|
|
110
|
+
// stderr write failures are non-fatal.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
34
113
|
|
|
35
114
|
// renderHeroLine(receipts, sessions?)
|
|
36
115
|
// receipts -- array of cross-runs.jsonl records
|
|
@@ -53,7 +132,7 @@ export function renderHeroLine(receipts, sessions = []) {
|
|
|
53
132
|
let totalConsensus = 0;
|
|
54
133
|
let receiptsInputTokens = 0;
|
|
55
134
|
let hasReceiptsTokens = true;
|
|
56
|
-
let
|
|
135
|
+
let totalCacheSavings = 0; // dollars, computed per-receipt using its model
|
|
57
136
|
|
|
58
137
|
for (const r of receipts) {
|
|
59
138
|
if (Array.isArray(r.auditors)) {
|
|
@@ -72,7 +151,9 @@ export function renderHeroLine(receipts, sessions = []) {
|
|
|
72
151
|
}
|
|
73
152
|
const crt = r.cache_stats?.cache_read_input_tokens;
|
|
74
153
|
if (typeof crt === 'number' && crt > 0) {
|
|
75
|
-
|
|
154
|
+
const { perToken, isFallback } = cacheSavingsForModel(r.model);
|
|
155
|
+
if (isFallback) warnUnknownModelOnce(r.model);
|
|
156
|
+
totalCacheSavings += crt * perToken;
|
|
76
157
|
}
|
|
77
158
|
}
|
|
78
159
|
|
|
@@ -81,7 +162,7 @@ export function renderHeroLine(receipts, sessions = []) {
|
|
|
81
162
|
|
|
82
163
|
// Cache savings suffix (10D.4): append only when cache reads produced a
|
|
83
164
|
// visible saving (>= $0.01). A sub-cent figure reads as anti-value.
|
|
84
|
-
const rawSaved =
|
|
165
|
+
const rawSaved = totalCacheSavings;
|
|
85
166
|
const cacheSuffix = rawSaved >= 0.01
|
|
86
167
|
? ` (prompt cache hit -- ~$${rawSaved.toFixed(2)} saved)`
|
|
87
168
|
: '';
|
package/src/intent-router.js
CHANGED
|
@@ -179,6 +179,41 @@ function adaptProjectScaleNudge(prompt) {
|
|
|
179
179
|
return `This sounds like a project. Want me to ${verb} with you? I'll ask a few questions, do some research, and come back with recommendations.`;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* detectIntent — deterministic keyword → skill router.
|
|
184
|
+
*
|
|
185
|
+
* Resolution order (v1.5.0 audit-LOW-work-L6, documented for op-clarity):
|
|
186
|
+
*
|
|
187
|
+
* 1. **Priority (DESC).** Each intent entry declares a numeric `priority`.
|
|
188
|
+
* Higher numbers beat lower numbers. Reserved bands:
|
|
189
|
+
* - 10 : cross-* analytic intents (cross-research / cross-critique /
|
|
190
|
+
* cross-audit). These are explicit operator commands and should
|
|
191
|
+
* override any prose-pattern match.
|
|
192
|
+
* - 8 : brainstorm (primary workflow entry point).
|
|
193
|
+
* - 7 : project-scale (length-based fallback for brainstorm).
|
|
194
|
+
* - 5 : ship / review / remember / recall / handoff / mode (default
|
|
195
|
+
* band for everyday skills).
|
|
196
|
+
* - 1 : critique (broad-pattern; intentionally lowest so it never
|
|
197
|
+
* steals a more specific intent).
|
|
198
|
+
* Adding a new intent? Pick the band, don't invent a new one.
|
|
199
|
+
*
|
|
200
|
+
* 2. **Match length (DESC).** If two intents tie on priority, the one
|
|
201
|
+
* whose regex matched a longer substring wins. Rationale: longer match
|
|
202
|
+
* = more specific signal. (Skills that use `check()` instead of regex
|
|
203
|
+
* patterns report matchLen=0 and lose this tiebreak by design — their
|
|
204
|
+
* `check()` already encodes the specificity.)
|
|
205
|
+
*
|
|
206
|
+
* 3. **Declaration order (ASC).** Final stable tiebreak: the intent listed
|
|
207
|
+
* earlier in the INTENTS array wins. This makes the resolution fully
|
|
208
|
+
* deterministic — no Map/Set ordering surprises across Node versions.
|
|
209
|
+
*
|
|
210
|
+
* Bypass: a prompt with a leading `*` or containing `ijfw off` returns null
|
|
211
|
+
* without entering the resolver. This is the documented escape hatch for
|
|
212
|
+
* users who want to suppress all routing for one prompt.
|
|
213
|
+
*
|
|
214
|
+
* @param {string} prompt
|
|
215
|
+
* @returns {{intent: string, skill: string, nudge: string} | null}
|
|
216
|
+
*/
|
|
182
217
|
export function detectIntent(prompt) {
|
|
183
218
|
if (typeof prompt !== 'string' || !prompt) return null;
|
|
184
219
|
// Skip if user explicitly bypasses (leading * or `ijfw off`).
|