@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/blackboard.js
CHANGED
|
@@ -4,12 +4,28 @@
|
|
|
4
4
|
// small and dependency-free: tasks/claims are atomic JSON, notes are append-only
|
|
5
5
|
// JSONL, and handoff is plain markdown.
|
|
6
6
|
|
|
7
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
8
8
|
import { join, resolve } from 'node:path';
|
|
9
9
|
import { writeAtomic, readSafe, withLock } from './lib/atomic-io.js';
|
|
10
|
+
import { rotateJsonlIfNeeded } from './lib/jsonl-rotation.js';
|
|
10
11
|
|
|
11
12
|
export const BLACKBOARD_VERSION = 1;
|
|
12
13
|
|
|
14
|
+
// F-REL-1 (H5.3): default claim TTL = 30 minutes. Subagents that go silent
|
|
15
|
+
// (the wayland 5/8-subagent failure mode) leave their claims forever
|
|
16
|
+
// without this. Configurable via the `ttlMs` option on evictOrphanedClaims
|
|
17
|
+
// and via the `--ttl-min N` CLI flag on `ijfw swarm evict-orphans`.
|
|
18
|
+
export const DEFAULT_CLAIM_TTL_MS = 30 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
// v1.5.0 audit-LOW-teams-#16: hard cap on tasks.json + claims.json
|
|
21
|
+
// serialized size. A runaway producer (bug or attacker) could otherwise
|
|
22
|
+
// grow either file unboundedly and starve the project's disk + slow every
|
|
23
|
+
// read of the cache to a crawl. 4MB is comfortably above any legitimate
|
|
24
|
+
// swarm workload (thousands of tasks) but well below "fill up the disk"
|
|
25
|
+
// territory; refusing the write here keeps the previous on-disk state
|
|
26
|
+
// intact (atomic writes only swap on success, so the old file survives).
|
|
27
|
+
export const MAX_BB_FILE_BYTES = 4_000_000;
|
|
28
|
+
|
|
13
29
|
export function blackboardPaths(projectRoot = process.cwd()) {
|
|
14
30
|
const root = resolve(projectRoot);
|
|
15
31
|
const dir = join(root, '.ijfw', 'blackboard');
|
|
@@ -60,7 +76,21 @@ function readJson(path, fallback, validator) {
|
|
|
60
76
|
|
|
61
77
|
function writeJson(path, data) {
|
|
62
78
|
data.updated_at = nowIso();
|
|
63
|
-
|
|
79
|
+
const serialized = `${JSON.stringify(data, null, 2)}\n`;
|
|
80
|
+
// v1.5.0 audit-LOW-teams-#16: enforce the size cap on the write path
|
|
81
|
+
// only -- existing readers (readBlackboard / pathsOverlap / etc.) are
|
|
82
|
+
// unaffected so a legacy oversized file remains readable. Throwing here
|
|
83
|
+
// preserves the previous on-disk state because writeAtomic only swaps
|
|
84
|
+
// after a successful tmp-write.
|
|
85
|
+
const bytes = Buffer.byteLength(serialized, 'utf8');
|
|
86
|
+
if (bytes > MAX_BB_FILE_BYTES) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`blackboard: refusing to write ${path} -- serialized size ${bytes} bytes ` +
|
|
89
|
+
`exceeds cap ${MAX_BB_FILE_BYTES} bytes (audit-LOW-teams-#16). Trim ` +
|
|
90
|
+
`tasks/claims (e.g. archive completed tasks) before retrying.`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return writeAtomic(path, serialized, { mode: 0o600 });
|
|
64
94
|
}
|
|
65
95
|
|
|
66
96
|
function readJsonl(path, limit = 5) {
|
|
@@ -76,6 +106,10 @@ function readJsonl(path, limit = 5) {
|
|
|
76
106
|
}
|
|
77
107
|
|
|
78
108
|
function appendJsonlUnlocked(path, entry) {
|
|
109
|
+
// F-PRF-1 (audit-MED-teams-#10): rotate large JSONL files in place before
|
|
110
|
+
// appending. The rotator is a no-op when the file is under the 4MB
|
|
111
|
+
// threshold, so this stays a hot-path-friendly stat() in the common case.
|
|
112
|
+
try { rotateJsonlIfNeeded(path); } catch { /* rotation is best-effort */ }
|
|
79
113
|
appendFileSync(path, `${JSON.stringify(entry)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
80
114
|
return entry;
|
|
81
115
|
}
|
|
@@ -110,10 +144,53 @@ export function initBlackboard(projectRoot = process.cwd()) {
|
|
|
110
144
|
return { ok: true, dir: paths.dir };
|
|
111
145
|
}
|
|
112
146
|
|
|
147
|
+
// F-SPD-2 (audit-MED-teams-#9): mtime cache for readBlackboard. Re-parsing
|
|
148
|
+
// tasks.json + claims.json on every status/listSwarmTasks call shows up in
|
|
149
|
+
// hot-path traces (planner + dispatcher both call this). The cache is keyed
|
|
150
|
+
// on the resolved project dir and remembers the mtimeMs of both JSON files.
|
|
151
|
+
// On a hit we return the previously-parsed JSON shape; on miss we re-parse
|
|
152
|
+
// and refresh the cache. JSONL recent-tails are NOT cached -- they are
|
|
153
|
+
// append-only and the LRU is intentionally narrow.
|
|
154
|
+
const BLACKBOARD_READ_CACHE = new Map();
|
|
155
|
+
const BLACKBOARD_READ_CACHE_MAX = 32;
|
|
156
|
+
|
|
157
|
+
function blackboardFileMtime(path) {
|
|
158
|
+
try {
|
|
159
|
+
return statSync(path).mtimeMs;
|
|
160
|
+
} catch {
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function getCachedJson(cacheKey, path, mtime, fallback, validator) {
|
|
166
|
+
const cached = BLACKBOARD_READ_CACHE.get(cacheKey);
|
|
167
|
+
if (cached && cached.path === path && cached.mtime === mtime && mtime > 0) {
|
|
168
|
+
return cached.value;
|
|
169
|
+
}
|
|
170
|
+
const value = readJson(path, fallback, validator);
|
|
171
|
+
BLACKBOARD_READ_CACHE.set(cacheKey, { path, mtime, value });
|
|
172
|
+
// Lightweight LRU eviction: drop oldest entry when over cap.
|
|
173
|
+
if (BLACKBOARD_READ_CACHE.size > BLACKBOARD_READ_CACHE_MAX) {
|
|
174
|
+
const firstKey = BLACKBOARD_READ_CACHE.keys().next().value;
|
|
175
|
+
if (firstKey !== undefined) BLACKBOARD_READ_CACHE.delete(firstKey);
|
|
176
|
+
}
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Exposed for tests + cache invalidation hooks. Clears all memoised entries.
|
|
181
|
+
export function _resetBlackboardReadCache() {
|
|
182
|
+
BLACKBOARD_READ_CACHE.clear();
|
|
183
|
+
}
|
|
184
|
+
|
|
113
185
|
export function readBlackboard(projectRoot = process.cwd()) {
|
|
114
186
|
const paths = blackboardPaths(projectRoot);
|
|
115
|
-
|
|
116
|
-
|
|
187
|
+
// F-SPD-2: mtime-keyed memo. When tasks.json + claims.json are unchanged
|
|
188
|
+
// we skip JSON.parse entirely. mtime===0 forces a miss so transient stat
|
|
189
|
+
// failures degrade to the un-cached path safely.
|
|
190
|
+
const tasksMtime = blackboardFileMtime(paths.tasks);
|
|
191
|
+
const claimsMtime = blackboardFileMtime(paths.claims);
|
|
192
|
+
const tasks = getCachedJson(`${paths.root}::tasks`, paths.tasks, tasksMtime, defaultTasks, validTasks);
|
|
193
|
+
const claims = getCachedJson(`${paths.root}::claims`, paths.claims, claimsMtime, defaultClaims, validClaims);
|
|
117
194
|
return {
|
|
118
195
|
paths,
|
|
119
196
|
tasks,
|
|
@@ -207,6 +284,36 @@ function commonPrefixBeforeGlob(pattern) {
|
|
|
207
284
|
return idx === -1 ? pattern : pattern.slice(0, idx);
|
|
208
285
|
}
|
|
209
286
|
|
|
287
|
+
// v1.5.0 N4.obs M7: explicit path-segment overlap detection.
|
|
288
|
+
//
|
|
289
|
+
// The old prefix check was `right.startsWith(lp)` which falsely overlapped
|
|
290
|
+
// e.g. `src` with `srcfoo`. Real path containment requires either an exact
|
|
291
|
+
// match OR a `/` separator immediately after the shorter prefix (so `src/`
|
|
292
|
+
// is the prefix of `src/foo`, but `src` does NOT contain `srcfoo`).
|
|
293
|
+
//
|
|
294
|
+
// `commonPrefixBeforeGlob` is preserved for glob handling -- it returns the
|
|
295
|
+
// literal head of a glob pattern (`src/*.js` -> `src/`). When that head
|
|
296
|
+
// already ends with `/`, we compare directly; when it doesn't (no glob in
|
|
297
|
+
// the pattern at all), we require a trailing-slash match below.
|
|
298
|
+
//
|
|
299
|
+
// Same-string comparison short-circuits at the top, so `src` vs `src`
|
|
300
|
+
// remains overlap-true.
|
|
301
|
+
function segmentOverlap(prefix, candidate) {
|
|
302
|
+
if (!prefix || !candidate) return false;
|
|
303
|
+
if (prefix === candidate) return true;
|
|
304
|
+
// Treat the prefix as a directory prefix: candidate must start with
|
|
305
|
+
// `prefix` AND the next character must be `/`. This rejects the
|
|
306
|
+
// `srcfoo`-vs-`src` false positive.
|
|
307
|
+
if (prefix.endsWith('/')) {
|
|
308
|
+
// Glob-derived prefix already includes the separator; plain prefix match
|
|
309
|
+
// is the right semantics.
|
|
310
|
+
return candidate === prefix.slice(0, -1) || candidate.startsWith(prefix);
|
|
311
|
+
}
|
|
312
|
+
return candidate.length > prefix.length
|
|
313
|
+
&& candidate.startsWith(prefix)
|
|
314
|
+
&& candidate.charAt(prefix.length) === '/';
|
|
315
|
+
}
|
|
316
|
+
|
|
210
317
|
function pathsOverlap(a, b) {
|
|
211
318
|
if (!a.length || !b.length) return false;
|
|
212
319
|
for (const left of a) {
|
|
@@ -214,8 +321,8 @@ function pathsOverlap(a, b) {
|
|
|
214
321
|
if (left === right) return true;
|
|
215
322
|
const lp = commonPrefixBeforeGlob(left);
|
|
216
323
|
const rp = commonPrefixBeforeGlob(right);
|
|
217
|
-
if (lp
|
|
218
|
-
if (rp
|
|
324
|
+
if (segmentOverlap(lp, right)) return true;
|
|
325
|
+
if (segmentOverlap(rp, left)) return true;
|
|
219
326
|
}
|
|
220
327
|
}
|
|
221
328
|
return false;
|
|
@@ -236,6 +343,9 @@ export function claimArtifact(projectRoot, input) {
|
|
|
236
343
|
const current = readJson(paths.claims, defaultClaims, validClaims).data;
|
|
237
344
|
const artifactId = String(input.artifact_id || input.artifact || '').trim();
|
|
238
345
|
const agent = String(input.agent || input.owner || '').trim();
|
|
346
|
+
const ttlMs = Number.isFinite(input.ttlMs) && input.ttlMs > 0
|
|
347
|
+
? Math.floor(input.ttlMs)
|
|
348
|
+
: DEFAULT_CLAIM_TTL_MS;
|
|
239
349
|
const next = {
|
|
240
350
|
id: input.id || `${artifactId}:${agent}`,
|
|
241
351
|
artifact_id: artifactId,
|
|
@@ -243,6 +353,13 @@ export function claimArtifact(projectRoot, input) {
|
|
|
243
353
|
paths: normalizePaths(input.paths),
|
|
244
354
|
status: 'active',
|
|
245
355
|
claimed_at: nowIso(),
|
|
356
|
+
// F-REL-1: TTL is stored on the claim so per-claim overrides survive
|
|
357
|
+
// a config reload and the evictor doesn't need the original config.
|
|
358
|
+
ttl_ms: ttlMs,
|
|
359
|
+
// heartbeat_at is OPTIONAL -- subagents that don't ping fall back to
|
|
360
|
+
// claimed_at as the freshness anchor. Initialised null so the field
|
|
361
|
+
// always exists in the JSON shape (no schema migration needed).
|
|
362
|
+
heartbeat_at: null,
|
|
246
363
|
note: input.note ? String(input.note) : undefined,
|
|
247
364
|
};
|
|
248
365
|
if (!next.artifact_id) return { ok: false, error: 'artifact-required' };
|
|
@@ -265,6 +382,90 @@ export function claimArtifact(projectRoot, input) {
|
|
|
265
382
|
}).result ?? { ok: false, error: 'locked' };
|
|
266
383
|
}
|
|
267
384
|
|
|
385
|
+
/**
|
|
386
|
+
* v1.5.0 audit-LOW-teams-#17: bulk-claim API.
|
|
387
|
+
*
|
|
388
|
+
* Acquire claims for N artifacts under ONE lock + ONE writeJson, instead of
|
|
389
|
+
* N round-trips through `claimArtifact`. The dispatcher fanned-out batch case
|
|
390
|
+
* (e.g. wave fan-out reserves 10+ artifacts at once) previously hit the lock
|
|
391
|
+
* 10+ times with serialised disk writes between each.
|
|
392
|
+
*
|
|
393
|
+
* Semantics:
|
|
394
|
+
* - All-or-nothing: any single conflict ABORTS the batch and reports the
|
|
395
|
+
* conflicting artifact_id + the existing claim. No partial commits.
|
|
396
|
+
* - Same conflict rules as claimArtifact (artifact_id equal OR paths
|
|
397
|
+
* overlap, scoped to a different agent).
|
|
398
|
+
* - Per-item agent override: each item may set its own `agent`. When
|
|
399
|
+
* omitted, the top-level `agent` is used.
|
|
400
|
+
*
|
|
401
|
+
* @param {string} projectRoot
|
|
402
|
+
* @param {Array<{artifact_id: string, paths?: string[], agent?: string, ttlMs?: number, note?: string}>} items
|
|
403
|
+
* @param {{agent?: string}} [defaults]
|
|
404
|
+
* @returns {{ok: true, claims: object[]} | {ok: false, error: string, artifact_id?: string, conflicts?: object[]}}
|
|
405
|
+
*/
|
|
406
|
+
export function claimArtifacts(projectRoot, items, defaults = {}) {
|
|
407
|
+
const paths = blackboardPaths(projectRoot);
|
|
408
|
+
ensureDir(paths);
|
|
409
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
410
|
+
return { ok: false, error: 'items-required' };
|
|
411
|
+
}
|
|
412
|
+
return withLock(paths.lock, () => {
|
|
413
|
+
const current = readJson(paths.claims, defaultClaims, validClaims).data;
|
|
414
|
+
const accepted = [];
|
|
415
|
+
// Track NEW claims so they conflict-check against each other (same wave
|
|
416
|
+
// calling claim_a + claim_b where they overlap is still a conflict).
|
|
417
|
+
const pendingClaims = [];
|
|
418
|
+
for (const input of items) {
|
|
419
|
+
const artifactId = String(input.artifact_id || input.artifact || '').trim();
|
|
420
|
+
const agent = String(input.agent || defaults.agent || input.owner || '').trim();
|
|
421
|
+
const ttlMs = Number.isFinite(input.ttlMs) && input.ttlMs > 0
|
|
422
|
+
? Math.floor(input.ttlMs)
|
|
423
|
+
: DEFAULT_CLAIM_TTL_MS;
|
|
424
|
+
const next = {
|
|
425
|
+
id: input.id || `${artifactId}:${agent}`,
|
|
426
|
+
artifact_id: artifactId,
|
|
427
|
+
agent,
|
|
428
|
+
paths: normalizePaths(input.paths),
|
|
429
|
+
status: 'active',
|
|
430
|
+
claimed_at: nowIso(),
|
|
431
|
+
ttl_ms: ttlMs,
|
|
432
|
+
heartbeat_at: null,
|
|
433
|
+
note: input.note ? String(input.note) : undefined,
|
|
434
|
+
};
|
|
435
|
+
if (!next.artifact_id) return { ok: false, error: 'artifact-required' };
|
|
436
|
+
if (!next.agent) return { ok: false, error: 'owner-required' };
|
|
437
|
+
|
|
438
|
+
// Conflict-check against existing AND already-accepted pending claims.
|
|
439
|
+
const combined = { claims: [...current.claims, ...pendingClaims] };
|
|
440
|
+
const conflicts = claimConflicts(combined, next);
|
|
441
|
+
if (conflicts.length) {
|
|
442
|
+
return { ok: false, error: 'conflict', artifact_id: next.artifact_id, conflicts };
|
|
443
|
+
}
|
|
444
|
+
pendingClaims.push(next);
|
|
445
|
+
accepted.push(next);
|
|
446
|
+
}
|
|
447
|
+
// Drop any prior duplicates (same artifact_id + agent) — matches
|
|
448
|
+
// claimArtifact semantics where a re-claim by the same agent is idempotent.
|
|
449
|
+
for (const next of accepted) {
|
|
450
|
+
current.claims = current.claims.filter(
|
|
451
|
+
(claim) => !(claimArtifactId(claim) === next.artifact_id && claimAgent(claim) === next.agent),
|
|
452
|
+
);
|
|
453
|
+
current.claims.push(next);
|
|
454
|
+
}
|
|
455
|
+
writeJson(paths.claims, current);
|
|
456
|
+
for (const next of accepted) {
|
|
457
|
+
appendJsonlUnlocked(paths.events, blackboardEventEntry({
|
|
458
|
+
type: 'claim.acquired',
|
|
459
|
+
actor: next.agent,
|
|
460
|
+
artifact_ids: [next.artifact_id],
|
|
461
|
+
message: `Claimed ${next.artifact_id} (bulk)`,
|
|
462
|
+
data: { paths: next.paths, bulk: true },
|
|
463
|
+
}));
|
|
464
|
+
}
|
|
465
|
+
return { ok: true, claims: accepted };
|
|
466
|
+
}).result ?? { ok: false, error: 'locked' };
|
|
467
|
+
}
|
|
468
|
+
|
|
268
469
|
export function releaseClaim(projectRoot, input) {
|
|
269
470
|
const paths = blackboardPaths(projectRoot);
|
|
270
471
|
ensureDir(paths);
|
|
@@ -295,6 +496,97 @@ export function releaseClaim(projectRoot, input) {
|
|
|
295
496
|
}).result ?? { ok: false, error: 'locked' };
|
|
296
497
|
}
|
|
297
498
|
|
|
499
|
+
/**
|
|
500
|
+
* F-REL-1 (H5.3): heartbeat ping. Subagents call this to extend their claim
|
|
501
|
+
* TTL without releasing + reclaiming. Heartbeat is matched by claim id
|
|
502
|
+
* (preferred) or by (artifact_id, agent) tuple. Returns the updated claim
|
|
503
|
+
* so the caller can verify the new heartbeat_at.
|
|
504
|
+
*/
|
|
505
|
+
export function updateClaimHeartbeat(projectRoot, input) {
|
|
506
|
+
const paths = blackboardPaths(projectRoot);
|
|
507
|
+
ensureDir(paths);
|
|
508
|
+
return withLock(paths.lock, () => {
|
|
509
|
+
const current = readJson(paths.claims, defaultClaims, validClaims).data;
|
|
510
|
+
const claimId = input.claim_id || input.id ? String(input.claim_id || input.id).trim() : null;
|
|
511
|
+
const artifactId = input.artifact_id || input.artifact ? String(input.artifact_id || input.artifact).trim() : null;
|
|
512
|
+
const agent = input.agent || input.owner ? String(input.agent || input.owner).trim() : null;
|
|
513
|
+
if (!claimId && !(artifactId && agent)) {
|
|
514
|
+
return { ok: false, error: 'claim-or-tuple-required' };
|
|
515
|
+
}
|
|
516
|
+
let updated = null;
|
|
517
|
+
current.claims = current.claims.map((claim) => {
|
|
518
|
+
if (claim.status !== 'active') return claim;
|
|
519
|
+
const matchesById = claimId && claim.id === claimId;
|
|
520
|
+
const matchesByTuple = !claimId && claimArtifactId(claim) === artifactId && claimAgent(claim) === agent;
|
|
521
|
+
if (!matchesById && !matchesByTuple) return claim;
|
|
522
|
+
updated = { ...claim, heartbeat_at: nowIso() };
|
|
523
|
+
return updated;
|
|
524
|
+
});
|
|
525
|
+
if (!updated) return { ok: false, error: 'claim-not-found' };
|
|
526
|
+
writeJson(paths.claims, current);
|
|
527
|
+
return { ok: true, claim: updated };
|
|
528
|
+
}).result ?? { ok: false, error: 'locked' };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* F-REL-1 (H5.3): orphan evictor. Walks active claims, releases any whose
|
|
533
|
+
* freshness anchor (max(claimed_at, heartbeat_at)) is older than ttlMs.
|
|
534
|
+
* Returns evicted claim IDs so the caller can log + report. Default TTL is
|
|
535
|
+
* 30 minutes, matching DEFAULT_CLAIM_TTL_MS.
|
|
536
|
+
*
|
|
537
|
+
* Per-claim ttl_ms (recorded at claim time) is honoured when present; the
|
|
538
|
+
* options.ttlMs is a fallback for legacy claims written before the TTL
|
|
539
|
+
* field existed.
|
|
540
|
+
*/
|
|
541
|
+
export function evictOrphanedClaims(projectRoot, options = {}) {
|
|
542
|
+
const paths = blackboardPaths(projectRoot);
|
|
543
|
+
ensureDir(paths);
|
|
544
|
+
const fallbackTtl = Number.isFinite(options.ttlMs) && options.ttlMs > 0
|
|
545
|
+
? Math.floor(options.ttlMs)
|
|
546
|
+
: DEFAULT_CLAIM_TTL_MS;
|
|
547
|
+
const now = Number.isFinite(options.nowMs) ? Number(options.nowMs) : Date.now();
|
|
548
|
+
return withLock(paths.lock, () => {
|
|
549
|
+
const current = readJson(paths.claims, defaultClaims, validClaims).data;
|
|
550
|
+
const evicted = [];
|
|
551
|
+
current.claims = current.claims.map((claim) => {
|
|
552
|
+
if (claim.status !== 'active') return claim;
|
|
553
|
+
const claimedAt = parseIso(claim.claimed_at);
|
|
554
|
+
const heartbeatAt = parseIso(claim.heartbeat_at);
|
|
555
|
+
const anchor = Math.max(claimedAt || 0, heartbeatAt || 0);
|
|
556
|
+
if (!anchor) return claim; // unparseable timestamps -- leave alone, don't false-evict
|
|
557
|
+
const ttl = Number.isFinite(claim.ttl_ms) && claim.ttl_ms > 0 ? claim.ttl_ms : fallbackTtl;
|
|
558
|
+
if (now - anchor <= ttl) return claim;
|
|
559
|
+
evicted.push({
|
|
560
|
+
id: claim.id,
|
|
561
|
+
artifact_id: claimArtifactId(claim),
|
|
562
|
+
agent: claimAgent(claim),
|
|
563
|
+
age_ms: now - anchor,
|
|
564
|
+
ttl_ms: ttl,
|
|
565
|
+
});
|
|
566
|
+
return { ...claim, status: 'expired', expired_at: nowIso(), eviction_reason: 'ttl-exceeded' };
|
|
567
|
+
});
|
|
568
|
+
if (evicted.length > 0) {
|
|
569
|
+
writeJson(paths.claims, current);
|
|
570
|
+
for (const item of evicted) {
|
|
571
|
+
appendJsonlUnlocked(paths.events, blackboardEventEntry({
|
|
572
|
+
type: 'claim.evicted',
|
|
573
|
+
actor: 'ijfw',
|
|
574
|
+
artifact_ids: [item.artifact_id],
|
|
575
|
+
message: `Evicted orphan claim ${item.id} (age ${Math.round(item.age_ms / 1000)}s > ttl ${Math.round(item.ttl_ms / 1000)}s)`,
|
|
576
|
+
data: { id: item.id, agent: item.agent, age_ms: item.age_ms, ttl_ms: item.ttl_ms },
|
|
577
|
+
}));
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return { ok: true, evicted, evicted_ids: evicted.map((item) => item.id), count: evicted.length };
|
|
581
|
+
}).result ?? { ok: false, error: 'locked' };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function parseIso(value) {
|
|
585
|
+
if (!value || typeof value !== 'string') return 0;
|
|
586
|
+
const ms = Date.parse(value);
|
|
587
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
588
|
+
}
|
|
589
|
+
|
|
298
590
|
export function addBlackboardNote(projectRoot, input) {
|
|
299
591
|
const paths = blackboardPaths(projectRoot);
|
|
300
592
|
ensureDir(paths);
|
package/src/cli-run.js
CHANGED
|
@@ -10,20 +10,34 @@
|
|
|
10
10
|
* long-lived MCP server. A 30-line shim that imports dispatchRun directly
|
|
11
11
|
* keeps the dependency chain trivial: bash -> node -> dispatch/*.js.
|
|
12
12
|
*
|
|
13
|
+
* v1.5.0 T12 extends this shim with the `state:<verb>` colon-namespace —
|
|
14
|
+
* the CLI face of the state-SDK (contract §0). The same shim now lets
|
|
15
|
+
* external tooling reach `query(verb, payload, ctx)` from bash, e.g.
|
|
16
|
+
* shell-hook state writes (T11) and the e2e-smoke `state:workflow.get` gate.
|
|
17
|
+
*
|
|
13
18
|
* Usage:
|
|
14
19
|
* node cli-run.js <namespace>:<command> [--project-root <dir>] [args...]
|
|
15
20
|
*
|
|
16
21
|
* Examples:
|
|
17
22
|
* node cli-run.js domain-manifest:load --project-root /path/to/proj
|
|
18
23
|
* node cli-run.js extension:deploy-lazy --project-root /path/to/proj
|
|
24
|
+
* node cli-run.js state:workflow.get '{}'
|
|
25
|
+
* node cli-run.js state:workflow.set-phase '{"phase":"build"}'
|
|
19
26
|
*
|
|
20
27
|
* Contract:
|
|
21
|
-
* -
|
|
22
|
-
* command reports ok:false -- that's a *result*, not a shim failure).
|
|
28
|
+
* - Prints the JSON-stringified result to stdout.
|
|
23
29
|
* - Exits 2 on argv-shape errors (missing colon expression).
|
|
24
30
|
* - Exits 3 on a thrown error inside the dispatcher.
|
|
25
|
-
* -
|
|
26
|
-
*
|
|
31
|
+
* - For the `state:` namespace: exits 0 on `ok:true`, non-zero on
|
|
32
|
+
* `ok:false` so shell callers can branch on `$?` without re-parsing
|
|
33
|
+
* the JSON. The non-zero exit is paired with a stderr line carrying
|
|
34
|
+
* the result's `error` for log readability.
|
|
35
|
+
* - For every other namespace (compute/index/detect/graph/override/
|
|
36
|
+
* extension/domain-manifest): exits 0 on a successful dispatch even
|
|
37
|
+
* when the dispatched command reports ok:false — that is a *result*,
|
|
38
|
+
* not a shim failure (legacy behaviour preserved).
|
|
39
|
+
* - stderr stays empty on the happy path so the session-start log isn't
|
|
40
|
+
* polluted.
|
|
27
41
|
*
|
|
28
42
|
* Discipline:
|
|
29
43
|
* - Built-in Node only. No new deps.
|
|
@@ -80,7 +94,21 @@ async function main() {
|
|
|
80
94
|
const result = await dispatchRun(parsed, {
|
|
81
95
|
projectRoot: projectRoot || process.env.IJFW_PROJECT_DIR || process.cwd(),
|
|
82
96
|
});
|
|
83
|
-
|
|
97
|
+
const payload = result == null
|
|
98
|
+
? { ok: false, error: 'dispatch returned null (unknown namespace)' }
|
|
99
|
+
: result;
|
|
100
|
+
process.stdout.write(JSON.stringify(payload) + '\n');
|
|
101
|
+
// T12: the `state:` namespace honours `ok:true/false` as the process
|
|
102
|
+
// exit code so bash callers can `if ijfw state:foo ...; then` without
|
|
103
|
+
// re-parsing the JSON. Other namespaces keep the legacy always-0 contract
|
|
104
|
+
// — a dispatched compute:python script with exit_code=1 is a *result*,
|
|
105
|
+
// not a shim failure, and the existing session-start hooks rely on that.
|
|
106
|
+
if (parsed.namespace === 'state' && payload && payload.ok === false) {
|
|
107
|
+
if (payload.error) {
|
|
108
|
+
process.stderr.write(`cli-run: state:${parsed.command || '<verb>'}: ${payload.error}\n`);
|
|
109
|
+
}
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
84
112
|
process.exit(0);
|
|
85
113
|
} catch (err) {
|
|
86
114
|
process.stderr.write(`cli-run: dispatch threw: ${err && err.message ? err.message : String(err)}\n`);
|
package/src/codex-agents.js
CHANGED
|
@@ -1,7 +1,33 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
|
-
import { join, resolve } from 'node:path';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
3
3
|
import { writeAtomic } from './lib/atomic-io.js';
|
|
4
4
|
|
|
5
|
+
// F-FUN-7 (audit-MED-teams-#8): role-type → Codex tool allowlist. Honors
|
|
6
|
+
// the principle of least authority: research roles read only, review roles
|
|
7
|
+
// read+edit, software/lead/qa get the full toolbelt, book/content roles get
|
|
8
|
+
// Read/Write/Edit (they author durable text artifacts). Unknown role types
|
|
9
|
+
// fall back to the conservative software set so a missing entry never
|
|
10
|
+
// silently downgrades a charter we already accepted.
|
|
11
|
+
export const ROLE_TOOL_ALLOWLIST = Object.freeze({
|
|
12
|
+
research: ['Read'],
|
|
13
|
+
review: ['Read', 'Edit'],
|
|
14
|
+
software: ['Read', 'Write', 'Edit', 'Bash'],
|
|
15
|
+
lead: ['Read', 'Write', 'Edit', 'Bash'],
|
|
16
|
+
qa: ['Read', 'Write', 'Edit', 'Bash'],
|
|
17
|
+
book: ['Read', 'Write', 'Edit'],
|
|
18
|
+
content: ['Read', 'Write', 'Edit'],
|
|
19
|
+
design: ['Read', 'Write', 'Edit'],
|
|
20
|
+
business: ['Read', 'Write', 'Edit'],
|
|
21
|
+
education: ['Read', 'Write', 'Edit'],
|
|
22
|
+
operations: ['Read', 'Write', 'Edit', 'Bash'],
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export function toolsForRoleType(roleType) {
|
|
26
|
+
const fallback = ROLE_TOOL_ALLOWLIST.software;
|
|
27
|
+
if (!roleType || typeof roleType !== 'string') return fallback;
|
|
28
|
+
return ROLE_TOOL_ALLOWLIST[roleType] || fallback;
|
|
29
|
+
}
|
|
30
|
+
|
|
5
31
|
export function renderCodexAgentToml(role, bundle = {}) {
|
|
6
32
|
assertRole(role);
|
|
7
33
|
const charter = bundle.charter || bundle;
|
|
@@ -23,6 +49,12 @@ export function renderCodexAgentToml(role, bundle = {}) {
|
|
|
23
49
|
lines.push(`model_reasoning_effort = ${tomlString(codexConfig.model_reasoning_effort.trim())}`);
|
|
24
50
|
}
|
|
25
51
|
|
|
52
|
+
// F-FUN-7 (audit-MED-teams-#8): emit a role-type-keyed tools allowlist. The
|
|
53
|
+
// Codex agent runtime honors this as the per-agent toolbelt; research roles
|
|
54
|
+
// get read-only, software/lead/qa get the full set, etc.
|
|
55
|
+
const tools = toolsForRoleType(role.role_type);
|
|
56
|
+
lines.push(`tools = ${tomlStringArray(tools)}`);
|
|
57
|
+
|
|
26
58
|
lines.push(`developer_instructions = ${tomlMultiline(instructions)}`);
|
|
27
59
|
return `${lines.join('\n')}\n`;
|
|
28
60
|
}
|
|
@@ -37,21 +69,75 @@ export function syncCodexAgents(projectRoot = process.cwd(), options = {}) {
|
|
|
37
69
|
const agentsDir = join(root, '.codex', 'agents');
|
|
38
70
|
mkdirSync(agentsDir, { recursive: true, mode: 0o700 });
|
|
39
71
|
|
|
72
|
+
// F-SEC-3 (audit-MED-teams-#12): symlink-realpath containment gate. If the
|
|
73
|
+
// .codex/agents/ target was swapped for a symlink that points outside the
|
|
74
|
+
// project root, writing into it would let a hostile project escape its
|
|
75
|
+
// own boundary. realpathSync resolves the link, and we require the
|
|
76
|
+
// resolved path to live inside the realpath'd project root.
|
|
77
|
+
const containment = assertAgentsDirContained(root, agentsDir);
|
|
78
|
+
if (!containment.ok) {
|
|
79
|
+
return { ok: false, error: containment.error, agentsDir, agentFiles: [], detail: containment.detail };
|
|
80
|
+
}
|
|
81
|
+
const safeAgentsDir = containment.realpath;
|
|
82
|
+
|
|
40
83
|
const agentFiles = [];
|
|
84
|
+
let skipped = 0;
|
|
41
85
|
for (const role of bundle.charter.roles) {
|
|
42
|
-
const agentPath = join(
|
|
43
|
-
|
|
86
|
+
const agentPath = join(safeAgentsDir, `${codexAgentFilename(role.name)}.toml`);
|
|
87
|
+
const rendered = renderCodexAgentToml(role, bundle);
|
|
88
|
+
// F-SPD-3 (audit-MED-teams-#11): content-hash skip. Reading the existing
|
|
89
|
+
// file is cheap (TOML agent files are small) and avoids touching mtime
|
|
90
|
+
// when nothing changed -- which in turn keeps downstream watchers and
|
|
91
|
+
// Codex-side hot-reloaders from doing redundant work.
|
|
92
|
+
if (existsSync(agentPath)) {
|
|
93
|
+
let existing = '';
|
|
94
|
+
try { existing = readFileSync(agentPath, 'utf8'); } catch { existing = ''; }
|
|
95
|
+
if (existing === rendered) {
|
|
96
|
+
agentFiles.push(agentPath);
|
|
97
|
+
skipped += 1;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
writeAtomic(agentPath, rendered, { mode: 0o600 });
|
|
44
102
|
agentFiles.push(agentPath);
|
|
45
103
|
}
|
|
46
104
|
|
|
47
105
|
return {
|
|
48
106
|
ok: true,
|
|
49
|
-
agentsDir,
|
|
107
|
+
agentsDir: safeAgentsDir,
|
|
50
108
|
agentFiles,
|
|
51
109
|
count: agentFiles.length,
|
|
110
|
+
skipped,
|
|
52
111
|
};
|
|
53
112
|
}
|
|
54
113
|
|
|
114
|
+
// F-SEC-3: contain `.codex/agents/` to the project root. Returns the
|
|
115
|
+
// realpath-resolved agents dir so callers write through the resolved path
|
|
116
|
+
// (defence-in-depth: if a future writer re-uses `agentsDir` we still touch
|
|
117
|
+
// the same vetted location).
|
|
118
|
+
function assertAgentsDirContained(projectRoot, agentsDir) {
|
|
119
|
+
let resolvedRoot;
|
|
120
|
+
let resolvedAgents;
|
|
121
|
+
try {
|
|
122
|
+
resolvedRoot = realpathSync(projectRoot);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
return { ok: false, error: 'project-realpath-failed', detail: String(err?.message || err) };
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
resolvedAgents = realpathSync(agentsDir);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
// Newly-created dir may not yet have a resolvable realpath if a parent
|
|
130
|
+
// is a symlink; fall back to the un-resolved path for containment but
|
|
131
|
+
// verify the parent is contained.
|
|
132
|
+
return { ok: false, error: 'agents-realpath-failed', detail: String(err?.message || err) };
|
|
133
|
+
}
|
|
134
|
+
const rel = relative(resolvedRoot, resolvedAgents);
|
|
135
|
+
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
|
|
136
|
+
return { ok: false, error: 'agents-escapes-project', detail: `agentsDir=${resolvedAgents} root=${resolvedRoot}` };
|
|
137
|
+
}
|
|
138
|
+
return { ok: true, realpath: resolvedAgents };
|
|
139
|
+
}
|
|
140
|
+
|
|
55
141
|
function readTeamBundle(root) {
|
|
56
142
|
const charterPath = join(root, '.ijfw', 'team', 'charter.json');
|
|
57
143
|
if (!existsSync(charterPath)) return null;
|
|
@@ -168,6 +254,11 @@ function tomlString(value) {
|
|
|
168
254
|
return JSON.stringify(String(value));
|
|
169
255
|
}
|
|
170
256
|
|
|
257
|
+
function tomlStringArray(values) {
|
|
258
|
+
const items = list(values).map((v) => tomlString(String(v)));
|
|
259
|
+
return `[${items.join(', ')}]`;
|
|
260
|
+
}
|
|
261
|
+
|
|
171
262
|
function tomlMultiline(value) {
|
|
172
263
|
return `"""\n${String(value).replace(/"""/g, '\\"\\"\\"')}\n"""`;
|
|
173
264
|
}
|
package/src/cost/aggregator.js
CHANGED
|
@@ -124,29 +124,59 @@ export function buildCostReport(days, observations = []) {
|
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// v1.5.0 audit-LOW-tok-L3: memoize buildBreakdown per (dim, days, bucket).
|
|
128
|
+
// Dashboards call this endpoint repeatedly on a 10s tick across four
|
|
129
|
+
// dimensions; each call re-reads ~3 platforms of transcript JSON. Within
|
|
130
|
+
// a 60-second time bucket the result is functionally identical, so cache
|
|
131
|
+
// it. TTL is short enough that newly-arriving turns surface within one
|
|
132
|
+
// dashboard refresh interval.
|
|
133
|
+
const BREAKDOWN_CACHE_TTL_MS = 60_000;
|
|
134
|
+
const _breakdownCache = new Map();
|
|
135
|
+
|
|
136
|
+
function _bucketKey(dim, days, now = Date.now()) {
|
|
137
|
+
const bucket = Math.floor(now / BREAKDOWN_CACHE_TTL_MS);
|
|
138
|
+
return `${dim}|${days ?? 'all'}|${bucket}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Test helper: clear the memoization cache.
|
|
142
|
+
export function _resetBreakdownCache() { _breakdownCache.clear(); }
|
|
143
|
+
|
|
127
144
|
/**
|
|
128
145
|
* Build a breakdown grouped by a dimension.
|
|
129
146
|
* dim: 'platform' | 'session' | 'model' | 'tool'
|
|
130
147
|
*/
|
|
131
148
|
export function buildBreakdown(dim, days, _observations = []) {
|
|
149
|
+
const key = _bucketKey(dim, days);
|
|
150
|
+
const cached = _breakdownCache.get(key);
|
|
151
|
+
if (cached) return cached;
|
|
152
|
+
|
|
132
153
|
const raw = readAllTurns(days);
|
|
133
154
|
const turns = annotateCosts(raw);
|
|
134
155
|
|
|
135
156
|
const groups = {};
|
|
136
157
|
for (const t of turns) {
|
|
137
|
-
const
|
|
138
|
-
if (!groups[
|
|
139
|
-
groups[
|
|
140
|
-
groups[
|
|
141
|
-
groups[
|
|
142
|
-
groups[
|
|
143
|
-
groups[
|
|
144
|
-
groups[
|
|
158
|
+
const k = t[dim] || 'unknown';
|
|
159
|
+
if (!groups[k]) groups[k] = { key: k, cost_usd: 0, theoretical_cost_usd: 0, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, count: 0 };
|
|
160
|
+
groups[k].cost_usd += t.cost_usd;
|
|
161
|
+
groups[k].theoretical_cost_usd += t.theoretical_cost_usd || 0;
|
|
162
|
+
groups[k].input_tokens += t.input_tokens || 0;
|
|
163
|
+
groups[k].output_tokens += t.output_tokens || 0;
|
|
164
|
+
groups[k].cache_read_tokens += t.cache_read_tokens || 0;
|
|
165
|
+
groups[k].count++;
|
|
145
166
|
}
|
|
146
167
|
|
|
147
168
|
// Sort by theoretical cost so Max-session breakdowns still rank by usage
|
|
148
169
|
// intensity even when cost_usd is uniformly zero.
|
|
149
|
-
|
|
170
|
+
const result = Object.values(groups).sort((a, b) => b.theoretical_cost_usd - a.theoretical_cost_usd);
|
|
171
|
+
|
|
172
|
+
// Bound the cache: keep at most 32 entries (4 dims * 8 window/bucket combos).
|
|
173
|
+
// Eviction is opportunistic on insert -- O(1) amortised.
|
|
174
|
+
if (_breakdownCache.size >= 32) {
|
|
175
|
+
const firstKey = _breakdownCache.keys().next().value;
|
|
176
|
+
if (firstKey !== undefined) _breakdownCache.delete(firstKey);
|
|
177
|
+
}
|
|
178
|
+
_breakdownCache.set(key, result);
|
|
179
|
+
return result;
|
|
150
180
|
}
|
|
151
181
|
|
|
152
182
|
/**
|