@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
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// a11y-contract.js -- v1.5.0 audit-MED-design-#11.
|
|
2
|
+
//
|
|
3
|
+
// Accessibility as part of the design contract. Mirrors lighthouse-pillar.js:
|
|
4
|
+
// the actual axe-core run is performed by a peer tool (chrome-devtools-mcp's
|
|
5
|
+
// axe runner, the user's own playwright + @axe-core/playwright, etc.). This
|
|
6
|
+
// module is the pure-stdlib evaluator that:
|
|
7
|
+
//
|
|
8
|
+
// 1. Reads `a11y_target: <WCAG-id>` + `max_violations: <N>` from UI-SPEC.md
|
|
9
|
+
// (already parsed by uispec-drift.js → parseUISpec).
|
|
10
|
+
// 2. Takes the raw axe-core result (array of violations) from the caller.
|
|
11
|
+
// 3. Returns {pass, violations, target, count, reason} where pass=false when
|
|
12
|
+
// violations.length > maxViolations (default 0).
|
|
13
|
+
//
|
|
14
|
+
// Graceful-degrade: missing axe results → pass=null reason='axe-unavailable'.
|
|
15
|
+
//
|
|
16
|
+
// Exports:
|
|
17
|
+
// - DEFAULT_A11Y_TARGET -- 'WCAG-2.2-AA'
|
|
18
|
+
// - DEFAULT_MAX_VIOLATIONS -- 0
|
|
19
|
+
// - evaluateA11y(axeReport, contract?)
|
|
20
|
+
// - axePromptFor(url, target)
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_A11Y_TARGET = 'WCAG-2.2-AA';
|
|
23
|
+
export const DEFAULT_MAX_VIOLATIONS = 0;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Evaluate an axe-core violations report.
|
|
27
|
+
*
|
|
28
|
+
* Accepts shapes:
|
|
29
|
+
* - axe-core full result: { violations: [...], passes: [...], incomplete: [...] }
|
|
30
|
+
* - bare array of violations: [{ id, impact, ... }, ...]
|
|
31
|
+
* - { violations: [...] }
|
|
32
|
+
*
|
|
33
|
+
* @param {object|Array|null|undefined} axeReport
|
|
34
|
+
* @param {object} [contract]
|
|
35
|
+
* @param {string} [contract.target] a11y_target ID
|
|
36
|
+
* @param {number} [contract.maxViolations] max allowed
|
|
37
|
+
* @param {string[]} [contract.severityFilter] Only count violations whose
|
|
38
|
+
* `impact` is in this set; default ['critical','serious']. Pass `['*']`
|
|
39
|
+
* to count all severities.
|
|
40
|
+
* @returns {{pass: boolean|null, count: number|null, violations: Array, target: string, reason: string, maxViolations: number, severityFilter: string[]}}
|
|
41
|
+
*/
|
|
42
|
+
export function evaluateA11y(axeReport, contract = {}) {
|
|
43
|
+
const target = contract.target || DEFAULT_A11Y_TARGET;
|
|
44
|
+
const maxViolations =
|
|
45
|
+
typeof contract.maxViolations === 'number' ? contract.maxViolations : DEFAULT_MAX_VIOLATIONS;
|
|
46
|
+
const severityFilter =
|
|
47
|
+
Array.isArray(contract.severityFilter) && contract.severityFilter.length > 0
|
|
48
|
+
? contract.severityFilter
|
|
49
|
+
: ['critical', 'serious'];
|
|
50
|
+
|
|
51
|
+
if (axeReport == null) {
|
|
52
|
+
return {
|
|
53
|
+
pass: null,
|
|
54
|
+
count: null,
|
|
55
|
+
violations: [],
|
|
56
|
+
target,
|
|
57
|
+
reason: 'axe-unavailable',
|
|
58
|
+
maxViolations,
|
|
59
|
+
severityFilter,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const raw = Array.isArray(axeReport)
|
|
64
|
+
? axeReport
|
|
65
|
+
: Array.isArray(axeReport.violations)
|
|
66
|
+
? axeReport.violations
|
|
67
|
+
: null;
|
|
68
|
+
|
|
69
|
+
if (!Array.isArray(raw)) {
|
|
70
|
+
return {
|
|
71
|
+
pass: null,
|
|
72
|
+
count: null,
|
|
73
|
+
violations: [],
|
|
74
|
+
target,
|
|
75
|
+
reason: 'violations-array-missing',
|
|
76
|
+
maxViolations,
|
|
77
|
+
severityFilter,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const counted = severityFilter.includes('*')
|
|
82
|
+
? raw
|
|
83
|
+
: raw.filter((v) => v && severityFilter.includes(v.impact));
|
|
84
|
+
|
|
85
|
+
const pass = counted.length <= maxViolations;
|
|
86
|
+
const reason = pass
|
|
87
|
+
? `${counted.length} violation(s) within budget`
|
|
88
|
+
: `${counted.length} violation(s) exceed budget ${maxViolations}`;
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
pass,
|
|
92
|
+
count: counted.length,
|
|
93
|
+
violations: counted,
|
|
94
|
+
target,
|
|
95
|
+
reason,
|
|
96
|
+
maxViolations,
|
|
97
|
+
severityFilter,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Build the prompt fragment ijfw-ui-auditor uses to run axe-core via
|
|
103
|
+
* chrome-devtools-mcp (or fallback to @axe-core/cli locally).
|
|
104
|
+
*
|
|
105
|
+
* @param {string} url
|
|
106
|
+
* @param {string} [target]
|
|
107
|
+
*/
|
|
108
|
+
export function axePromptFor(url, target = DEFAULT_A11Y_TARGET) {
|
|
109
|
+
return [
|
|
110
|
+
`Run axe-core for ${target} compliance against:`,
|
|
111
|
+
` url: ${url}`,
|
|
112
|
+
'Prefer chrome-devtools-mcp:evaluate_script with the axe-core CDN bundle,',
|
|
113
|
+
'or run `npx @axe-core/cli ${url}` locally if the peer is installed.',
|
|
114
|
+
'Pipe the resulting violations[] through evaluateA11y() from',
|
|
115
|
+
'mcp-server/src/lib/a11y-contract.js. BLOCK the review if pass=false.',
|
|
116
|
+
].join('\n');
|
|
117
|
+
}
|
package/src/lib/atomic-io.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// 5s default wait with 50ms retry. On timeout returns {status:'locked', pid}.
|
|
12
12
|
|
|
13
13
|
import { writeFileSync, openSync, closeSync, fsyncSync, renameSync, readFileSync,
|
|
14
|
-
existsSync, mkdirSync, unlinkSync, statSync, chmodSync } from 'node:fs';
|
|
14
|
+
existsSync, mkdirSync, unlinkSync, statSync, lstatSync, chmodSync } from 'node:fs';
|
|
15
15
|
import { dirname, resolve as pathResolve } from 'node:path';
|
|
16
16
|
import { randomBytes } from 'node:crypto';
|
|
17
17
|
import { platform } from 'node:os';
|
|
@@ -25,15 +25,36 @@ export function writeAtomic(targetPath, data, opts = {}) {
|
|
|
25
25
|
|
|
26
26
|
if (ensureDir && !existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
27
27
|
|
|
28
|
-
// Refuse symlinks at target -- supply-chain hygiene per v3 sec 1
|
|
28
|
+
// Refuse symlinks at target -- supply-chain hygiene per v3 sec 1.
|
|
29
|
+
//
|
|
30
|
+
// v1.5.1 H1.3 (audit update-install-trust.md F-COR-1): was `statSync`, which
|
|
31
|
+
// follows symlinks. On a symlink target's stat, `isSymbolicLink()` always
|
|
32
|
+
// returns false, so the check was dead code. Fixed to `lstatSync`.
|
|
33
|
+
//
|
|
34
|
+
// v1.5.1 H1.3-followup (Trident r18): the throw was nested inside a try/
|
|
35
|
+
// catch that string-matched `e.message.startsWith('refusing')` to decide
|
|
36
|
+
// whether to rethrow. Functionally correct but fragile — if the message
|
|
37
|
+
// string ever changes the catch swallows the symlink refusal. Restructured
|
|
38
|
+
// so the throw is OUTSIDE any try/catch: only the lstatSync probe is
|
|
39
|
+
// try-wrapped (to tolerate a race where the file vanishes between
|
|
40
|
+
// existsSync and lstatSync), and the refusal throw runs unconditionally
|
|
41
|
+
// when isSymbolicLink() is true.
|
|
42
|
+
//
|
|
43
|
+
// NB: the rename pattern below (write tmp, rename to abs) is already
|
|
44
|
+
// symlink-safe at the POSIX `rename(2)` level — rename replaces a symlink
|
|
45
|
+
// rather than writing through it. This check is defense-in-depth: it makes
|
|
46
|
+
// symlink-replacement an explicit refusal instead of a silent overwrite.
|
|
29
47
|
if (existsSync(abs)) {
|
|
48
|
+
let st = null;
|
|
30
49
|
try {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
50
|
+
st = lstatSync(abs);
|
|
51
|
+
} catch {
|
|
52
|
+
// Race: file vanished between existsSync and lstatSync. Tolerate —
|
|
53
|
+
// the subsequent rename will either succeed cleanly or surface its own
|
|
54
|
+
// error. We do NOT silently allow a symlink overwrite via this path.
|
|
55
|
+
}
|
|
56
|
+
if (st && st.isSymbolicLink && st.isSymbolicLink()) {
|
|
57
|
+
throw new Error(`refusing to overwrite symlink at ${abs}`);
|
|
37
58
|
}
|
|
38
59
|
}
|
|
39
60
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// v1.5.0 audit-MED-tok-M2 — Cache-keepalive heartbeat.
|
|
2
|
+
//
|
|
3
|
+
// Anthropic's ephemeral prompt cache has a 5-minute TTL. A long Trident
|
|
4
|
+
// wave (5+ minutes of sequential calls separated by network/CLI latency)
|
|
5
|
+
// can therefore start LOSING cache hits mid-wave even though every call
|
|
6
|
+
// re-sends the same cache-eligible prefix. A "keepalive" heartbeat keeps
|
|
7
|
+
// the cache warm by sending a tiny dummy request to the same prefix on
|
|
8
|
+
// a configurable interval.
|
|
9
|
+
//
|
|
10
|
+
// This module is OPT-IN and OFF by default. Enable via env:
|
|
11
|
+
//
|
|
12
|
+
// IJFW_CACHE_KEEPALIVE_MS=60000 # 60s -> fire heartbeat every minute
|
|
13
|
+
//
|
|
14
|
+
// Set to 0 or unset to disable (default).
|
|
15
|
+
//
|
|
16
|
+
// The heartbeat is a NO-OP when:
|
|
17
|
+
// - env is unset, empty, or non-numeric
|
|
18
|
+
// - parsed value is ≤ 0 or > 300_000 (5 min cap matches the cache TTL)
|
|
19
|
+
// - no callback is supplied
|
|
20
|
+
// - process.env.IJFW_DISABLE_CACHE_KEEPALIVE is truthy (override)
|
|
21
|
+
//
|
|
22
|
+
// Callers receive a `cancel()` function. The cancel is idempotent: calling
|
|
23
|
+
// it twice (or after the heartbeat already finished) is safe.
|
|
24
|
+
//
|
|
25
|
+
// The CALLER supplies the actual "send a tiny request" logic via the
|
|
26
|
+
// `onTick` callback. This keeps the lib zero-dep on api-client.js and lets
|
|
27
|
+
// tests inject a deterministic stub.
|
|
28
|
+
|
|
29
|
+
// Lower bound is intentionally generous: 1s minimum so a fat-fingered "1"
|
|
30
|
+
// env var doesn't spin the loop tighter than the runtime needs.
|
|
31
|
+
const MIN_INTERVAL_MS = 1_000;
|
|
32
|
+
// Upper bound matches the Anthropic ephemeral-cache TTL. Beyond 5 min the
|
|
33
|
+
// next request would be a fresh-cache call regardless, so the heartbeat
|
|
34
|
+
// has no effect.
|
|
35
|
+
const MAX_INTERVAL_MS = 300_000;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Parse the keepalive interval from env. Returns 0 (disabled) when the env
|
|
39
|
+
* var is missing, unparseable, or out of bounds — never throws.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} [env] - defaults to process.env
|
|
42
|
+
* @returns {number} - interval in ms; 0 means disabled
|
|
43
|
+
*/
|
|
44
|
+
export function parseKeepaliveInterval(env = process.env) {
|
|
45
|
+
if (env.IJFW_DISABLE_CACHE_KEEPALIVE && env.IJFW_DISABLE_CACHE_KEEPALIVE !== 'false' && env.IJFW_DISABLE_CACHE_KEEPALIVE !== '0') {
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
const raw = env.IJFW_CACHE_KEEPALIVE_MS;
|
|
49
|
+
if (raw === undefined || raw === null || raw === '') return 0;
|
|
50
|
+
const n = Number(raw);
|
|
51
|
+
if (!Number.isFinite(n)) return 0;
|
|
52
|
+
if (n < MIN_INTERVAL_MS) return 0;
|
|
53
|
+
if (n > MAX_INTERVAL_MS) return MAX_INTERVAL_MS;
|
|
54
|
+
return Math.floor(n);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Start a keepalive heartbeat. Returns a cancel function (idempotent).
|
|
59
|
+
*
|
|
60
|
+
* Contract:
|
|
61
|
+
* - When `intervalMs === 0` or `onTick` is not a function, returns a
|
|
62
|
+
* no-op cancel so the caller never has to branch on enable/disable.
|
|
63
|
+
* - The heartbeat NEVER throws back to the caller; onTick errors are
|
|
64
|
+
* swallowed (and optionally surfaced via `onError`) so a transient
|
|
65
|
+
* network blip during keepalive can't crash the Trident wave.
|
|
66
|
+
* - The timer is `.unref()`'d so it never holds the event loop open.
|
|
67
|
+
*
|
|
68
|
+
* @param {object} args
|
|
69
|
+
* @param {number} args.intervalMs - 0 = disabled, else MIN..MAX
|
|
70
|
+
* @param {() => Promise<void>|void} args.onTick - sender; runs each interval
|
|
71
|
+
* @param {(err: Error) => void} [args.onError] - optional error sink
|
|
72
|
+
* @param {AbortSignal} [args.signal] - external cancel propagation
|
|
73
|
+
* @returns {{ cancel: () => void, isActive: () => boolean, ticks: () => number }}
|
|
74
|
+
*/
|
|
75
|
+
export function startKeepalive({ intervalMs, onTick, onError, signal } = {}) {
|
|
76
|
+
const noop = { cancel: () => {}, isActive: () => false, ticks: () => 0 };
|
|
77
|
+
|
|
78
|
+
if (typeof intervalMs !== 'number' || intervalMs <= 0) return noop;
|
|
79
|
+
if (typeof onTick !== 'function') return noop;
|
|
80
|
+
if (signal && signal.aborted) return noop;
|
|
81
|
+
|
|
82
|
+
let tickCount = 0;
|
|
83
|
+
let cancelled = false;
|
|
84
|
+
let timer = null;
|
|
85
|
+
let running = false;
|
|
86
|
+
|
|
87
|
+
const fire = async () => {
|
|
88
|
+
if (cancelled) return;
|
|
89
|
+
if (running) return; // overlap guard — if the previous tick still in flight, skip
|
|
90
|
+
running = true;
|
|
91
|
+
tickCount++;
|
|
92
|
+
try {
|
|
93
|
+
await onTick();
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (typeof onError === 'function') {
|
|
96
|
+
try { onError(err); } catch { /* swallow */ }
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
running = false;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Use setInterval (not setTimeout chain) so a slow onTick can't drift the
|
|
104
|
+
// schedule indefinitely; combined with the `running` overlap guard, the
|
|
105
|
+
// worst case is "skipped tick" rather than "overlapping ticks".
|
|
106
|
+
timer = setInterval(fire, intervalMs);
|
|
107
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
108
|
+
|
|
109
|
+
const cancel = () => {
|
|
110
|
+
if (cancelled) return;
|
|
111
|
+
cancelled = true;
|
|
112
|
+
if (timer !== null) {
|
|
113
|
+
clearInterval(timer);
|
|
114
|
+
timer = null;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
if (signal) {
|
|
119
|
+
if (signal.aborted) {
|
|
120
|
+
cancel();
|
|
121
|
+
} else {
|
|
122
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
cancel,
|
|
128
|
+
isActive: () => !cancelled,
|
|
129
|
+
ticks: () => tickCount,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Convenience wrapper for the common Trident-wave case: read env, start
|
|
135
|
+
* keepalive (or no-op), and return the cancel handle.
|
|
136
|
+
*
|
|
137
|
+
* @param {object} args
|
|
138
|
+
* @param {() => Promise<void>|void} args.onTick
|
|
139
|
+
* @param {(err: Error) => void} [args.onError]
|
|
140
|
+
* @param {AbortSignal} [args.signal]
|
|
141
|
+
* @param {object} [args.env]
|
|
142
|
+
* @returns {{ cancel: () => void, isActive: () => boolean, ticks: () => number }}
|
|
143
|
+
*/
|
|
144
|
+
export function startKeepaliveFromEnv({ onTick, onError, signal, env = process.env } = {}) {
|
|
145
|
+
const intervalMs = parseKeepaliveInterval(env);
|
|
146
|
+
return startKeepalive({ intervalMs, onTick, onError, signal });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Constants exported for tests + docs.
|
|
150
|
+
export const KEEPALIVE_BOUNDS = { minIntervalMs: MIN_INTERVAL_MS, maxIntervalMs: MAX_INTERVAL_MS };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// F-PRF-1 (audit-MED-teams-#10): JSONL rotation helper.
|
|
2
|
+
//
|
|
3
|
+
// Generic append-only JSONL rotator. When the target file's size crosses a
|
|
4
|
+
// threshold, the rotator:
|
|
5
|
+
// 1. Reads the current file bytes.
|
|
6
|
+
// 2. Gzips them via node:zlib.
|
|
7
|
+
// 3. Writes <prefix>.<YYYY-MM-DD>.jsonl.gz alongside the original (uniquify
|
|
8
|
+
// with a numeric suffix if the date-stamped archive already exists).
|
|
9
|
+
// 4. Truncates the original to zero bytes.
|
|
10
|
+
//
|
|
11
|
+
// All writes are atomic-friendly (final rename) so concurrent readers either
|
|
12
|
+
// see the full pre-rotation file or the empty post-rotation file -- never a
|
|
13
|
+
// half-written archive.
|
|
14
|
+
//
|
|
15
|
+
// Used by the blackboard event/permission writers + any caller that does
|
|
16
|
+
// `appendFileSync(path, JSON.stringify(entry) + '\n')`. ESM, zero external deps.
|
|
17
|
+
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { dirname, basename, join } from 'node:path';
|
|
20
|
+
import { gzipSync } from 'node:zlib';
|
|
21
|
+
|
|
22
|
+
// Default rotation threshold (4 MB). Configurable per-call.
|
|
23
|
+
export const DEFAULT_ROTATE_SIZE = 4 * 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
function isoDate(now = new Date()) {
|
|
26
|
+
// YYYY-MM-DD in UTC for stable archive names across timezones.
|
|
27
|
+
return now.toISOString().slice(0, 10);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function strippedJsonlName(file) {
|
|
31
|
+
const base = basename(file);
|
|
32
|
+
// Trim a trailing .jsonl so callers like events.jsonl rotate to events.<date>.jsonl.gz
|
|
33
|
+
// (not events.jsonl.<date>.jsonl.gz).
|
|
34
|
+
return base.endsWith('.jsonl') ? base.slice(0, -'.jsonl'.length) : base;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function archivePath(file, now) {
|
|
38
|
+
const dir = dirname(file);
|
|
39
|
+
const stem = strippedJsonlName(file);
|
|
40
|
+
const date = isoDate(now);
|
|
41
|
+
let candidate = join(dir, `${stem}.${date}.jsonl.gz`);
|
|
42
|
+
if (!existsSync(candidate)) return candidate;
|
|
43
|
+
// Collision (multiple rotations same day) -- append a numeric suffix.
|
|
44
|
+
for (let i = 1; i < 1000; i += 1) {
|
|
45
|
+
candidate = join(dir, `${stem}.${date}.${i}.jsonl.gz`);
|
|
46
|
+
if (!existsSync(candidate)) return candidate;
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`jsonl-rotation: too many same-day archives for ${file}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Check whether `path` is over the rotation threshold and, if so, archive
|
|
53
|
+
* it to a gzipped sibling and truncate the original. Returns an object
|
|
54
|
+
* describing the action -- callers can ignore the result safely.
|
|
55
|
+
*
|
|
56
|
+
* options.maxBytes -- rotate when file size > this. Default 4 MB.
|
|
57
|
+
* options.now -- override the date used in the archive name (testing).
|
|
58
|
+
*/
|
|
59
|
+
export function rotateJsonlIfNeeded(path, options = {}) {
|
|
60
|
+
const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0
|
|
61
|
+
? Math.floor(options.maxBytes)
|
|
62
|
+
: DEFAULT_ROTATE_SIZE;
|
|
63
|
+
const now = options.now instanceof Date ? options.now : new Date();
|
|
64
|
+
|
|
65
|
+
let stat;
|
|
66
|
+
try { stat = statSync(path); }
|
|
67
|
+
catch { return { rotated: false, reason: 'missing' }; }
|
|
68
|
+
if (!stat.isFile()) return { rotated: false, reason: 'not-a-file' };
|
|
69
|
+
if (stat.size <= maxBytes) return { rotated: false, reason: 'under-threshold', size: stat.size };
|
|
70
|
+
|
|
71
|
+
let bytes;
|
|
72
|
+
try { bytes = readFileSync(path); }
|
|
73
|
+
catch (err) { return { rotated: false, reason: 'read-failed', error: String(err?.message || err) }; }
|
|
74
|
+
|
|
75
|
+
const dest = archivePath(path, now);
|
|
76
|
+
const gz = gzipSync(bytes);
|
|
77
|
+
// Write to a temp file then rename so a partial gzip never appears at the
|
|
78
|
+
// archive path. Truncation of the live file happens after the rename so
|
|
79
|
+
// a crash mid-rotation loses at most one batch of pending writes.
|
|
80
|
+
const tmp = `${dest}.tmp`;
|
|
81
|
+
const destDir = dirname(dest);
|
|
82
|
+
if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true });
|
|
83
|
+
writeFileSync(tmp, gz, { mode: 0o600 });
|
|
84
|
+
try { renameSync(tmp, dest); }
|
|
85
|
+
catch (err) {
|
|
86
|
+
try { unlinkSync(tmp); } catch {}
|
|
87
|
+
return { rotated: false, reason: 'rename-failed', error: String(err?.message || err) };
|
|
88
|
+
}
|
|
89
|
+
// Truncate the original. writeFileSync('') replaces the inode contents in
|
|
90
|
+
// place; readers that opened the fd before the rotation keep their view.
|
|
91
|
+
writeFileSync(path, '', { mode: 0o600 });
|
|
92
|
+
return { rotated: true, archive: dest, archivedBytes: bytes.length, gzippedBytes: gz.length };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Convenience wrapper: rotateJsonlIfNeeded(path, options) then append `line`
|
|
97
|
+
* with a trailing newline. Used by event/permission writers that already
|
|
98
|
+
* batch their own JSON.stringify.
|
|
99
|
+
*/
|
|
100
|
+
export function appendJsonlWithRotation(path, line, options = {}) {
|
|
101
|
+
const result = rotateJsonlIfNeeded(path, options);
|
|
102
|
+
// Caller still appends; we just signal whether rotation fired.
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// lighthouse-pillar.js -- v1.5.0 audit-MED-design-#7.
|
|
2
|
+
//
|
|
3
|
+
// Mandatory Lighthouse pillar for the IJFW UI-review pipeline.
|
|
4
|
+
//
|
|
5
|
+
// Wraps a chrome-devtools-mcp `lighthouse_audit` result and returns a
|
|
6
|
+
// deterministic PASS / FAIL verdict per the audit MED:
|
|
7
|
+
//
|
|
8
|
+
// FAIL if LCP > 2.5s OR CLS > 0.1
|
|
9
|
+
// PASS otherwise.
|
|
10
|
+
//
|
|
11
|
+
// The actual MCP call is made by the caller (auditor agent or workflow
|
|
12
|
+
// dispatcher) because IJFW core has zero external deps. This module is a
|
|
13
|
+
// pure-stdlib evaluator + a result-shape contract the auditor can rely on.
|
|
14
|
+
//
|
|
15
|
+
// Exports:
|
|
16
|
+
// - evaluateLighthouse(report, opts?) -> {pass, lcpMs, clsScore, reason, thresholds}
|
|
17
|
+
// - LIGHTHOUSE_THRESHOLDS -- default LCP/CLS cutoffs
|
|
18
|
+
//
|
|
19
|
+
// Graceful-degrade: if `report` is null, undefined, or missing the relevant
|
|
20
|
+
// audits, returns {pass: null, reason: 'lighthouse-unavailable'}. Callers
|
|
21
|
+
// treat null verdict as "skip" (no peer tool installed) rather than FAIL.
|
|
22
|
+
|
|
23
|
+
/** Default WCAG/Core-Web-Vitals thresholds. */
|
|
24
|
+
export const LIGHTHOUSE_THRESHOLDS = Object.freeze({
|
|
25
|
+
lcpMs: 2500, // Largest Contentful Paint, milliseconds
|
|
26
|
+
clsScore: 0.1, // Cumulative Layout Shift, unitless
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Evaluate a Lighthouse audit report against IJFW's CWV gates.
|
|
31
|
+
*
|
|
32
|
+
* Accepts several shapes for robustness:
|
|
33
|
+
* - The raw Lighthouse JSON: { audits: { 'largest-contentful-paint': { numericValue }, 'cumulative-layout-shift': { numericValue } } }
|
|
34
|
+
* - A pre-extracted summary: { lcpMs, clsScore }
|
|
35
|
+
* - A chrome-devtools-mcp wrapper: { lighthouse: <raw>, ... }
|
|
36
|
+
*
|
|
37
|
+
* @param {object|null|undefined} report
|
|
38
|
+
* @param {object} [opts]
|
|
39
|
+
* @param {number} [opts.lcpMs] Override LCP threshold (ms)
|
|
40
|
+
* @param {number} [opts.clsScore] Override CLS threshold
|
|
41
|
+
* @returns {{pass: boolean|null, lcpMs: number|null, clsScore: number|null, reason: string, thresholds: typeof LIGHTHOUSE_THRESHOLDS}}
|
|
42
|
+
*/
|
|
43
|
+
export function evaluateLighthouse(report, opts = {}) {
|
|
44
|
+
const thresholds = {
|
|
45
|
+
lcpMs: typeof opts.lcpMs === 'number' ? opts.lcpMs : LIGHTHOUSE_THRESHOLDS.lcpMs,
|
|
46
|
+
clsScore: typeof opts.clsScore === 'number' ? opts.clsScore : LIGHTHOUSE_THRESHOLDS.clsScore,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
if (report == null) {
|
|
50
|
+
return { pass: null, lcpMs: null, clsScore: null, reason: 'lighthouse-unavailable', thresholds };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { lcpMs, clsScore } = extractMetrics(report);
|
|
54
|
+
|
|
55
|
+
if (lcpMs == null && clsScore == null) {
|
|
56
|
+
return { pass: null, lcpMs: null, clsScore: null, reason: 'metrics-missing', thresholds };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const reasons = [];
|
|
60
|
+
let pass = true;
|
|
61
|
+
if (lcpMs != null && lcpMs > thresholds.lcpMs) {
|
|
62
|
+
pass = false;
|
|
63
|
+
reasons.push(`LCP ${Math.round(lcpMs)}ms > ${thresholds.lcpMs}ms`);
|
|
64
|
+
}
|
|
65
|
+
if (clsScore != null && clsScore > thresholds.clsScore) {
|
|
66
|
+
pass = false;
|
|
67
|
+
reasons.push(`CLS ${clsScore.toFixed(3)} > ${thresholds.clsScore}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
pass,
|
|
72
|
+
lcpMs: lcpMs ?? null,
|
|
73
|
+
clsScore: clsScore ?? null,
|
|
74
|
+
reason: pass ? 'within-budget' : reasons.join('; '),
|
|
75
|
+
thresholds,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function extractMetrics(report) {
|
|
80
|
+
// Pre-extracted summary form.
|
|
81
|
+
if (typeof report.lcpMs === 'number' || typeof report.clsScore === 'number') {
|
|
82
|
+
return {
|
|
83
|
+
lcpMs: typeof report.lcpMs === 'number' ? report.lcpMs : null,
|
|
84
|
+
clsScore: typeof report.clsScore === 'number' ? report.clsScore : null,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// chrome-devtools-mcp wrapper form.
|
|
88
|
+
const lh = report.lighthouse || report.lhr || report;
|
|
89
|
+
const audits = lh && typeof lh === 'object' ? lh.audits : null;
|
|
90
|
+
if (!audits || typeof audits !== 'object') {
|
|
91
|
+
return { lcpMs: null, clsScore: null };
|
|
92
|
+
}
|
|
93
|
+
const lcpAudit = audits['largest-contentful-paint'] || audits.lcp;
|
|
94
|
+
const clsAudit = audits['cumulative-layout-shift'] || audits.cls;
|
|
95
|
+
const lcpMs =
|
|
96
|
+
lcpAudit && typeof lcpAudit.numericValue === 'number'
|
|
97
|
+
? lcpAudit.numericValue
|
|
98
|
+
: null;
|
|
99
|
+
const clsScore =
|
|
100
|
+
clsAudit && typeof clsAudit.numericValue === 'number'
|
|
101
|
+
? clsAudit.numericValue
|
|
102
|
+
: null;
|
|
103
|
+
return { lcpMs, clsScore };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Build the prompt fragment the ijfw-ui-auditor agent uses to invoke the MCP
|
|
108
|
+
* tool. Kept here so the verdict-logic and the call-site stay co-located.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} url
|
|
111
|
+
* @returns {string}
|
|
112
|
+
*/
|
|
113
|
+
export function lighthousePromptFor(url) {
|
|
114
|
+
return [
|
|
115
|
+
'Invoke chrome-devtools-mcp:lighthouse_audit on the dev server URL:',
|
|
116
|
+
` url: ${url}`,
|
|
117
|
+
'Then pipe the result through evaluateLighthouse() from',
|
|
118
|
+
'mcp-server/src/lib/lighthouse-pillar.js. FAIL the review if the',
|
|
119
|
+
'returned `pass` is false; record `reason` in UI-REVIEW.md.',
|
|
120
|
+
].join('\n');
|
|
121
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// mcp-server/src/lib/llm-call.js
|
|
2
|
+
// IJFW v1.5.0 -- minimal LLM-call wrapper for the M2 auto-linker.
|
|
3
|
+
//
|
|
4
|
+
// Defaults to Claude Haiku 4.5 (claude-haiku-4-5-20251001). Env overrides:
|
|
5
|
+
// IJFW_AUTOLINK_OFF=1 -> never call; return skipped.
|
|
6
|
+
// IJFW_AUTOLINK_BUDGET_USD=<float> -> per-day cap; 0 disables.
|
|
7
|
+
// IJFW_AUTOLINK_MODEL=<model-id> -> override default.
|
|
8
|
+
// IJFW_AUTOLINK_API_KEY=<key> -> if absent, falls back to
|
|
9
|
+
// ANTHROPIC_API_KEY; if both
|
|
10
|
+
// absent, returns skipped=no_key.
|
|
11
|
+
//
|
|
12
|
+
// Budget tracking is a simple JSONL spend log at
|
|
13
|
+
// `<repoRoot>/.ijfw/.llm-spend.jsonl` rolled by day. Approximate USD per
|
|
14
|
+
// call uses Haiku pricing.
|
|
15
|
+
|
|
16
|
+
import { readFileSync, appendFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
17
|
+
import { join, dirname } from 'node:path';
|
|
18
|
+
|
|
19
|
+
const DEFAULT_MODEL = 'claude-haiku-4-5-20251001';
|
|
20
|
+
const DEFAULT_PRICE_IN = 0.80;
|
|
21
|
+
const DEFAULT_PRICE_OUT = 4.00;
|
|
22
|
+
|
|
23
|
+
function isoDay(d = new Date()) {
|
|
24
|
+
return d.toISOString().slice(0, 10);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function spendPath(root) {
|
|
28
|
+
return join(root, '.ijfw', '.llm-spend.jsonl');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function todaySpend(root) {
|
|
32
|
+
const p = spendPath(root);
|
|
33
|
+
if (!existsSync(p)) return 0;
|
|
34
|
+
const day = isoDay();
|
|
35
|
+
let usd = 0;
|
|
36
|
+
for (const line of readFileSync(p, 'utf8').split('\n')) {
|
|
37
|
+
if (!line) continue;
|
|
38
|
+
try {
|
|
39
|
+
const row = JSON.parse(line);
|
|
40
|
+
if (row.day === day && typeof row.usd === 'number') usd += row.usd;
|
|
41
|
+
} catch { /* skip corrupt */ }
|
|
42
|
+
}
|
|
43
|
+
return usd;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function recordSpend(root, usd, model, inTok, outTok) {
|
|
47
|
+
const p = spendPath(root);
|
|
48
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
49
|
+
appendFileSync(
|
|
50
|
+
p,
|
|
51
|
+
JSON.stringify({
|
|
52
|
+
day: isoDay(),
|
|
53
|
+
ts: new Date().toISOString(),
|
|
54
|
+
model,
|
|
55
|
+
input_tokens: inTok,
|
|
56
|
+
output_tokens: outTok,
|
|
57
|
+
usd,
|
|
58
|
+
}) + '\n',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function llmCall({
|
|
63
|
+
system,
|
|
64
|
+
user,
|
|
65
|
+
maxTokens = 512,
|
|
66
|
+
root = process.cwd(),
|
|
67
|
+
model,
|
|
68
|
+
apiKey,
|
|
69
|
+
} = {}) {
|
|
70
|
+
if (process.env.IJFW_AUTOLINK_OFF === '1') {
|
|
71
|
+
return { skipped: true, reason: 'autolink_off' };
|
|
72
|
+
}
|
|
73
|
+
const budget = process.env.IJFW_AUTOLINK_BUDGET_USD;
|
|
74
|
+
if (budget !== undefined && Number(budget) <= 0) {
|
|
75
|
+
return { skipped: true, reason: 'budget_exhausted' };
|
|
76
|
+
}
|
|
77
|
+
if (budget !== undefined && todaySpend(root) >= Number(budget)) {
|
|
78
|
+
return { skipped: true, reason: 'budget_exhausted' };
|
|
79
|
+
}
|
|
80
|
+
const key = apiKey || process.env.IJFW_AUTOLINK_API_KEY || process.env.ANTHROPIC_API_KEY;
|
|
81
|
+
if (!key) return { skipped: true, reason: 'no_key' };
|
|
82
|
+
const m = model || process.env.IJFW_AUTOLINK_MODEL || DEFAULT_MODEL;
|
|
83
|
+
const priceIn = Number(process.env.IJFW_AUTOLINK_PRICE_IN_PER_MTOK || DEFAULT_PRICE_IN);
|
|
84
|
+
const priceOut = Number(process.env.IJFW_AUTOLINK_PRICE_OUT_PER_MTOK || DEFAULT_PRICE_OUT);
|
|
85
|
+
|
|
86
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: {
|
|
89
|
+
'content-type': 'application/json',
|
|
90
|
+
'x-api-key': key,
|
|
91
|
+
'anthropic-version': '2023-06-01',
|
|
92
|
+
},
|
|
93
|
+
body: JSON.stringify({
|
|
94
|
+
model: m,
|
|
95
|
+
max_tokens: maxTokens,
|
|
96
|
+
system,
|
|
97
|
+
messages: [{ role: 'user', content: user }],
|
|
98
|
+
}),
|
|
99
|
+
});
|
|
100
|
+
if (!res.ok) return { skipped: true, reason: `http_${res.status}` };
|
|
101
|
+
const json = await res.json();
|
|
102
|
+
const text = (json.content || []).map((c) => c.text || '').join('');
|
|
103
|
+
const inTok = json.usage?.input_tokens || 0;
|
|
104
|
+
const outTok = json.usage?.output_tokens || 0;
|
|
105
|
+
const usd = (inTok / 1e6) * priceIn + (outTok / 1e6) * priceOut;
|
|
106
|
+
recordSpend(root, usd, m, inTok, outTok);
|
|
107
|
+
return { skipped: false, text, usd, model: m, input_tokens: inTok, output_tokens: outTok };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function parseLlmJsonResponse(raw) {
|
|
111
|
+
if (typeof raw !== 'string') throw new Error('parseLlmJsonResponse: input not a string');
|
|
112
|
+
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
113
|
+
const body = fenced ? fenced[1].trim() : raw.trim();
|
|
114
|
+
try {
|
|
115
|
+
return JSON.parse(body);
|
|
116
|
+
} catch (e) {
|
|
117
|
+
throw new Error(`parseLlmJsonResponse: parse failed -- ${e.message}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export default { llmCall, parseLlmJsonResponse };
|