@ijfw/memory-server 1.4.4 → 1.5.1
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/bin/ijfw-memorize +14 -7
- package/fixtures/team/book.json +6 -6
- package/fixtures/team/business.json +146 -20
- package/fixtures/team/content.json +6 -6
- package/fixtures/team/design.json +148 -20
- package/fixtures/team/mixed.json +206 -27
- package/fixtures/team/research.json +146 -20
- package/fixtures/team/software.json +148 -20
- 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 +6 -3
- 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 +754 -159
- package/src/cross-orchestrator.js +1065 -17
- package/src/cross-project-search.js +195 -9
- package/src/dashboard-client-waves.html +304 -0
- package/src/dashboard-client.html +5 -1
- package/src/dashboard-server.js +73 -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 +26 -2
- package/src/dispatch/registry-cli.js +4 -1
- package/src/dispatch/wave-cli.js +201 -6
- 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/hardware-signer.js +4 -2
- 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 +595 -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 +267 -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/migration-runner.js +6 -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/migrations/009-obsidian-backfill.js +50 -0
- package/src/memory/obsidian-parser.js +152 -0
- package/src/memory/query-dataview.js +86 -0
- package/src/memory/search.js +46 -15
- 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-trigger.js +374 -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 +277 -0
- package/src/orchestrator/review.js +38 -3
- 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 +1932 -0
- package/src/orchestrator/status-protocol.js +84 -17
- package/src/orchestrator/subagent-telemetry.js +471 -0
- package/src/orchestrator/termination.js +160 -0
- package/src/orchestrator/verification-gate.js +200 -16
- package/src/orchestrator/wave-state.js +332 -23
- package/src/orchestrator/worktree-provision.js +77 -0
- package/src/override-resolver.js +5 -3
- 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 +961 -0
- package/src/recovery/truncation.js +317 -0
- package/src/redactor.js +75 -6
- package/src/runtime-mediator.js +15 -1
- package/src/sanitizer.js +10 -0
- package/src/search-hybrid.js +139 -0
- package/src/server.js +795 -112
- package/src/swarm/worktree.js +27 -4
- package/src/swarm-config.js +102 -17
- package/src/team/domain-templates/book.json +51 -0
- package/src/team/domain-templates/business.json +44 -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 +44 -0
- package/src/team/domain-templates/software.json +40 -0
- package/src/team/generator.js +440 -3
- package/src/team/modify.js +203 -0
- package/src/team/schemas.js +48 -0
- package/src/update-apply.js +19 -3
- package/src/dashboard-charts.js +0 -239
package/src/audit-roster.js
CHANGED
|
@@ -12,6 +12,38 @@
|
|
|
12
12
|
// gets filtered as "self."
|
|
13
13
|
|
|
14
14
|
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { getLatestModel } from './model-refresh.js';
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// v1.5.0 F5 -- audit-rotation v0 schema (schema-only; runtime ships in v1.6.0)
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
//
|
|
21
|
+
// We are NOT shipping rotation logic in v1.5.0. We are shipping the schema
|
|
22
|
+
// commitment so callers / future code have a stable contract to encode
|
|
23
|
+
// against. Auto-rotation flips on in v1.6.0 once we have telemetry to
|
|
24
|
+
// decide rotation policy (cost-weighted, win-rate-weighted, round-robin).
|
|
25
|
+
//
|
|
26
|
+
// Contract:
|
|
27
|
+
// - ROTATION_SCHEMA_VERSION = 1: any future schema change bumps this
|
|
28
|
+
// integer; consumers MUST refuse to apply policy from a higher version
|
|
29
|
+
// than they support.
|
|
30
|
+
// - defaultRotationPolicy = 'manual': v0 behavior is "no rotation;
|
|
31
|
+
// caller (or operator) picks the auditor explicitly via pickAuditors
|
|
32
|
+
// `only:` or default-priority strategy." Other policy values reserved
|
|
33
|
+
// for v1.6.0: 'round-robin', 'cost-weighted', 'win-rate-weighted'.
|
|
34
|
+
//
|
|
35
|
+
// Shape (reserved; not consumed yet):
|
|
36
|
+
// {
|
|
37
|
+
// schema: ROTATION_SCHEMA_VERSION,
|
|
38
|
+
// policy: defaultRotationPolicy,
|
|
39
|
+
// window_days: 7, // reserved -- look-back for win-rate
|
|
40
|
+
// min_picks_per_auditor: 1, // reserved -- floor on usage
|
|
41
|
+
// last_rotated: <ISO>, // reserved -- persistence anchor
|
|
42
|
+
// }
|
|
43
|
+
//
|
|
44
|
+
/** @typedef {{ schema: number, policy: string, window_days?: number, min_picks_per_auditor?: number, last_rotated?: string }} RotationPolicy */
|
|
45
|
+
export const ROTATION_SCHEMA_VERSION = 1;
|
|
46
|
+
export const defaultRotationPolicy = 'manual';
|
|
15
47
|
|
|
16
48
|
export const ROSTER = [
|
|
17
49
|
{
|
|
@@ -19,7 +51,22 @@ export const ROSTER = [
|
|
|
19
51
|
family: 'openai',
|
|
20
52
|
model: '',
|
|
21
53
|
name: 'Codex CLI',
|
|
54
|
+
// Prompt-via-stdin path. Proven working 2026-05-18 with codex-cli 0.130.0.
|
|
22
55
|
invoke: 'codex exec --skip-git-repo-check --sandbox read-only -c approval_policy="never" -c mcp_servers.ijfw-memory.enabled=false -',
|
|
56
|
+
// Dedicated review subcommand path. Use when an audit target is a git ref
|
|
57
|
+
// (HEAD~N, branch name, or commit SHA). The -c mcp_servers.ijfw-memory.enabled=false
|
|
58
|
+
// override is LOAD-BEARING: without it, codex review hangs indefinitely on
|
|
59
|
+
// the ijfw_memory_prelude MCP tool autostart (cycle: codex spawns IJFW MCP
|
|
60
|
+
// server, prelude tool waits on a response, IJFW MCP server is itself the
|
|
61
|
+
// child of the codex session). Verified 2026-05-18, codex-cli 0.130.0.
|
|
62
|
+
// {REF} is the substitution token the caller swaps for the base git ref.
|
|
63
|
+
reviewInvoke: 'codex review --base {REF} -c approval_policy="never" -c mcp_servers.ijfw-memory.enabled=false',
|
|
64
|
+
// 8 min default per-auditor budget for review work. codex review against
|
|
65
|
+
// HEAD~5 with MCP disabled completed in ~75s during S7 reproduction;
|
|
66
|
+
// larger diffs and reasoning-heavy targets need headroom. The existing
|
|
67
|
+
// PROVIDER_TIMEOUT_MS['codex'] in cross-orchestrator.js is 120s (2 min),
|
|
68
|
+
// which is fine for exec-mode quick prompts but too tight for review.
|
|
69
|
+
timeoutMs: 8 * 60 * 1000,
|
|
23
70
|
note: 'Different training lineage; fast on review tasks. The - flag reads prompt from stdin. --skip-git-repo-check bypasses the trusted-directory gate added in codex-cli 0.118.0. --sandbox read-only blocks file WRITES on the host (verified Codex 0.122.0: `echo > /tmp/x` returns `operation not permitted`); it does NOT block shell exec or subprocess launching -- a `read-only` sandbox can still run `ls`, `curl`, or `gemini`. The defense against codex going meta and shelling out to other auditors is the prompt-layer "Operating constraints" block in cross-dispatcher.js buildRequest, not the sandbox flag. The model layer additionally refuses to read explicitly-secret files like ~/.ssh/id_rsa or ~/.codex/auth.json even when prompt-injected to do so. The visibility surface in cross-orchestrator-cli.js cmdCross catches any residual silent failure. approval_policy="never" auto-approves without an interactive prompt. mcp_servers.ijfw-memory.enabled=false disables IJFW MCP for this session because Codex in `codex exec` mode under a non-bypass sandbox auto-cancels MCP tool calls -- the cancellation noise wastes tokens and the audit does not need IJFW memory recall (the brief contains the full target inline).',
|
|
24
71
|
// CODEX_SESSION_ID is set by codex itself when running INSIDE a codex
|
|
25
72
|
// session; CODEX_HOME is a config-path env var that's set whenever codex
|
|
@@ -28,7 +75,11 @@ export const ROSTER = [
|
|
|
28
75
|
// installed but where the caller is something else (Claude Code, Cursor,
|
|
29
76
|
// etc.). Surface noted by carrmjw during the qwen roster review (#11).
|
|
30
77
|
detect: (env) => Boolean(env.CODEX_SESSION_ID) || /codex/i.test(env._ || ''),
|
|
31
|
-
|
|
78
|
+
// model is resolved at call-time via model-refresh.js (24h-cached probe of
|
|
79
|
+
// /v1/models). Hardcoded value below is the offline fallback only.
|
|
80
|
+
get apiFallback() {
|
|
81
|
+
return { provider: 'openai', model: getLatestModel('openai'), authEnv: 'OPENAI_API_KEY', endpoint: 'https://api.openai.com/v1/chat/completions' };
|
|
82
|
+
},
|
|
32
83
|
},
|
|
33
84
|
{
|
|
34
85
|
id: 'gemini',
|
|
@@ -38,7 +89,10 @@ export const ROSTER = [
|
|
|
38
89
|
invoke: 'gemini',
|
|
39
90
|
note: 'Strong on security + architectural patterns. Auto-detects piped stdin for headless mode.',
|
|
40
91
|
detect: (env) => Boolean(env.GEMINI_CLI || env.GOOGLE_CLOUD_PROJECT_GEMINI) || /gemini-cli/i.test(env._ || ''),
|
|
41
|
-
|
|
92
|
+
// model is resolved at call-time via model-refresh.js (24h-cached probe).
|
|
93
|
+
get apiFallback() {
|
|
94
|
+
return { provider: 'google', model: getLatestModel('google'), authEnv: 'GEMINI_API_KEY', endpoint: 'https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent' };
|
|
95
|
+
},
|
|
42
96
|
},
|
|
43
97
|
{
|
|
44
98
|
id: 'qwen',
|
|
@@ -108,7 +162,10 @@ export const ROSTER = [
|
|
|
108
162
|
invoke: 'claude -p',
|
|
109
163
|
note: 'Anthropic; useful when you want a second Claude pass in a fresh session.',
|
|
110
164
|
detect: (env) => Boolean(env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT || env.CLAUDE_PLUGIN_ROOT),
|
|
111
|
-
|
|
165
|
+
// model is resolved at call-time via model-refresh.js (24h-cached probe).
|
|
166
|
+
get apiFallback() {
|
|
167
|
+
return { provider: 'anthropic', model: getLatestModel('anthropic'), authEnv: 'ANTHROPIC_API_KEY', endpoint: 'https://api.anthropic.com/v1/messages' };
|
|
168
|
+
},
|
|
112
169
|
},
|
|
113
170
|
];
|
|
114
171
|
|
|
@@ -122,9 +179,27 @@ export function detectSelf(env = process.env) {
|
|
|
122
179
|
|
|
123
180
|
// Probe whether the auditor's CLI is on PATH. Cached per process.
|
|
124
181
|
// Exported so tests can prime the cache for deterministic behavior.
|
|
182
|
+
//
|
|
183
|
+
// v1.5.0 audit-LOW-trident-L1: cache entries now carry a timestamp and
|
|
184
|
+
// expire after 5 minutes. A long-running orchestrator session that installs
|
|
185
|
+
// an auditor mid-session (e.g. `npm install -g @google/gemini-cli`) was
|
|
186
|
+
// otherwise stuck with the stale "not installed" verdict for the rest of
|
|
187
|
+
// the process lifetime. 5min is comfortably longer than a typical Trident
|
|
188
|
+
// fan-out (which probes every auditor up front) but short enough that a
|
|
189
|
+
// mid-session install is detected on the next round.
|
|
125
190
|
export const _installedCache = new Map();
|
|
191
|
+
const INSTALLED_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
126
192
|
export function isInstalled(id) {
|
|
127
|
-
|
|
193
|
+
const cached = _installedCache.get(id);
|
|
194
|
+
if (cached !== undefined) {
|
|
195
|
+
// Legacy entries (primed by tests as a raw boolean) are honoured
|
|
196
|
+
// forever — tests rely on that contract. New entries carry {value, ts}.
|
|
197
|
+
if (typeof cached === 'boolean') return cached;
|
|
198
|
+
if (cached && typeof cached === 'object' && cached.ts + INSTALLED_CACHE_TTL_MS > Date.now()) {
|
|
199
|
+
return cached.value;
|
|
200
|
+
}
|
|
201
|
+
// expired — fall through to re-probe
|
|
202
|
+
}
|
|
128
203
|
const entry = ROSTER.find(e => e.id === id);
|
|
129
204
|
if (!entry) return false;
|
|
130
205
|
// First word of invoke is the binary; the rest are args.
|
|
@@ -133,7 +208,7 @@ export function isInstalled(id) {
|
|
|
133
208
|
// works reliably across macOS + Linux. spawnSync exit code = 0 → present.
|
|
134
209
|
const r = spawnSync('bash', ['-lc', `command -v ${JSON.stringify(bin)} >/dev/null 2>&1`], { timeout: 2000 });
|
|
135
210
|
const installed = r.status === 0;
|
|
136
|
-
_installedCache.set(id, installed);
|
|
211
|
+
_installedCache.set(id, { value: installed, ts: Date.now() });
|
|
137
212
|
return installed;
|
|
138
213
|
}
|
|
139
214
|
|
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`);
|