@ijfw/memory-server 1.4.4 → 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 +1016 -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/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 +38 -3
- 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 +84 -17
- package/src/orchestrator/subagent-telemetry.js +452 -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-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 +94 -17
- 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
|
@@ -23,20 +23,105 @@ import { buildRequest, parseResponse, mergeResponses, checkBudget } from './cros
|
|
|
23
23
|
import { writeReceipt, readReceipts } from './receipts.js';
|
|
24
24
|
import { runViaApi } from './api-client.js';
|
|
25
25
|
import { RELEASE_BLOCKER_GATES, DegradedTridentError } from './trident/dispatch.js';
|
|
26
|
+
// v1.5.0 wire-W1.B — Anthropic ephemeral-cache TTL heartbeat for long
|
|
27
|
+
// convergence waves. Opt-in via IJFW_CACHE_KEEPALIVE_MS env (1s..5min).
|
|
28
|
+
import { startKeepaliveFromEnv } from './lib/cache-keepalive.js';
|
|
29
|
+
// v1.5.0 T21 (W4) — convergence telemetry via state-SDK telemetry.record verb.
|
|
30
|
+
// Writes `.ijfw/telemetry/convergence.json` with cycles-to-converge, false-
|
|
31
|
+
// positive rate, and cost. Routed through the SDK so the append is journaled,
|
|
32
|
+
// dedup'd, and tap-emitted on the standard observability surface.
|
|
33
|
+
import { query as _stateQuery } from './orchestrator/state-sdk.js';
|
|
26
34
|
|
|
27
35
|
// ---------------------------------------------------------------------------
|
|
28
36
|
// Per-provider timeout defaults (ms). Codex cold-start can take 120s+ (U2).
|
|
37
|
+
// r17.1 — bumped gemini 45s → 90s. The 45s budget was tuned for Gemini 2.x
|
|
38
|
+
// which routinely returned in 10-20s; Gemini 3.x cold starts + larger context
|
|
39
|
+
// windows mean a single audit on a 10KB target can exceed 45s end-to-end
|
|
40
|
+
// (verified in cross-audit r16 on this codebase). 90s gives realistic
|
|
41
|
+
// headroom without making a hung process feel hung.
|
|
29
42
|
// ---------------------------------------------------------------------------
|
|
30
43
|
const PROVIDER_TIMEOUT_MS = {
|
|
31
44
|
codex: 120_000,
|
|
32
|
-
gemini:
|
|
45
|
+
gemini: 90_000,
|
|
33
46
|
anthropic: 60_000,
|
|
34
47
|
'api-mode': 30_000,
|
|
35
48
|
};
|
|
36
49
|
const DEFAULT_TIMEOUT_MS = 90_000;
|
|
37
50
|
|
|
51
|
+
// r17.1 — retry budget for transient timeouts. Gemini's API frequently returns
|
|
52
|
+
// transient 502s and TCP hangs that resolve on a second attempt; codex more
|
|
53
|
+
// often has a real cold-start problem that needs the longer first timeout
|
|
54
|
+
// rather than a retry. Default: 1 retry, only for the gemini family. Per-
|
|
55
|
+
// auditor `retryOnTimeout: true|false` in audit-roster.js overrides.
|
|
56
|
+
// r17-L1 closure: also keyed by `family` so any future google-family auditor
|
|
57
|
+
// (e.g. a "gemini-fast" id) inherits the family retry policy without us
|
|
58
|
+
// having to add every id explicitly.
|
|
59
|
+
const PROVIDER_RETRY_ON_TIMEOUT = {
|
|
60
|
+
gemini: true,
|
|
61
|
+
};
|
|
62
|
+
const FAMILY_RETRY_ON_TIMEOUT = {
|
|
63
|
+
google: true,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// v1.5.0 audit-MED-trident-M7 — per-(provider, path) retry matrix.
|
|
67
|
+
// CLI cold-start failure modes (process startup, codex/gemini binary auth) are
|
|
68
|
+
// different from API failure modes (transient 502s, network blips). A blanket
|
|
69
|
+
// "no retry for codex" was wrong: codex CLI shouldn't retry (cold-start hits
|
|
70
|
+
// the same wall), but codex *API* path absolutely should retry on transient
|
|
71
|
+
// network noise. Resolves a finding where the api-fallback path inherited the
|
|
72
|
+
// CLI's no-retry policy and gave up after one HTTP 502.
|
|
73
|
+
//
|
|
74
|
+
// Lookup precedence (most → least specific):
|
|
75
|
+
// 1. pick.retryMatrix[path] -- explicit per-auditor override
|
|
76
|
+
// 2. PROVIDER_RETRY_PATH[id][path]
|
|
77
|
+
// 3. FAMILY_RETRY_PATH[family][path]
|
|
78
|
+
// 4. PROVIDER_RETRY_ON_TIMEOUT[id] / FAMILY_RETRY_ON_TIMEOUT[family]
|
|
79
|
+
// (legacy single-axis policy, kept for back-compat)
|
|
80
|
+
// 5. default: false (CLI), true (API)
|
|
81
|
+
const PROVIDER_RETRY_PATH = {
|
|
82
|
+
// codex CLI cold-start is real work; API is HTTP and benefits from retry.
|
|
83
|
+
codex: { cli: false, api: true },
|
|
84
|
+
gemini: { cli: true, api: true },
|
|
85
|
+
};
|
|
86
|
+
const FAMILY_RETRY_PATH = {
|
|
87
|
+
openai: { cli: false, api: true },
|
|
88
|
+
google: { cli: true, api: true },
|
|
89
|
+
anthropic: { cli: false, api: true },
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// shouldRetryOnTimeout(pick, path) -- path is 'cli' | 'api'.
|
|
93
|
+
function shouldRetryOnTimeout(pick, path) {
|
|
94
|
+
// Explicit pick-level override.
|
|
95
|
+
if (pick && pick.retryMatrix && pick.retryMatrix[path] !== undefined) {
|
|
96
|
+
return pick.retryMatrix[path] === true;
|
|
97
|
+
}
|
|
98
|
+
// Per-provider, per-path.
|
|
99
|
+
const provider = pick && pick.id;
|
|
100
|
+
if (provider && PROVIDER_RETRY_PATH[provider] && PROVIDER_RETRY_PATH[provider][path] !== undefined) {
|
|
101
|
+
return PROVIDER_RETRY_PATH[provider][path] === true;
|
|
102
|
+
}
|
|
103
|
+
// Per-family, per-path.
|
|
104
|
+
const family = pick && pick.family;
|
|
105
|
+
if (family && FAMILY_RETRY_PATH[family] && FAMILY_RETRY_PATH[family][path] !== undefined) {
|
|
106
|
+
return FAMILY_RETRY_PATH[family][path] === true;
|
|
107
|
+
}
|
|
108
|
+
// Legacy single-axis fallback (preserves r17.1 behavior for non-matrixed picks).
|
|
109
|
+
if (pick && pick.retryOnTimeout === true) return true;
|
|
110
|
+
if (pick && pick.retryOnTimeout === false) return false;
|
|
111
|
+
if (provider && PROVIDER_RETRY_ON_TIMEOUT[provider] === true) return true;
|
|
112
|
+
if (family && FAMILY_RETRY_ON_TIMEOUT[family] === true) return true;
|
|
113
|
+
// Default: API path retries on timeout; CLI does not.
|
|
114
|
+
return path === 'api';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Exported for tests.
|
|
118
|
+
export const _shouldRetryOnTimeout = shouldRetryOnTimeout;
|
|
119
|
+
|
|
38
120
|
function timeoutForPick(pick, resolvedTimeoutSec) {
|
|
39
121
|
if (resolvedTimeoutSec) return resolvedTimeoutSec * 1000;
|
|
122
|
+
// v1.5.0 S7: roster-level override takes precedence over family default.
|
|
123
|
+
// Codex needs 8min for review work vs 90s default; see audit-roster.js.
|
|
124
|
+
if (pick.timeoutMs) return pick.timeoutMs;
|
|
40
125
|
return PROVIDER_TIMEOUT_MS[pick.id] ?? DEFAULT_TIMEOUT_MS;
|
|
41
126
|
}
|
|
42
127
|
|
|
@@ -121,11 +206,72 @@ function angleFor(mode, id) {
|
|
|
121
206
|
// silently pick up an unrelated gcloud project (cloudaicompanion.googleapis.com
|
|
122
207
|
// billing collisions). Reproduced by Kat in issue #9.
|
|
123
208
|
//
|
|
124
|
-
//
|
|
209
|
+
// v1.5.0 audit-MED-trident-M2 (F-SEC-1): per-pick API-key allowlist.
|
|
210
|
+
// Previously every cross-fired auditor inherited the FULL parent env --
|
|
211
|
+
// codex saw GEMINI_API_KEY + DEEPSEEK_API_KEY + ANTHROPIC_API_KEY, gemini saw
|
|
212
|
+
// OPENAI_API_KEY, etc. A prompt-injected auditor could egress every key the
|
|
213
|
+
// host has loaded. We now whitelist the keys each auditor legitimately needs
|
|
214
|
+
// (its own auth + minimal POSIX baseline) and drop everything else from the
|
|
215
|
+
// inherited env. Non-secret config vars (PATH, HOME, LANG, IJFW_*, CODEX_*)
|
|
216
|
+
// pass through; vendor API keys are filtered.
|
|
217
|
+
//
|
|
218
|
+
// Precedence we still enforce when GEMINI_API_KEY is set:
|
|
125
219
|
// GEMINI_API_KEY (kept) > GOOGLE_APPLICATION_CREDENTIALS (dropped)
|
|
126
220
|
// > gcloud active-project env (dropped)
|
|
221
|
+
|
|
222
|
+
// Vendor API-key env-vars that MUST NOT leak across auditor boundaries.
|
|
223
|
+
// Any key matching this list is dropped unless explicitly re-added by the
|
|
224
|
+
// per-pick allowlist below.
|
|
225
|
+
const VENDOR_API_KEY_VARS = new Set([
|
|
226
|
+
'OPENAI_API_KEY',
|
|
227
|
+
'ANTHROPIC_API_KEY',
|
|
228
|
+
'GEMINI_API_KEY',
|
|
229
|
+
'GOOGLE_API_KEY',
|
|
230
|
+
'DEEPSEEK_API_KEY',
|
|
231
|
+
'DASHSCOPE_API_KEY', // qwen / Alibaba
|
|
232
|
+
'MOONSHOT_API_KEY', // kimi
|
|
233
|
+
'GROQ_API_KEY',
|
|
234
|
+
'XAI_API_KEY', // grok
|
|
235
|
+
'MISTRAL_API_KEY',
|
|
236
|
+
'COHERE_API_KEY',
|
|
237
|
+
'PERPLEXITY_API_KEY',
|
|
238
|
+
'TOGETHER_API_KEY',
|
|
239
|
+
'OPENROUTER_API_KEY',
|
|
240
|
+
'AZURE_OPENAI_API_KEY',
|
|
241
|
+
'GH_COPILOT_TOKEN',
|
|
242
|
+
'GITHUB_TOKEN',
|
|
243
|
+
]);
|
|
244
|
+
|
|
245
|
+
// Per-pick API-key allowlist. Each auditor sees ONLY the keys it needs.
|
|
246
|
+
// Unrecognized picks fall through to a conservative "no vendor keys" default
|
|
247
|
+
// (still inherits PATH/HOME/LANG/etc via the broader env, just no API keys).
|
|
248
|
+
const PER_PICK_API_KEY_ALLOWLIST = {
|
|
249
|
+
codex: ['OPENAI_API_KEY'],
|
|
250
|
+
copilot: ['OPENAI_API_KEY', 'GH_COPILOT_TOKEN', 'GITHUB_TOKEN'],
|
|
251
|
+
gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'],
|
|
252
|
+
claude: ['ANTHROPIC_API_KEY'],
|
|
253
|
+
deepseek: ['DEEPSEEK_API_KEY'],
|
|
254
|
+
qwen: ['DASHSCOPE_API_KEY'],
|
|
255
|
+
kimi: ['MOONSHOT_API_KEY'],
|
|
256
|
+
opencode: [],
|
|
257
|
+
aider: ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY'], // aider routes to either
|
|
258
|
+
};
|
|
259
|
+
|
|
127
260
|
export function buildSpawnEnv(pick, baseEnv) {
|
|
128
261
|
const env = { ...baseEnv };
|
|
262
|
+
|
|
263
|
+
// M2: strip every vendor API key, then re-inject only the ones this pick
|
|
264
|
+
// is on the allowlist for. The allowlist is keyed by pick.id; unknown ids
|
|
265
|
+
// get an empty allowlist (still safe — drops all vendor keys).
|
|
266
|
+
const pickId = pick && pick.id;
|
|
267
|
+
const allowed = PER_PICK_API_KEY_ALLOWLIST[pickId] || [];
|
|
268
|
+
const allowedSet = new Set(allowed);
|
|
269
|
+
for (const key of VENDOR_API_KEY_VARS) {
|
|
270
|
+
if (!allowedSet.has(key)) delete env[key];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Issue #9-A guard remains: if gemini is the pick AND its own key is set,
|
|
274
|
+
// also scrub gcloud project env vars so they don't silently override.
|
|
129
275
|
if (pick && pick.id === 'gemini' && env.GEMINI_API_KEY) {
|
|
130
276
|
delete env.GOOGLE_APPLICATION_CREDENTIALS;
|
|
131
277
|
delete env.GOOGLE_CLOUD_PROJECT;
|
|
@@ -255,23 +401,150 @@ async function fireExternal(pick, request, timeoutMs, env = process.env, signal
|
|
|
255
401
|
return { stdout: '', stderr: apiResult.error, exitCode: null, status: 'failed', source: 'none', elapsedMs: elapsed() };
|
|
256
402
|
}
|
|
257
403
|
|
|
258
|
-
|
|
404
|
+
let raw = await spawnCli(pick, request, timeoutMs, signal, env);
|
|
259
405
|
|
|
260
406
|
// Aborted by runAc
|
|
261
407
|
if (raw && raw.aborted) {
|
|
262
408
|
return { stdout: '', stderr: 'aborted', exitCode: null, status: 'aborted', source: 'none', elapsedMs: elapsed() };
|
|
263
409
|
}
|
|
264
410
|
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
|
|
411
|
+
// v1.5.0 audit-MED-tok-M8 + audit-MED-trident-M7 — Parallel retry-vs-fallback
|
|
412
|
+
// race, gated by the path-aware retry matrix.
|
|
413
|
+
//
|
|
414
|
+
// The retry policy is per-(provider,path) — CLI cold-start (path='cli') and
|
|
415
|
+
// API transient errors (path='api') are distinct failure modes with distinct
|
|
416
|
+
// retry profiles. shouldRetryOnTimeout() consults the matrix; an explicit
|
|
417
|
+
// `pick.retryMatrix[path] === false` or `pick.retryOnTimeout === false`
|
|
418
|
+
// always wins for caller opt-out.
|
|
419
|
+
//
|
|
420
|
+
// When BOTH a CLI retry AND an api-fallback are eligible, we RACE them
|
|
421
|
+
// concurrently and take the first productive result, capping the timeout-
|
|
422
|
+
// recovery budget at max(retry-timeout, api-mode-timeout) instead of the
|
|
423
|
+
// sequential sum (~25% wall-clock improvement on the gemini unhappy path).
|
|
424
|
+
// When only one channel is eligible, we fall back to sequential behaviour
|
|
425
|
+
// so the change is purely additive — same outcome whenever exactly one
|
|
426
|
+
// recovery channel exists.
|
|
427
|
+
if (raw && raw.timedOut && !signal?.aborted) {
|
|
428
|
+
const canRetry = shouldRetryOnTimeout(pick, 'cli');
|
|
429
|
+
const canFallback = Boolean(pick.apiFallback) && isReachable(pick.id, env).api;
|
|
430
|
+
|
|
431
|
+
if (canRetry && canFallback) {
|
|
432
|
+
// Race retry against api-fallback. Each path runs with its own abort
|
|
433
|
+
// controller; the loser is aborted as soon as we have a winner.
|
|
434
|
+
const retryAc = new AbortController();
|
|
435
|
+
const fallbackAc = new AbortController();
|
|
436
|
+
// Honour the parent runAc abort by cascading into both.
|
|
437
|
+
const onParentAbort = () => { retryAc.abort(); fallbackAc.abort(); };
|
|
438
|
+
if (signal) {
|
|
439
|
+
if (signal.aborted) onParentAbort();
|
|
440
|
+
else signal.addEventListener('abort', onParentAbort, { once: true });
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const retryPromise = spawnCli(pick, request, timeoutMs, retryAc.signal, env)
|
|
444
|
+
.then(r => ({ kind: 'retry', raw: r }));
|
|
445
|
+
|
|
268
446
|
const { mode, angle, target } = extractApiParams();
|
|
269
|
-
|
|
447
|
+
// M7: api-path retry on transient timeout (separate from CLI policy).
|
|
448
|
+
// Wrapped in an async IIFE so the per-path retry happens inside the
|
|
449
|
+
// single racing promise.
|
|
450
|
+
const fallbackPromise = (async () => {
|
|
451
|
+
let api = await runViaApi(pick, mode, angle, target, env, PROVIDER_TIMEOUT_MS['api-mode'], fallbackAc.signal);
|
|
452
|
+
if (api.status !== 'ok' && shouldRetryOnTimeout(pick, 'api') && !fallbackAc.signal.aborted) {
|
|
453
|
+
api = await runViaApi(pick, mode, angle, target, env, PROVIDER_TIMEOUT_MS['api-mode'], fallbackAc.signal);
|
|
454
|
+
}
|
|
455
|
+
return { kind: 'fallback', api };
|
|
456
|
+
})();
|
|
457
|
+
|
|
458
|
+
// We want the FIRST PRODUCTIVE result. Promise.race() returns whichever
|
|
459
|
+
// settles first regardless of productivity, so we iterate manually:
|
|
460
|
+
// if the first to resolve is "unproductive" (retry timed out, fallback
|
|
461
|
+
// errored), we await the other.
|
|
462
|
+
const settled = { retry: null, fallback: null };
|
|
463
|
+
let winner = null;
|
|
464
|
+
|
|
465
|
+
// Helper: did this kind produce a usable result?
|
|
466
|
+
const isProductive = (kind) => {
|
|
467
|
+
if (kind === 'retry') {
|
|
468
|
+
const r = settled.retry;
|
|
469
|
+
return Boolean(r && !r.aborted && !r.timedOut && r.exitCode === 0);
|
|
470
|
+
}
|
|
471
|
+
if (kind === 'fallback') {
|
|
472
|
+
return settled.fallback && settled.fallback.status === 'ok';
|
|
473
|
+
}
|
|
474
|
+
return false;
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
// Resolve as soon as ONE side returns a productive result, OR both have settled.
|
|
478
|
+
await new Promise((resolve) => {
|
|
479
|
+
let pending = 2;
|
|
480
|
+
let done = false;
|
|
481
|
+
const finish = (kind) => {
|
|
482
|
+
if (done) return;
|
|
483
|
+
if (isProductive(kind)) {
|
|
484
|
+
done = true;
|
|
485
|
+
winner = kind;
|
|
486
|
+
// Abort the loser so it stops consuming budget.
|
|
487
|
+
if (kind === 'retry') fallbackAc.abort();
|
|
488
|
+
else retryAc.abort();
|
|
489
|
+
resolve();
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
if (--pending === 0) {
|
|
493
|
+
done = true;
|
|
494
|
+
resolve();
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
retryPromise.then(({ raw: r }) => { settled.retry = r; finish('retry'); })
|
|
498
|
+
.catch(() => { settled.retry = null; finish('retry'); });
|
|
499
|
+
fallbackPromise.then(({ api: a }) => { settled.fallback = a; finish('fallback'); })
|
|
500
|
+
.catch(() => { settled.fallback = null; finish('fallback'); });
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
// Detach parent-abort listener if it didn't already fire.
|
|
504
|
+
if (signal) {
|
|
505
|
+
try { signal.removeEventListener('abort', onParentAbort); } catch {}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Decision matrix on the raced outcome.
|
|
509
|
+
if (winner === 'fallback' && settled.fallback && settled.fallback.status === 'ok') {
|
|
510
|
+
return { stdout: settled.fallback.raw, stderr: '', exitCode: 0, status: 'fallback-used', source: 'api', elapsedMs: elapsed() };
|
|
511
|
+
}
|
|
512
|
+
if (winner === 'retry' && settled.retry && settled.retry.exitCode === 0) {
|
|
513
|
+
// CLI second attempt landed -- use it as if it were the original.
|
|
514
|
+
raw = settled.retry;
|
|
515
|
+
} else {
|
|
516
|
+
// Neither path produced a productive result. Prefer the most-informative
|
|
517
|
+
// failure status: an explicit fallback error beats a bare timeout.
|
|
518
|
+
if (settled.fallback && settled.fallback.status !== 'ok' && settled.fallback.error) {
|
|
519
|
+
return { stdout: '', stderr: 'timeout-and-fallback-failed', exitCode: null, status: 'timeout', source: 'none', elapsedMs: elapsed() };
|
|
520
|
+
}
|
|
521
|
+
return { stdout: '', stderr: 'timeout', exitCode: null, status: 'timeout', source: 'none', elapsedMs: elapsed() };
|
|
522
|
+
}
|
|
523
|
+
} else if (canRetry) {
|
|
524
|
+
// Retry-only path (no api-fallback eligible): single sequential CLI retry.
|
|
525
|
+
raw = await spawnCli(pick, request, timeoutMs, signal, env);
|
|
526
|
+
if (raw && raw.aborted) {
|
|
527
|
+
return { stdout: '', stderr: 'aborted-after-retry', exitCode: null, status: 'aborted', source: 'none', elapsedMs: elapsed() };
|
|
528
|
+
}
|
|
529
|
+
if (raw && raw.timedOut) {
|
|
530
|
+
return { stdout: '', stderr: 'timeout', exitCode: null, status: 'timeout', source: 'none', elapsedMs: elapsed() };
|
|
531
|
+
}
|
|
532
|
+
} else if (canFallback) {
|
|
533
|
+
// Fallback-only path (no CLI retry eligible): API fallback with M7
|
|
534
|
+
// path-aware retry on transient API timeout.
|
|
535
|
+
const { mode, angle, target } = extractApiParams();
|
|
536
|
+
let apiResult = await runViaApi(pick, mode, angle, target, env, PROVIDER_TIMEOUT_MS['api-mode'], signal);
|
|
537
|
+
if (apiResult.status !== 'ok' && shouldRetryOnTimeout(pick, 'api') && !signal?.aborted) {
|
|
538
|
+
apiResult = await runViaApi(pick, mode, angle, target, env, PROVIDER_TIMEOUT_MS['api-mode'], signal);
|
|
539
|
+
}
|
|
270
540
|
if (apiResult.status === 'ok') {
|
|
271
541
|
return { stdout: apiResult.raw, stderr: '', exitCode: 0, status: 'fallback-used', source: 'api', elapsedMs: elapsed() };
|
|
272
542
|
}
|
|
543
|
+
return { stdout: '', stderr: 'timeout', exitCode: null, status: 'timeout', source: 'none', elapsedMs: elapsed() };
|
|
544
|
+
} else {
|
|
545
|
+
// Neither retry nor fallback eligible: nothing more we can do.
|
|
546
|
+
return { stdout: '', stderr: 'timeout', exitCode: null, status: 'timeout', source: 'none', elapsedMs: elapsed() };
|
|
273
547
|
}
|
|
274
|
-
return { stdout: '', stderr: 'timeout', exitCode: null, status: 'timeout', source: 'none', elapsedMs: elapsed() };
|
|
275
548
|
}
|
|
276
549
|
|
|
277
550
|
// CLI failed -- try API fallback
|
|
@@ -486,25 +759,31 @@ async function runPhaseEAuto({ projectDir, phase, target, env, quiet }) {
|
|
|
486
759
|
const auditorResults = rawResults.map((raw, i) => {
|
|
487
760
|
const pick = picks[i];
|
|
488
761
|
if (raw === null) {
|
|
489
|
-
return { status: 'failed', parsed: { items: [], prose: `[${pick.id}: spawn failed]` } };
|
|
762
|
+
return { status: 'failed', counted: false, parsed: { items: [], prose: `[${pick.id}: spawn failed]` } };
|
|
490
763
|
}
|
|
491
764
|
const { stdout, exitCode, status: rawStatus } = raw;
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
765
|
+
// v1.5.0 S7: non-productive results MUST NOT count toward synthesis verdict.
|
|
766
|
+
// A hung CLI returning zero items would silently produce a PASS under the
|
|
767
|
+
// old logic (classifyVerdict([]) === 'PASS'). counted:false isolates them.
|
|
768
|
+
if (rawStatus === 'timeout') return { status: 'timeout', counted: false, parsed: { items: [], prose: `[${pick.id}: timeout]` } };
|
|
769
|
+
if (rawStatus === 'failed') return { status: 'failed', counted: false, parsed: { items: [], prose: `[${pick.id}: failed]` } };
|
|
770
|
+
if (rawStatus === 'aborted') return { status: 'aborted', counted: false, parsed: { items: [], prose: `[${pick.id}: aborted]` } };
|
|
495
771
|
if (rawStatus === 'fallback-used') {
|
|
496
772
|
const p = parseResponse('audit', stdout);
|
|
497
|
-
return { status: 'fallback-used', parsed: p };
|
|
773
|
+
return { status: 'fallback-used', counted: true, parsed: p };
|
|
498
774
|
}
|
|
499
|
-
if (exitCode !== 0) return { status: 'failed', parsed: { items: [], prose: `[${pick.id}: exited ${exitCode}]` } };
|
|
775
|
+
if (exitCode !== 0) return { status: 'failed', counted: false, parsed: { items: [], prose: `[${pick.id}: exited ${exitCode}]` } };
|
|
500
776
|
const p = parseResponse('audit', stdout);
|
|
501
|
-
return { status: 'ok', parsed: p };
|
|
777
|
+
return { status: 'ok', counted: true, parsed: p };
|
|
502
778
|
});
|
|
503
779
|
|
|
504
|
-
const
|
|
780
|
+
const productive = auditorResults.filter(r => r.counted);
|
|
781
|
+
const parsed = productive.map(r => r.parsed);
|
|
505
782
|
const merged = mergeResponses('audit', parsed);
|
|
506
783
|
const items = Array.isArray(merged) ? merged : [];
|
|
507
|
-
|
|
784
|
+
// v1.5.0 S7: when zero auditors return productive output, the verdict is
|
|
785
|
+
// INCONCLUSIVE — refuses to grant PASS from a hung-CLI floor.
|
|
786
|
+
const verdict = productive.length === 0 ? 'INCONCLUSIVE' : classifyVerdict(items);
|
|
508
787
|
|
|
509
788
|
// Write synthesis to .planning/<phase>/CROSS-AUDIT-r<N>.md
|
|
510
789
|
const outputPath = resolveAuditOutputPath(projectDir, phase);
|
|
@@ -766,3 +1045,723 @@ export async function runCrossOp({
|
|
|
766
1045
|
accept_degraded: !!accept_degraded,
|
|
767
1046
|
};
|
|
768
1047
|
}
|
|
1048
|
+
|
|
1049
|
+
// ---------------------------------------------------------------------------
|
|
1050
|
+
// v1.5.0 W12-C N01+N03 — runPhaseEConverge
|
|
1051
|
+
// ---------------------------------------------------------------------------
|
|
1052
|
+
//
|
|
1053
|
+
// Multi-lens consensus convergence (lock-in #47 — canonical Phase E).
|
|
1054
|
+
// Single-shot Phase E is the FALLBACK. Default behavior is a convergence
|
|
1055
|
+
// loop with divergence detection.
|
|
1056
|
+
//
|
|
1057
|
+
// Per-iteration flow:
|
|
1058
|
+
// 1. Dispatch all lenses in parallel.
|
|
1059
|
+
// 2. Collect verdicts (PASS / CONDITIONAL / FAIL) + finding lists.
|
|
1060
|
+
// 3. If all PASS → done.
|
|
1061
|
+
// 4. If verdicts diverge, build a CYCLE_SUMMARY (prior verdicts +
|
|
1062
|
+
// areas of disagreement) and re-dispatch with it injected.
|
|
1063
|
+
// 5. Cap at `maxIterations` (default 3). If still divergent at the cap,
|
|
1064
|
+
// emit `consensus_failed` and surface divergence.
|
|
1065
|
+
// 6. Stall breaker: if iter N produces byte-identical findings to
|
|
1066
|
+
// iter N-1, halt with `stalled: true` rather than burn tokens.
|
|
1067
|
+
//
|
|
1068
|
+
// Dispatcher contract (for test/DI):
|
|
1069
|
+
// dispatch({ lens, commitRange, iteration, cycleSummary }) →
|
|
1070
|
+
// { lens, verdict: 'PASS'|'CONDITIONAL'|'FAIL'|'UNREACHABLE',
|
|
1071
|
+
// findings: Array<{severity?, text?, ...}> }
|
|
1072
|
+
//
|
|
1073
|
+
// Tests inject a synthetic dispatcher; production passes a real one
|
|
1074
|
+
// that wraps `fireExternal` / `parseResponse` / `classifyVerdict` (or
|
|
1075
|
+
// reuses runCrossOp for each iteration).
|
|
1076
|
+
// ---------------------------------------------------------------------------
|
|
1077
|
+
|
|
1078
|
+
const VERDICT_PASS = 'PASS';
|
|
1079
|
+
const VERDICT_CONDITIONAL = 'CONDITIONAL';
|
|
1080
|
+
const VERDICT_FAIL = 'FAIL';
|
|
1081
|
+
const VERDICT_UNREACHABLE = 'UNREACHABLE';
|
|
1082
|
+
const VERDICT_CONSENSUS_FAIL = 'consensus_failed';
|
|
1083
|
+
const DEFAULT_LENSES = ['codex', 'gemini', 'claude'];
|
|
1084
|
+
|
|
1085
|
+
// v1.5.0 audit-H4.1 — hard upper bound on convergence iterations. A caller
|
|
1086
|
+
// asking for 100 rounds would burn 100 rounds of full Trident dispatch (~3
|
|
1087
|
+
// auditors × ~90s = ~4.5h per cycle on cold start). 10 is well above the
|
|
1088
|
+
// observed empirical ceiling — the convergence loop almost always settles in
|
|
1089
|
+
// 2-3 iters; >5 is a smell, >10 is a misuse. Anything above the cap is
|
|
1090
|
+
// silently clamped to MAX_CONVERGE_ITERATIONS + emits a single dedup'd warning.
|
|
1091
|
+
export const MAX_CONVERGE_ITERATIONS = 10;
|
|
1092
|
+
|
|
1093
|
+
// Module-level dedup set for max-iterations clamp warnings. One stderr line
|
|
1094
|
+
// per unique requested value per process lifetime (same pattern as
|
|
1095
|
+
// cross-project-search SKIP_LOG).
|
|
1096
|
+
const _MAX_ITER_WARN_LOG = new Set();
|
|
1097
|
+
|
|
1098
|
+
/** Reset the max-iter warn-log dedup set. Test-only. */
|
|
1099
|
+
export function _resetMaxIterWarnLog() {
|
|
1100
|
+
_MAX_ITER_WARN_LOG.clear();
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// Stable serialization for stall comparison.
|
|
1104
|
+
function stableFindingsKey(perLensFindings) {
|
|
1105
|
+
// perLensFindings: { lens → Array<finding> }
|
|
1106
|
+
// Use lens-sorted keys, and within each lens stringify findings (sorted by
|
|
1107
|
+
// a stable text representation) so semantically-identical iterations match.
|
|
1108
|
+
const lenses = Object.keys(perLensFindings).sort();
|
|
1109
|
+
const parts = [];
|
|
1110
|
+
for (const lens of lenses) {
|
|
1111
|
+
const findings = Array.isArray(perLensFindings[lens]) ? perLensFindings[lens] : [];
|
|
1112
|
+
const serialized = findings
|
|
1113
|
+
.map(f => JSON.stringify(f, Object.keys(f || {}).sort()))
|
|
1114
|
+
.sort();
|
|
1115
|
+
parts.push(`${lens}:[${serialized.join(',')}]`);
|
|
1116
|
+
}
|
|
1117
|
+
return parts.join('|');
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// Detect whether lens verdicts diverge.
|
|
1121
|
+
// All-PASS (with no UNREACHABLE) → false. Anything else with mixed verdicts → true.
|
|
1122
|
+
// All-FAIL across reachable lenses → still divergent only if findings differ;
|
|
1123
|
+
// pure consensus FAIL is not divergence (everyone agrees the change is bad).
|
|
1124
|
+
function detectDivergence(lensResults) {
|
|
1125
|
+
const reachable = lensResults.filter(r => r.verdict !== VERDICT_UNREACHABLE);
|
|
1126
|
+
if (reachable.length === 0) return { divergent: false, axes: [] };
|
|
1127
|
+
const verdicts = new Set(reachable.map(r => r.verdict));
|
|
1128
|
+
if (verdicts.size === 1) {
|
|
1129
|
+
// All reachable lenses agree on the verdict. Findings can still differ
|
|
1130
|
+
// (one lens may report extra MEDIUM findings), but verdict consensus is
|
|
1131
|
+
// sufficient — convergence is verdict-level, not finding-level.
|
|
1132
|
+
return { divergent: false, axes: [] };
|
|
1133
|
+
}
|
|
1134
|
+
// Verdicts diverge: compute axes (which lens differs from the majority).
|
|
1135
|
+
const counts = {};
|
|
1136
|
+
for (const r of reachable) counts[r.verdict] = (counts[r.verdict] || 0) + 1;
|
|
1137
|
+
let majority = reachable[0].verdict;
|
|
1138
|
+
let max = 0;
|
|
1139
|
+
for (const v of Object.keys(counts)) {
|
|
1140
|
+
if (counts[v] > max) { max = counts[v]; majority = v; }
|
|
1141
|
+
}
|
|
1142
|
+
const axes = reachable
|
|
1143
|
+
.filter(r => r.verdict !== majority)
|
|
1144
|
+
.map(r => ({ lens: r.lens, verdict: r.verdict, majority }));
|
|
1145
|
+
return { divergent: true, axes };
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// Build a CYCLE_SUMMARY string injected into the next iteration's lens brief.
|
|
1149
|
+
function buildCycleSummary(iteration, prior) {
|
|
1150
|
+
const lines = [
|
|
1151
|
+
`# CYCLE_SUMMARY (iteration ${iteration})`,
|
|
1152
|
+
'',
|
|
1153
|
+
'Previous round verdicts:',
|
|
1154
|
+
];
|
|
1155
|
+
for (const r of prior.lensResults) {
|
|
1156
|
+
lines.push(`- ${r.lens}: ${r.verdict} (${(r.findings || []).length} findings)`);
|
|
1157
|
+
}
|
|
1158
|
+
if (prior.divergence && prior.divergence.axes && prior.divergence.axes.length > 0) {
|
|
1159
|
+
lines.push('');
|
|
1160
|
+
lines.push('Areas of disagreement (lens vs majority):');
|
|
1161
|
+
for (const a of prior.divergence.axes) {
|
|
1162
|
+
lines.push(`- ${a.lens} said ${a.verdict}; majority said ${a.majority}`);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
lines.push('');
|
|
1166
|
+
lines.push('Re-evaluate. If your prior verdict was correct, restate it with explicit reasoning. If the other lenses changed your mind, update accordingly.');
|
|
1167
|
+
return lines.join('\n');
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// Public convergence entrypoint.
|
|
1171
|
+
// Inputs:
|
|
1172
|
+
// commitRange string (e.g. 'HEAD~1..HEAD')
|
|
1173
|
+
// lenses array of lens ids (default ['codex','gemini','claude'])
|
|
1174
|
+
// maxIterations number (default 3; lock-in #43/#44 style)
|
|
1175
|
+
// dispatch async function (DI hook for tests/production)
|
|
1176
|
+
// projectRoot string (passed through to dispatch)
|
|
1177
|
+
// totalTimeoutMs v1.5.0 audit-MED-trident-M6 — cumulative wall-clock cap.
|
|
1178
|
+
// When set, an AbortController fires at the deadline and
|
|
1179
|
+
// cancels remaining iterations. 3 iters × 3 lenses × 90s =
|
|
1180
|
+
// 270s worst case without a cap; this lets a caller say
|
|
1181
|
+
// "no more than 4 minutes for the whole convergence".
|
|
1182
|
+
// Defaults to env IJFW_AUDIT_CONVERGE_TOTAL_TIMEOUT_SEC.
|
|
1183
|
+
// perLensBudgetUsd v1.5.0 audit-MED-trident-M5 — per-lens USD cap across
|
|
1184
|
+
// this convergence cycle. Aborts a lens once its
|
|
1185
|
+
// cumulative cost in this run exceeds the cap. Defaults
|
|
1186
|
+
// to env IJFW_AUDIT_BUDGET_USD_PER_LENS.
|
|
1187
|
+
// Returns:
|
|
1188
|
+
// { verdict, iterations, findings, divergence?, stalled?, perIteration,
|
|
1189
|
+
// timedOutTotal?, lensesOverBudget?, lensCosts }
|
|
1190
|
+
export async function runPhaseEConverge({
|
|
1191
|
+
commitRange,
|
|
1192
|
+
lenses = DEFAULT_LENSES,
|
|
1193
|
+
maxIterations = 3,
|
|
1194
|
+
dispatch,
|
|
1195
|
+
projectRoot,
|
|
1196
|
+
projectDir, // v1.5.0 audit-H4.5 — receipts destination (defaults to projectRoot)
|
|
1197
|
+
runStamp, // v1.5.0 audit-H4.5 — caller-supplied stamp; auto if absent
|
|
1198
|
+
totalTimeoutMs, // v1.5.0 audit-MED-trident-M6 — cumulative timeout
|
|
1199
|
+
perLensBudgetUsd, // v1.5.0 audit-MED-trident-M5 — per-lens USD cap
|
|
1200
|
+
keepaliveOnTick, // v1.5.0 wire-W1.B — caller-supplied keepalive heartbeat
|
|
1201
|
+
env = process.env,
|
|
1202
|
+
} = {}) {
|
|
1203
|
+
if (typeof dispatch !== 'function') {
|
|
1204
|
+
throw new Error('runPhaseEConverge: dispatch function is required');
|
|
1205
|
+
}
|
|
1206
|
+
if (!Array.isArray(lenses) || lenses.length === 0) {
|
|
1207
|
+
throw new Error('runPhaseEConverge: lenses must be a non-empty array');
|
|
1208
|
+
}
|
|
1209
|
+
// v1.5.0 audit-H4.1 — clamp to [1, MAX_CONVERGE_ITERATIONS]. Anything above
|
|
1210
|
+
// the cap is silently coerced; one-line stderr warning per unique requested
|
|
1211
|
+
// value (Set dedup) so a caller running the same misconfigured tool 100
|
|
1212
|
+
// times doesn't spam the log.
|
|
1213
|
+
const requested = Math.max(1, Math.floor(maxIterations));
|
|
1214
|
+
const cap = Math.min(requested, MAX_CONVERGE_ITERATIONS);
|
|
1215
|
+
if (requested > MAX_CONVERGE_ITERATIONS) {
|
|
1216
|
+
const key = String(requested);
|
|
1217
|
+
if (!_MAX_ITER_WARN_LOG.has(key)) {
|
|
1218
|
+
_MAX_ITER_WARN_LOG.add(key);
|
|
1219
|
+
process.stderr.write(
|
|
1220
|
+
`runPhaseEConverge: maxIterations=${requested} clamped to MAX_CONVERGE_ITERATIONS=${MAX_CONVERGE_ITERATIONS}.\n`
|
|
1221
|
+
);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// v1.5.0 audit-H4.5 — receipts wiring. The flagship converge tool was
|
|
1226
|
+
// previously invisible to `ijfw status` and the dashboard because no receipt
|
|
1227
|
+
// was written. Capture start time + per-cycle finding counts here, write
|
|
1228
|
+
// ONE summary receipt before each return path (writeReceiptIfPossible).
|
|
1229
|
+
const _startMs = Date.now();
|
|
1230
|
+
const _receiptCycles = []; // [{ iteration, findingCount, lensVerdicts: {lens:verdict} }]
|
|
1231
|
+
let _totalInvocations = 0; // dispatch calls across all iterations
|
|
1232
|
+
|
|
1233
|
+
const _resolvedProjectDir = projectDir ?? projectRoot ?? process.cwd();
|
|
1234
|
+
const _resolvedRunStamp = runStamp ?? new Date().toISOString();
|
|
1235
|
+
|
|
1236
|
+
// v1.5.0 T21 — telemetry accumulators for the three metrics published via
|
|
1237
|
+
// the state-SDK `telemetry.record` verb at finalize time.
|
|
1238
|
+
// * _telemetryAlarms — count of (lens, cycle) observations whose verdict
|
|
1239
|
+
// was FAIL or CONDITIONAL (i.e. "raised an alarm"). The false-positive
|
|
1240
|
+
// rate compares this against the final consensus verdict.
|
|
1241
|
+
// * _telemetryReachableObs — count of reachable (lens, cycle) observations
|
|
1242
|
+
// (excludes UNREACHABLE/budget-capped) — the denominator for the rate.
|
|
1243
|
+
// Cost is read off `_lensCosts` (already accumulated) at finalize.
|
|
1244
|
+
let _telemetryAlarms = 0;
|
|
1245
|
+
let _telemetryReachableObs = 0;
|
|
1246
|
+
|
|
1247
|
+
// v1.5.0 audit-MED-trident-M6 — cumulative-timeout AbortController.
|
|
1248
|
+
// Either an arg-supplied totalTimeoutMs or env var; >0 enables. The signal
|
|
1249
|
+
// is checked between iterations + passed to dispatch (dispatchers that
|
|
1250
|
+
// honor `signal` will tear down in-flight lens calls).
|
|
1251
|
+
const _envTotalSec = env && env.IJFW_AUDIT_CONVERGE_TOTAL_TIMEOUT_SEC;
|
|
1252
|
+
const _resolvedTotalMs = (typeof totalTimeoutMs === 'number' && totalTimeoutMs > 0)
|
|
1253
|
+
? totalTimeoutMs
|
|
1254
|
+
: (Number.isFinite(Number(_envTotalSec)) && Number(_envTotalSec) > 0
|
|
1255
|
+
? Math.floor(Number(_envTotalSec) * 1000)
|
|
1256
|
+
: null);
|
|
1257
|
+
const _cycleAc = new AbortController();
|
|
1258
|
+
let _totalTimedOut = false;
|
|
1259
|
+
let _totalTimer = null;
|
|
1260
|
+
if (_resolvedTotalMs) {
|
|
1261
|
+
_totalTimer = setTimeout(() => {
|
|
1262
|
+
_totalTimedOut = true;
|
|
1263
|
+
try { _cycleAc.abort(); } catch { /* ignore */ }
|
|
1264
|
+
}, _resolvedTotalMs);
|
|
1265
|
+
// Don't keep the event loop alive purely for this timer.
|
|
1266
|
+
if (typeof _totalTimer.unref === 'function') _totalTimer.unref();
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// v1.5.0 wire-W1.B — Anthropic ephemeral-cache keepalive heartbeat.
|
|
1270
|
+
// Production wiring for `mcp-server/src/lib/cache-keepalive.js`. A long
|
|
1271
|
+
// Trident convergence (5+ minutes of sequential lens calls separated by
|
|
1272
|
+
// CLI / API latency) can lose cache hits mid-wave because the 5-min TTL
|
|
1273
|
+
// expires between calls even though every call re-sends the same cache-
|
|
1274
|
+
// eligible prefix. The lib provides an opt-in heartbeat that fires a
|
|
1275
|
+
// no-op on a configurable interval to keep the cache warm.
|
|
1276
|
+
//
|
|
1277
|
+
// Default OFF (intervalMs=0 when IJFW_CACHE_KEEPALIVE_MS env is unset).
|
|
1278
|
+
// When the env var is set in the [1000, 300000] range, the heartbeat
|
|
1279
|
+
// fires every N ms until convergence completes or the cumulative-timeout
|
|
1280
|
+
// signal aborts (signal is passed through so the cap cascades into
|
|
1281
|
+
// keepalive too).
|
|
1282
|
+
//
|
|
1283
|
+
// Every tick increments a counter (surfaced on the return value + receipt
|
|
1284
|
+
// for observability). Production callers can additionally supply their own
|
|
1285
|
+
// onTick via `keepaliveOnTick` arg (e.g. to ping an API endpoint that
|
|
1286
|
+
// re-warms the cache prefix); it runs alongside the counter, not instead.
|
|
1287
|
+
let _keepaliveTicks = 0;
|
|
1288
|
+
const _keepalive = startKeepaliveFromEnv({
|
|
1289
|
+
// r21-LOW: count every tick regardless of whether a custom onTick is
|
|
1290
|
+
// supplied. Previously the counter only ran on the default path, so
|
|
1291
|
+
// runs that passed keepaliveOnTick under-reported as zero ticks.
|
|
1292
|
+
onTick: () => {
|
|
1293
|
+
_keepaliveTicks += 1;
|
|
1294
|
+
if (typeof keepaliveOnTick === 'function') {
|
|
1295
|
+
try { keepaliveOnTick(); } catch { /* keepalive errors must never crash the wave */ }
|
|
1296
|
+
}
|
|
1297
|
+
},
|
|
1298
|
+
onError: () => { /* keepalive errors must never crash the wave */ },
|
|
1299
|
+
signal: _cycleAc.signal,
|
|
1300
|
+
env,
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
// v1.5.0 audit-MED-trident-M5 — per-lens budget. Tracks cumulative cost
|
|
1304
|
+
// attributed to each lens across this converge cycle; abort that lens once
|
|
1305
|
+
// its accumulated cost > cap. Cost source priority on dispatch return:
|
|
1306
|
+
// 1. result.cost_usd
|
|
1307
|
+
// 2. result.usage?.cost_usd
|
|
1308
|
+
// 3. 0 (unknown — counted as 0, can still hit cap via N iterations)
|
|
1309
|
+
const _envPerLensCap = env && env.IJFW_AUDIT_BUDGET_USD_PER_LENS;
|
|
1310
|
+
const _resolvedPerLensCap = (typeof perLensBudgetUsd === 'number' && perLensBudgetUsd > 0)
|
|
1311
|
+
? perLensBudgetUsd
|
|
1312
|
+
: (Number.isFinite(Number(_envPerLensCap)) && Number(_envPerLensCap) > 0
|
|
1313
|
+
? Number(_envPerLensCap)
|
|
1314
|
+
: null);
|
|
1315
|
+
const _lensCosts = Object.create(null);
|
|
1316
|
+
const _lensOverBudget = new Set();
|
|
1317
|
+
for (const lens of lenses) _lensCosts[lens] = 0;
|
|
1318
|
+
|
|
1319
|
+
async function _finalize(returnVal) {
|
|
1320
|
+
// Clear cumulative-timeout timer so the process can exit cleanly.
|
|
1321
|
+
if (_totalTimer) {
|
|
1322
|
+
clearTimeout(_totalTimer);
|
|
1323
|
+
_totalTimer = null;
|
|
1324
|
+
}
|
|
1325
|
+
// v1.5.0 wire-W1.B — sample the active flag BEFORE cancel. isActive()
|
|
1326
|
+
// returns false once cancelled, so reading it after teardown (r21-MED)
|
|
1327
|
+
// would under-report a heartbeat that was wired and running this wave.
|
|
1328
|
+
const _keepaliveActive = _keepalive.isActive();
|
|
1329
|
+
// Cancel keepalive (idempotent; no-op when never started).
|
|
1330
|
+
try { _keepalive.cancel(); } catch { /* never throws */ }
|
|
1331
|
+
// M5/M6: surface lens-budget + total-timeout posture on the return value
|
|
1332
|
+
// so callers can branch on partial-completion. lensCosts is always
|
|
1333
|
+
// present (even when no cap was set) so observability is consistent.
|
|
1334
|
+
const enriched = {
|
|
1335
|
+
...returnVal,
|
|
1336
|
+
lensCosts: { ..._lensCosts },
|
|
1337
|
+
...(_lensOverBudget.size > 0 ? { lensesOverBudget: [..._lensOverBudget] } : {}),
|
|
1338
|
+
...(_totalTimedOut ? { timedOutTotal: true } : {}),
|
|
1339
|
+
// wire-W1.B observability: tick count + whether the heartbeat was wired
|
|
1340
|
+
// at all for this run (proves the lib is live, not just imported).
|
|
1341
|
+
keepaliveTicks: _keepaliveTicks,
|
|
1342
|
+
keepaliveWired: _keepaliveActive || _keepaliveTicks > 0,
|
|
1343
|
+
};
|
|
1344
|
+
// Build + write the converge receipt. Failure to write must NOT clobber
|
|
1345
|
+
// the orchestrator return value (defensive — receipts are observability,
|
|
1346
|
+
// not correctness).
|
|
1347
|
+
try {
|
|
1348
|
+
const record = {
|
|
1349
|
+
v: 1,
|
|
1350
|
+
timestamp: new Date().toISOString(),
|
|
1351
|
+
run_stamp: _resolvedRunStamp,
|
|
1352
|
+
mode: 'converge',
|
|
1353
|
+
target: commitRange,
|
|
1354
|
+
// Receipt-compatible auditor list (renderReceipt reads .id from each).
|
|
1355
|
+
auditors: lenses.map(id => ({ id })),
|
|
1356
|
+
// findings shape matches renderReceipt's array branch.
|
|
1357
|
+
findings: { items: Array.isArray(enriched.findings) ? enriched.findings : [] },
|
|
1358
|
+
duration_ms: Date.now() - _startMs,
|
|
1359
|
+
// Converge-specific fields (new — minimal addition; renderReceipt
|
|
1360
|
+
// ignores unknown keys, so existing receipt renderers keep working).
|
|
1361
|
+
converge: {
|
|
1362
|
+
iterations: enriched.iterations,
|
|
1363
|
+
verdict: enriched.verdict,
|
|
1364
|
+
stalled: !!enriched.stalled,
|
|
1365
|
+
divergent: Array.isArray(enriched.divergence) && enriched.divergence.length > 0,
|
|
1366
|
+
total_invocations: _totalInvocations,
|
|
1367
|
+
cycles: _receiptCycles,
|
|
1368
|
+
requested_max_iterations: requested,
|
|
1369
|
+
effective_max_iterations: cap,
|
|
1370
|
+
// M5/M6 observability.
|
|
1371
|
+
total_timeout_ms: _resolvedTotalMs,
|
|
1372
|
+
timed_out_total: _totalTimedOut,
|
|
1373
|
+
per_lens_budget_usd: _resolvedPerLensCap,
|
|
1374
|
+
lens_costs: _lensCosts,
|
|
1375
|
+
lenses_over_budget: [..._lensOverBudget],
|
|
1376
|
+
// wire-W1.B observability: how many keepalive heartbeats fired
|
|
1377
|
+
// during this convergence; 0 means the lib was wired but the env
|
|
1378
|
+
// opt-in was off, >0 proves the heartbeat ran in production.
|
|
1379
|
+
keepalive_ticks: _keepaliveTicks,
|
|
1380
|
+
},
|
|
1381
|
+
};
|
|
1382
|
+
writeReceipt(_resolvedProjectDir, record);
|
|
1383
|
+
} catch {
|
|
1384
|
+
// Receipts are observability; never fail the converge run on a write error.
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
// v1.5.0 T21 (W4) — convergence telemetry via state-SDK `telemetry.record`.
|
|
1388
|
+
//
|
|
1389
|
+
// NOTE — this `_finalize` IIFE inside an async function chain: the
|
|
1390
|
+
// telemetry recorder needs to publish BEFORE the function returns so
|
|
1391
|
+
// callers (and tests) can read `.ijfw/telemetry/convergence.json`
|
|
1392
|
+
// synchronously after `await runPhaseEConverge(...)`. We therefore
|
|
1393
|
+
// shape `_finalize` to be async and await the telemetry write here.
|
|
1394
|
+
// The await is inside a try/catch that swallows everything — failure
|
|
1395
|
+
// to write telemetry never changes the convergence verdict.
|
|
1396
|
+
//
|
|
1397
|
+
// The three metrics are computed here AFTER the final verdict is known so
|
|
1398
|
+
// the false-positive rate has a stable consensus reference point. Failure
|
|
1399
|
+
// to record telemetry MUST NOT corrupt or override the convergence verdict:
|
|
1400
|
+
// wrapped in try/catch with full swallow per the off-the-critical-path
|
|
1401
|
+
// discipline. The convergence return value (`enriched`) is unchanged.
|
|
1402
|
+
//
|
|
1403
|
+
// Metric definitions (locked here so downstream dashboards and the
|
|
1404
|
+
// STATE-SDK contract §7 telemetry.record consumers can rely on them):
|
|
1405
|
+
//
|
|
1406
|
+
// 1. cyclesToConverge (integer ≥ 1, ≤ MAX_CONVERGE_ITERATIONS):
|
|
1407
|
+
// The iteration count at which the loop terminated. Equals
|
|
1408
|
+
// `enriched.iterations` — i.e. the number of dispatch rounds
|
|
1409
|
+
// actually executed before a stop condition (PASS consensus,
|
|
1410
|
+
// non-PASS consensus short-circuit, byte-identical stall,
|
|
1411
|
+
// cap reached, total-timeout, or all-budget-capped) was met.
|
|
1412
|
+
// A single-cycle PASS reports 1; a 3-iter stalemate that ends
|
|
1413
|
+
// in `consensus_failed` reports 3.
|
|
1414
|
+
//
|
|
1415
|
+
// 2. falsePositiveRate (decimal in [0, 1]):
|
|
1416
|
+
// Numerator: (lens, cycle) observations where a lens raised an
|
|
1417
|
+
// alarm (verdict FAIL or CONDITIONAL) but the FINAL consensus
|
|
1418
|
+
// verdict was PASS. UNREACHABLE observations are excluded.
|
|
1419
|
+
// Denominator: total reachable (lens, cycle) observations across
|
|
1420
|
+
// all cycles in this run.
|
|
1421
|
+
// Rationale: a "false positive" is a lens that cried wolf — it
|
|
1422
|
+
// said "this change is broken" against a run the swarm
|
|
1423
|
+
// ultimately blessed. When the final verdict is NOT PASS (FAIL,
|
|
1424
|
+
// CONDITIONAL, consensus_failed, UNREACHABLE), no alarm can be
|
|
1425
|
+
// retro-classified as false — the numerator is 0 and the rate
|
|
1426
|
+
// collapses to 0. When the denominator is 0 (no reachable
|
|
1427
|
+
// observations ever fired — all lenses budget-capped or all
|
|
1428
|
+
// dispatch threw), the rate is reported as 0 (no signal to
|
|
1429
|
+
// divide by). 0 ≤ rate ≤ 1 by construction.
|
|
1430
|
+
//
|
|
1431
|
+
// 3. costUsd (decimal ≥ 0):
|
|
1432
|
+
// Sum across all lenses of cumulative cost attributed to this
|
|
1433
|
+
// convergence run. Source: `_lensCosts[lens]`, which is fed from
|
|
1434
|
+
// `dispatch().cost_usd` (or `dispatch().usage.cost_usd`) on every
|
|
1435
|
+
// per-cycle settlement. Lenses that never returned a cost field
|
|
1436
|
+
// contribute 0 (their wall-clock time isn't a "USD" cost — that's
|
|
1437
|
+
// already captured in the receipt's `duration_ms` field). When
|
|
1438
|
+
// no lens reports cost, costUsd is 0.0; this is the truthful
|
|
1439
|
+
// signal that the swarm ran on CLI credentials with no per-call
|
|
1440
|
+
// billing surface, not a missing-data sentinel.
|
|
1441
|
+
try {
|
|
1442
|
+
const finalVerdict = enriched.verdict;
|
|
1443
|
+
const finalIsPass = finalVerdict === VERDICT_PASS;
|
|
1444
|
+
// Numerator: alarms only count as FALSE positives when consensus PASS'd.
|
|
1445
|
+
const _falsePositives = finalIsPass ? _telemetryAlarms : 0;
|
|
1446
|
+
const falsePositiveRate = _telemetryReachableObs > 0
|
|
1447
|
+
? _falsePositives / _telemetryReachableObs
|
|
1448
|
+
: 0;
|
|
1449
|
+
let _summedCost = 0;
|
|
1450
|
+
for (const v of Object.values(_lensCosts)) {
|
|
1451
|
+
if (typeof v === 'number' && Number.isFinite(v)) _summedCost += v;
|
|
1452
|
+
}
|
|
1453
|
+
const metrics = {
|
|
1454
|
+
cyclesToConverge: enriched.iterations,
|
|
1455
|
+
falsePositiveRate,
|
|
1456
|
+
costUsd: _summedCost,
|
|
1457
|
+
// Diagnostics — not part of the locked three but useful to readers
|
|
1458
|
+
// of `.ijfw/telemetry/convergence.json`. The SDK verb stores the
|
|
1459
|
+
// metrics object opaquely so extra keys are non-breaking.
|
|
1460
|
+
verdict: finalVerdict,
|
|
1461
|
+
commitRange: typeof commitRange === 'string' ? commitRange : null,
|
|
1462
|
+
lensCount: lenses.length,
|
|
1463
|
+
reachableObservations: _telemetryReachableObs,
|
|
1464
|
+
alarmObservations: _telemetryAlarms,
|
|
1465
|
+
durationMs: Date.now() - _startMs,
|
|
1466
|
+
};
|
|
1467
|
+
// Stable, run-scoped dedup key. The verb's append-idempotency (§4)
|
|
1468
|
+
// requires a string; the runStamp is the canonical per-run identity
|
|
1469
|
+
// and the commitRange disambiguates concurrent runs against different
|
|
1470
|
+
// targets. Same (stamp, range) → same key → second record is dedup'd
|
|
1471
|
+
// by the verb (deterministic for tests + safe under double-fire).
|
|
1472
|
+
const dedupKey = `convergence:${_resolvedRunStamp}:${typeof commitRange === 'string' ? commitRange : 'nocommitrange'}`;
|
|
1473
|
+
// Off the critical path: the await is wrapped + swallowed. The verdict
|
|
1474
|
+
// has already been computed; this is observability only.
|
|
1475
|
+
await _stateQuery('telemetry.record', {
|
|
1476
|
+
kind: 'convergence',
|
|
1477
|
+
metrics,
|
|
1478
|
+
dedupKey,
|
|
1479
|
+
}, {
|
|
1480
|
+
projectRoot: _resolvedProjectDir,
|
|
1481
|
+
}).catch(() => {
|
|
1482
|
+
// Telemetry failures must NEVER affect the convergence verdict.
|
|
1483
|
+
// (e.g. read-only FS, missing .ijfw/, lock contention) — swallow.
|
|
1484
|
+
});
|
|
1485
|
+
} catch {
|
|
1486
|
+
// Defensive — should never throw before the `.catch` chain, but the
|
|
1487
|
+
// metric computation itself (e.g. exotic NaN in _lensCosts) must not
|
|
1488
|
+
// break the orchestrator return value.
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
return enriched;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
let cycleSummary = null;
|
|
1495
|
+
let prior = null;
|
|
1496
|
+
let priorFindingsKey = null;
|
|
1497
|
+
const perIteration = [];
|
|
1498
|
+
|
|
1499
|
+
for (let iter = 1; iter <= cap; iter++) {
|
|
1500
|
+
// M6: stop the loop if the cumulative wall-clock budget has elapsed.
|
|
1501
|
+
if (_totalTimedOut || _cycleAc.signal.aborted) {
|
|
1502
|
+
return _finalize({
|
|
1503
|
+
verdict: VERDICT_CONSENSUS_FAIL,
|
|
1504
|
+
iterations: Math.max(1, iter - 1),
|
|
1505
|
+
findings: [],
|
|
1506
|
+
perIteration,
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// M5: filter out lenses that have exceeded the per-lens USD cap.
|
|
1511
|
+
const activeLenses = lenses.filter(l => !_lensOverBudget.has(l));
|
|
1512
|
+
if (activeLenses.length === 0) {
|
|
1513
|
+
// All lenses budget-capped — return what we have with consensus_failed.
|
|
1514
|
+
return _finalize({
|
|
1515
|
+
verdict: VERDICT_CONSENSUS_FAIL,
|
|
1516
|
+
iterations: Math.max(1, iter - 1),
|
|
1517
|
+
findings: [],
|
|
1518
|
+
perIteration,
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
// Fire all (still-active) lenses in parallel.
|
|
1523
|
+
_totalInvocations += activeLenses.length;
|
|
1524
|
+
const settlements = await Promise.all(activeLenses.map(lens =>
|
|
1525
|
+
Promise.resolve()
|
|
1526
|
+
.then(() => dispatch({
|
|
1527
|
+
lens,
|
|
1528
|
+
commitRange,
|
|
1529
|
+
iteration: iter,
|
|
1530
|
+
cycleSummary,
|
|
1531
|
+
projectRoot,
|
|
1532
|
+
// M6: pass the cumulative AbortController signal to dispatchers
|
|
1533
|
+
// that honor it. Existing tests inject a dispatcher that ignores
|
|
1534
|
+
// `signal`; production dispatchers should pass it through to
|
|
1535
|
+
// fireExternal so in-flight CLI/API calls tear down on deadline.
|
|
1536
|
+
signal: _cycleAc.signal,
|
|
1537
|
+
}))
|
|
1538
|
+
.catch(err => ({
|
|
1539
|
+
lens,
|
|
1540
|
+
verdict: VERDICT_UNREACHABLE,
|
|
1541
|
+
findings: [],
|
|
1542
|
+
error: err && err.message ? err.message : String(err),
|
|
1543
|
+
}))
|
|
1544
|
+
));
|
|
1545
|
+
|
|
1546
|
+
// M5: accrue per-lens cost + flag over-budget lenses for the next iter.
|
|
1547
|
+
if (_resolvedPerLensCap) {
|
|
1548
|
+
for (const s of settlements) {
|
|
1549
|
+
if (!s || !s.lens) continue;
|
|
1550
|
+
const cost = (typeof s.cost_usd === 'number' && Number.isFinite(s.cost_usd))
|
|
1551
|
+
? s.cost_usd
|
|
1552
|
+
: (s.usage && typeof s.usage.cost_usd === 'number' ? s.usage.cost_usd : 0);
|
|
1553
|
+
_lensCosts[s.lens] = (_lensCosts[s.lens] || 0) + cost;
|
|
1554
|
+
if (_lensCosts[s.lens] > _resolvedPerLensCap) {
|
|
1555
|
+
_lensOverBudget.add(s.lens);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
} else {
|
|
1559
|
+
// Even without a cap, still track cumulative cost for observability.
|
|
1560
|
+
for (const s of settlements) {
|
|
1561
|
+
if (!s || !s.lens) continue;
|
|
1562
|
+
const cost = (typeof s.cost_usd === 'number' && Number.isFinite(s.cost_usd))
|
|
1563
|
+
? s.cost_usd
|
|
1564
|
+
: (s.usage && typeof s.usage.cost_usd === 'number' ? s.usage.cost_usd : 0);
|
|
1565
|
+
_lensCosts[s.lens] = (_lensCosts[s.lens] || 0) + cost;
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
// Map back to the full lens list: lenses we filtered out (budget-capped)
|
|
1570
|
+
// synthesize a stub UNREACHABLE result so downstream divergence/stall
|
|
1571
|
+
// detection sees the same lens set every iteration.
|
|
1572
|
+
const settlementByLens = new Map();
|
|
1573
|
+
for (let i = 0; i < settlements.length; i++) {
|
|
1574
|
+
const r = settlements[i];
|
|
1575
|
+
const lensId = r && r.lens ? r.lens : activeLenses[i];
|
|
1576
|
+
settlementByLens.set(lensId, r);
|
|
1577
|
+
}
|
|
1578
|
+
const lensResults = lenses.map(lensId => {
|
|
1579
|
+
const r = settlementByLens.get(lensId);
|
|
1580
|
+
if (!r) {
|
|
1581
|
+
// M5: budget-capped lens — present as UNREACHABLE with explanatory error.
|
|
1582
|
+
return {
|
|
1583
|
+
lens: lensId,
|
|
1584
|
+
verdict: VERDICT_UNREACHABLE,
|
|
1585
|
+
findings: [],
|
|
1586
|
+
error: 'over per-lens budget',
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
return {
|
|
1590
|
+
lens: r.lens || lensId,
|
|
1591
|
+
verdict: r.verdict ? r.verdict : VERDICT_UNREACHABLE,
|
|
1592
|
+
findings: Array.isArray(r.findings) ? r.findings : [],
|
|
1593
|
+
...(r.error ? { error: r.error } : {}),
|
|
1594
|
+
};
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
// Build per-lens findings map for stall detection.
|
|
1598
|
+
const perLensFindings = {};
|
|
1599
|
+
for (const r of lensResults) perLensFindings[r.lens] = r.findings;
|
|
1600
|
+
const findingsKey = stableFindingsKey(perLensFindings);
|
|
1601
|
+
|
|
1602
|
+
const reachable = lensResults.filter(r => r.verdict !== VERDICT_UNREACHABLE);
|
|
1603
|
+
const divergence = detectDivergence(lensResults);
|
|
1604
|
+
|
|
1605
|
+
perIteration.push({
|
|
1606
|
+
iteration: iter,
|
|
1607
|
+
lensResults,
|
|
1608
|
+
divergent: divergence.divergent,
|
|
1609
|
+
});
|
|
1610
|
+
|
|
1611
|
+
const mergedFindings = lensResults.flatMap(r => r.findings.map(f => ({ ...f, _lens: r.lens })));
|
|
1612
|
+
|
|
1613
|
+
// Receipt cycle metadata — per-cycle finding count + verdict map.
|
|
1614
|
+
const lensVerdicts = {};
|
|
1615
|
+
for (const r of lensResults) lensVerdicts[r.lens] = r.verdict;
|
|
1616
|
+
_receiptCycles.push({
|
|
1617
|
+
iteration: iter,
|
|
1618
|
+
findingCount: mergedFindings.length,
|
|
1619
|
+
lensVerdicts,
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1622
|
+
// T21 — false-positive accounting. Every reachable lens observation is
|
|
1623
|
+
// a denominator tick; every FAIL/CONDITIONAL vote is a numerator tick
|
|
1624
|
+
// (it "raised an alarm"). Whether each alarm was a true or false positive
|
|
1625
|
+
// is decided at finalize against the final consensus verdict (see
|
|
1626
|
+
// `_finalize`). UNREACHABLE lenses are excluded from both numerator and
|
|
1627
|
+
// denominator — a CLI that never replied is neither a true nor false
|
|
1628
|
+
// positive, it's a no-data point.
|
|
1629
|
+
for (const r of lensResults) {
|
|
1630
|
+
if (r.verdict === VERDICT_UNREACHABLE) continue;
|
|
1631
|
+
_telemetryReachableObs += 1;
|
|
1632
|
+
if (r.verdict === VERDICT_FAIL || r.verdict === VERDICT_CONDITIONAL) {
|
|
1633
|
+
_telemetryAlarms += 1;
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// Stop conditions, in priority order.
|
|
1638
|
+
|
|
1639
|
+
// 1. maxIterations === 1 — cap the loop after first iter no matter what.
|
|
1640
|
+
if (cap === 1) {
|
|
1641
|
+
const verdict = reachable.length === 0
|
|
1642
|
+
? VERDICT_UNREACHABLE
|
|
1643
|
+
: (divergence.divergent ? VERDICT_CONSENSUS_FAIL : reachable[0].verdict);
|
|
1644
|
+
return _finalize({
|
|
1645
|
+
verdict,
|
|
1646
|
+
iterations: 1,
|
|
1647
|
+
findings: mergedFindings,
|
|
1648
|
+
...(divergence.divergent ? { divergence: divergence.axes } : {}),
|
|
1649
|
+
perIteration,
|
|
1650
|
+
});
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// 2. All reachable lenses PASS → done.
|
|
1654
|
+
if (reachable.length > 0 && !divergence.divergent && reachable[0].verdict === VERDICT_PASS) {
|
|
1655
|
+
return _finalize({
|
|
1656
|
+
verdict: VERDICT_PASS,
|
|
1657
|
+
iterations: iter,
|
|
1658
|
+
findings: mergedFindings,
|
|
1659
|
+
perIteration,
|
|
1660
|
+
});
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// 3. Consensus on non-PASS (e.g. all FAIL) — short-circuit, no point looping.
|
|
1664
|
+
if (reachable.length > 0 && !divergence.divergent) {
|
|
1665
|
+
return _finalize({
|
|
1666
|
+
verdict: reachable[0].verdict,
|
|
1667
|
+
iterations: iter,
|
|
1668
|
+
findings: mergedFindings,
|
|
1669
|
+
perIteration,
|
|
1670
|
+
});
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// 4. Stall breaker: byte-identical findings to prior iter → halt.
|
|
1674
|
+
if (priorFindingsKey !== null && findingsKey === priorFindingsKey) {
|
|
1675
|
+
return _finalize({
|
|
1676
|
+
verdict: VERDICT_CONSENSUS_FAIL,
|
|
1677
|
+
iterations: iter,
|
|
1678
|
+
findings: mergedFindings,
|
|
1679
|
+
divergence: divergence.axes,
|
|
1680
|
+
stalled: true,
|
|
1681
|
+
perIteration,
|
|
1682
|
+
});
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// 5. Cap hit while still divergent → consensus_failed.
|
|
1686
|
+
if (iter === cap) {
|
|
1687
|
+
return _finalize({
|
|
1688
|
+
verdict: VERDICT_CONSENSUS_FAIL,
|
|
1689
|
+
iterations: iter,
|
|
1690
|
+
findings: mergedFindings,
|
|
1691
|
+
divergence: divergence.axes,
|
|
1692
|
+
perIteration,
|
|
1693
|
+
});
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// Otherwise: stage next iteration with cycle summary.
|
|
1697
|
+
prior = { lensResults, divergence };
|
|
1698
|
+
cycleSummary = buildCycleSummary(iter + 1, prior);
|
|
1699
|
+
priorFindingsKey = findingsKey;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
// Unreachable; loop must exit via one of the return paths above.
|
|
1703
|
+
/* c8 ignore next */
|
|
1704
|
+
return _finalize({ verdict: VERDICT_CONSENSUS_FAIL, iterations: cap, findings: [], perIteration });
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// Default production dispatcher. Wraps the existing single-lens spawn path
|
|
1708
|
+
// (audit-roster pick → buildRequest → fireExternal → parseResponse →
|
|
1709
|
+
// classifyVerdict). Tests pass their own dispatcher; production callers can
|
|
1710
|
+
// either pass this one or supply a customized wrapper.
|
|
1711
|
+
//
|
|
1712
|
+
// One quirk: this dispatcher embeds the cycleSummary into the audit target
|
|
1713
|
+
// string (prefixed) so the lens sees prior-round context in its prompt.
|
|
1714
|
+
// Lens stdout shape: same as parseResponse('audit', stdout).
|
|
1715
|
+
export async function defaultConvergeDispatch({ lens, commitRange, iteration, cycleSummary, projectRoot, signal } = {}) {
|
|
1716
|
+
const env = process.env;
|
|
1717
|
+
const entry = ROSTER.find(e => e.id === lens);
|
|
1718
|
+
if (!entry) {
|
|
1719
|
+
return { lens, verdict: VERDICT_UNREACHABLE, findings: [], error: `lens "${lens}" not in roster` };
|
|
1720
|
+
}
|
|
1721
|
+
// v1.5.0 wire-W1.F — honor a pre-aborted cumulative-timeout signal before
|
|
1722
|
+
// any CLI/API spawn so runPhaseEConverge's totalTimeoutMs cap actually
|
|
1723
|
+
// bounds wall-clock time when the deadline expired between iterations.
|
|
1724
|
+
if (signal && signal.aborted) {
|
|
1725
|
+
return { lens, verdict: VERDICT_UNREACHABLE, findings: [], error: 'aborted' };
|
|
1726
|
+
}
|
|
1727
|
+
const reach = isReachable(lens, env);
|
|
1728
|
+
if (!reach.any) {
|
|
1729
|
+
return { lens, verdict: VERDICT_UNREACHABLE, findings: [], error: `lens "${lens}" CLI missing and no apiFallback` };
|
|
1730
|
+
}
|
|
1731
|
+
const pick = (!reach.cli && reach.api) ? { ...entry, preferredSource: 'api' } : { ...entry };
|
|
1732
|
+
|
|
1733
|
+
// v1.5.0 audit-H4.2 — cycleSummary MUST come AFTER any cache_control-eligible
|
|
1734
|
+
// block. Cached content (the stable commitRange/target) goes FIRST so user-
|
|
1735
|
+
// message cache_control (planned for v1.5.0 ADJUDICATION-1 cap-layer work)
|
|
1736
|
+
// hits the same prefix on every iteration; the iteration-varying summary
|
|
1737
|
+
// goes LAST so it never busts the cache. The previous prefix layout would
|
|
1738
|
+
// invalidate the cache on every iteration ≥ 2. See ADJUDICATIONS.md
|
|
1739
|
+
// DISPUTED-1 (cache_control ordering invariant).
|
|
1740
|
+
const target = (iteration > 1 && cycleSummary)
|
|
1741
|
+
? `${commitRange}\n\n---\n\n${cycleSummary}`
|
|
1742
|
+
: commitRange;
|
|
1743
|
+
|
|
1744
|
+
const request = buildRequest('audit', target, pick.id, 'general', null);
|
|
1745
|
+
const timeoutMs = timeoutForPick(pick, null);
|
|
1746
|
+
try {
|
|
1747
|
+
// v1.5.0 wire-W1.F — forward cumulative-timeout signal so in-flight CLI
|
|
1748
|
+
// spawn + API fallback both cascade their AbortControllers on deadline.
|
|
1749
|
+
const raw = await fireExternal(pick, request, timeoutMs, env, signal || null);
|
|
1750
|
+
if (!raw || raw.status === 'timeout' || raw.status === 'failed' || raw.status === 'aborted') {
|
|
1751
|
+
return { lens, verdict: VERDICT_UNREACHABLE, findings: [], error: (raw && raw.stderr) || 'no output' };
|
|
1752
|
+
}
|
|
1753
|
+
if (raw.exitCode !== 0 && raw.status !== 'fallback-used') {
|
|
1754
|
+
return { lens, verdict: VERDICT_UNREACHABLE, findings: [], error: `exit ${raw.exitCode}` };
|
|
1755
|
+
}
|
|
1756
|
+
const parsed = parseResponse('audit', raw.stdout);
|
|
1757
|
+
const items = Array.isArray(parsed.items) ? parsed.items : [];
|
|
1758
|
+
const verdict = classifyVerdict(items);
|
|
1759
|
+
return { lens, verdict, findings: items };
|
|
1760
|
+
} catch (err) {
|
|
1761
|
+
return { lens, verdict: VERDICT_UNREACHABLE, findings: [], error: err && err.message ? err.message : String(err) };
|
|
1762
|
+
}
|
|
1763
|
+
/* projectRoot reserved for future per-project dispatcher overrides */
|
|
1764
|
+
// eslint-disable-next-line no-unreachable
|
|
1765
|
+
void projectRoot;
|
|
1766
|
+
}
|
|
1767
|
+
|