@nforma.ai/nforma 0.2.1 → 0.28.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/README.md +2 -2
- package/agents/{qgsd-codebase-mapper.md → nf-codebase-mapper.md} +1 -1
- package/agents/{qgsd-debugger.md → nf-debugger.md} +3 -3
- package/agents/{qgsd-executor.md → nf-executor.md} +14 -14
- package/agents/{qgsd-integration-checker.md → nf-integration-checker.md} +1 -1
- package/agents/{qgsd-phase-researcher.md → nf-phase-researcher.md} +6 -6
- package/agents/{qgsd-plan-checker.md → nf-plan-checker.md} +9 -9
- package/agents/{qgsd-planner.md → nf-planner.md} +9 -9
- package/agents/{qgsd-project-researcher.md → nf-project-researcher.md} +2 -2
- package/agents/{qgsd-quorum-orchestrator.md → nf-quorum-orchestrator.md} +33 -33
- package/agents/{qgsd-quorum-slot-worker.md → nf-quorum-slot-worker.md} +3 -3
- package/agents/{qgsd-quorum-synthesizer.md → nf-quorum-synthesizer.md} +3 -3
- package/agents/{qgsd-quorum-test-worker.md → nf-quorum-test-worker.md} +1 -1
- package/agents/{qgsd-quorum-worker.md → nf-quorum-worker.md} +6 -6
- package/agents/{qgsd-research-synthesizer.md → nf-research-synthesizer.md} +5 -5
- package/agents/{qgsd-roadmapper.md → nf-roadmapper.md} +3 -3
- package/agents/{qgsd-verifier.md → nf-verifier.md} +8 -8
- package/bin/accept-debug-invariant.cjs +2 -2
- package/bin/account-manager.cjs +10 -10
- package/bin/aggregate-requirements.cjs +1 -1
- package/bin/analyze-assumptions.cjs +3 -3
- package/bin/analyze-state-space.cjs +14 -14
- package/bin/assumption-register.cjs +146 -0
- package/bin/attribute-trace-divergence.cjs +1 -1
- package/bin/auth-drivers/gh-cli.cjs +1 -1
- package/bin/auth-drivers/pool.cjs +1 -1
- package/bin/autoClosePtoF.cjs +3 -3
- package/bin/budget-tracker.cjs +77 -0
- package/bin/build-layer-manifest.cjs +153 -0
- package/bin/call-quorum-slot.cjs +3 -3
- package/bin/ccr-secure-config.cjs +5 -5
- package/bin/check-bundled-sdks.cjs +1 -1
- package/bin/check-mcp-health.cjs +1 -1
- package/bin/check-provider-health.cjs +6 -6
- package/bin/check-spec-sync.cjs +26 -26
- package/bin/check-trace-schema-drift.cjs +5 -5
- package/bin/conformance-schema.cjs +2 -2
- package/bin/cross-layer-dashboard.cjs +297 -0
- package/bin/design-impact.cjs +377 -0
- package/bin/detect-coverage-gaps.cjs +7 -7
- package/bin/failure-mode-catalog.cjs +227 -0
- package/bin/failure-taxonomy.cjs +177 -0
- package/bin/formal-scope-scan.cjs +179 -0
- package/bin/gate-a-grounding.cjs +334 -0
- package/bin/gate-b-abstraction.cjs +243 -0
- package/bin/gate-c-validation.cjs +166 -0
- package/bin/generate-formal-specs.cjs +17 -17
- package/bin/generate-petri-net.cjs +3 -3
- package/bin/generate-tla-cfg.cjs +5 -5
- package/bin/git-heatmap.cjs +571 -0
- package/bin/harness-diagnostic.cjs +326 -0
- package/bin/hazard-model.cjs +261 -0
- package/bin/install-formal-tools.cjs +1 -1
- package/bin/install.js +184 -139
- package/bin/instrumentation-map.cjs +178 -0
- package/bin/invariant-catalog.cjs +437 -0
- package/bin/issue-classifier.cjs +2 -2
- package/bin/load-baseline-requirements.cjs +4 -4
- package/bin/manage-agents-core.cjs +32 -32
- package/bin/migrate-to-slots.cjs +39 -39
- package/bin/mismatch-register.cjs +217 -0
- package/bin/nForma.cjs +176 -81
- package/bin/{qgsd-solve.cjs → nf-solve.cjs} +327 -14
- package/bin/observe-config.cjs +8 -0
- package/bin/observe-debt-writer.cjs +1 -1
- package/bin/observe-handler-deps.cjs +356 -0
- package/bin/observe-handler-grafana.cjs +2 -17
- package/bin/observe-handler-internal.cjs +5 -5
- package/bin/observe-handler-logstash.cjs +2 -17
- package/bin/observe-handler-prometheus.cjs +2 -17
- package/bin/observe-handler-upstream.cjs +251 -0
- package/bin/observe-handlers.cjs +12 -33
- package/bin/observe-render.cjs +68 -22
- package/bin/observe-utils.cjs +37 -0
- package/bin/observed-fsm.cjs +324 -0
- package/bin/planning-paths.cjs +6 -0
- package/bin/polyrepo.cjs +1 -1
- package/bin/probe-quorum-slots.cjs +1 -1
- package/bin/promote-gate-maturity.cjs +274 -0
- package/bin/promote-model.cjs +1 -1
- package/bin/propose-debug-invariants.cjs +1 -1
- package/bin/quorum-cache.cjs +144 -0
- package/bin/quorum-consensus-gate.cjs +1 -1
- package/bin/quorum-slot-dispatch.cjs +6 -6
- package/bin/requirements-core.cjs +1 -1
- package/bin/review-mcp-logs.cjs +1 -1
- package/bin/risk-heatmap.cjs +151 -0
- package/bin/run-account-manager-tlc.cjs +4 -4
- package/bin/run-account-pool-alloy.cjs +2 -2
- package/bin/run-alloy.cjs +2 -2
- package/bin/run-audit-alloy.cjs +2 -2
- package/bin/run-breaker-tlc.cjs +3 -3
- package/bin/run-formal-check.cjs +9 -9
- package/bin/run-formal-verify.cjs +30 -9
- package/bin/run-installer-alloy.cjs +2 -2
- package/bin/run-oscillation-tlc.cjs +4 -4
- package/bin/run-phase-tlc.cjs +1 -1
- package/bin/run-protocol-tlc.cjs +4 -4
- package/bin/run-quorum-composition-alloy.cjs +2 -2
- package/bin/run-sensitivity-sweep.cjs +2 -2
- package/bin/run-stop-hook-tlc.cjs +3 -3
- package/bin/run-tlc.cjs +21 -21
- package/bin/run-transcript-alloy.cjs +2 -2
- package/bin/secrets.cjs +5 -5
- package/bin/security-sweep.cjs +238 -0
- package/bin/sensitivity-report.cjs +3 -3
- package/bin/set-secret.cjs +5 -5
- package/bin/setup-telemetry-cron.sh +3 -3
- package/bin/stall-detector.cjs +126 -0
- package/bin/state-candidates.cjs +206 -0
- package/bin/sync-baseline-requirements.cjs +1 -1
- package/bin/telemetry-collector.cjs +1 -1
- package/bin/test-changed.cjs +111 -0
- package/bin/test-recipe-gen.cjs +250 -0
- package/bin/trace-corpus-stats.cjs +211 -0
- package/bin/unified-mcp-server.mjs +3 -3
- package/bin/update-scoreboard.cjs +1 -1
- package/bin/validate-memory.cjs +2 -2
- package/bin/validate-traces.cjs +10 -10
- package/bin/verify-quorum-health.cjs +66 -5
- package/bin/xstate-to-tla.cjs +4 -4
- package/bin/xstate-trace-walker.cjs +3 -3
- package/commands/{qgsd → nf}/add-phase.md +3 -3
- package/commands/{qgsd → nf}/add-requirement.md +3 -3
- package/commands/{qgsd → nf}/add-todo.md +3 -3
- package/commands/{qgsd → nf}/audit-milestone.md +4 -4
- package/commands/{qgsd → nf}/check-todos.md +3 -3
- package/commands/{qgsd → nf}/cleanup.md +3 -3
- package/commands/{qgsd → nf}/close-formal-gaps.md +2 -2
- package/commands/{qgsd → nf}/complete-milestone.md +9 -9
- package/commands/{qgsd → nf}/debug.md +9 -9
- package/commands/{qgsd → nf}/discuss-phase.md +3 -3
- package/commands/{qgsd → nf}/execute-phase.md +15 -15
- package/commands/{qgsd → nf}/fix-tests.md +3 -3
- package/commands/{qgsd → nf}/formal-test-sync.md +1 -1
- package/commands/{qgsd → nf}/health.md +3 -3
- package/commands/{qgsd → nf}/help.md +3 -3
- package/commands/{qgsd → nf}/insert-phase.md +3 -3
- package/commands/nf/join-discord.md +18 -0
- package/commands/{qgsd → nf}/list-phase-assumptions.md +2 -2
- package/commands/{qgsd → nf}/map-codebase.md +7 -7
- package/commands/{qgsd → nf}/map-requirements.md +3 -3
- package/commands/{qgsd → nf}/mcp-restart.md +3 -3
- package/commands/{qgsd → nf}/mcp-set-model.md +8 -8
- package/commands/{qgsd → nf}/mcp-setup.md +63 -63
- package/commands/{qgsd → nf}/mcp-status.md +3 -3
- package/commands/{qgsd → nf}/mcp-update.md +7 -7
- package/commands/{qgsd → nf}/new-milestone.md +8 -8
- package/commands/{qgsd → nf}/new-project.md +8 -8
- package/commands/{qgsd → nf}/observe.md +49 -16
- package/commands/{qgsd → nf}/pause-work.md +3 -3
- package/commands/{qgsd → nf}/plan-milestone-gaps.md +5 -5
- package/commands/{qgsd → nf}/plan-phase.md +6 -6
- package/commands/{qgsd → nf}/polyrepo.md +2 -2
- package/commands/{qgsd → nf}/progress.md +3 -3
- package/commands/{qgsd → nf}/queue.md +2 -2
- package/commands/{qgsd → nf}/quick.md +8 -8
- package/commands/{qgsd → nf}/quorum-test.md +10 -10
- package/commands/{qgsd → nf}/quorum.md +40 -40
- package/commands/{qgsd → nf}/reapply-patches.md +2 -2
- package/commands/{qgsd → nf}/remove-phase.md +3 -3
- package/commands/{qgsd → nf}/research-phase.md +12 -12
- package/commands/{qgsd → nf}/resume-work.md +3 -3
- package/commands/nf/review-requirements.md +31 -0
- package/commands/{qgsd → nf}/set-profile.md +3 -3
- package/commands/{qgsd → nf}/settings.md +6 -6
- package/commands/{qgsd → nf}/solve.md +35 -35
- package/commands/{qgsd → nf}/sync-baselines.md +4 -4
- package/commands/{qgsd → nf}/triage.md +10 -10
- package/commands/{qgsd → nf}/update.md +3 -3
- package/commands/{qgsd → nf}/verify-work.md +5 -5
- package/hooks/dist/config-loader.js +188 -32
- package/hooks/dist/conformance-schema.cjs +2 -2
- package/hooks/dist/gsd-context-monitor.js +118 -13
- package/hooks/dist/{qgsd-check-update.js → nf-check-update.js} +5 -5
- package/hooks/dist/{qgsd-circuit-breaker.js → nf-circuit-breaker.js} +35 -24
- package/hooks/dist/nf-circuit-breaker.test.js +1002 -0
- package/hooks/dist/{qgsd-precompact.js → nf-precompact.js} +13 -13
- package/hooks/dist/nf-precompact.test.js +227 -0
- package/hooks/dist/{qgsd-prompt.js → nf-prompt.js} +110 -33
- package/hooks/dist/nf-prompt.test.js +698 -0
- package/hooks/dist/nf-session-start.js +185 -0
- package/hooks/dist/nf-session-start.test.js +354 -0
- package/hooks/dist/{qgsd-slot-correlator.js → nf-slot-correlator.js} +13 -5
- package/hooks/dist/nf-slot-correlator.test.js +85 -0
- package/hooks/dist/{qgsd-spec-regen.js → nf-spec-regen.js} +17 -8
- package/hooks/dist/nf-spec-regen.test.js +73 -0
- package/hooks/dist/{qgsd-statusline.js → nf-statusline.js} +12 -3
- package/hooks/dist/nf-statusline.test.js +157 -0
- package/hooks/dist/{qgsd-stop.js → nf-stop.js} +152 -18
- package/hooks/dist/nf-stop.test.js +1388 -0
- package/hooks/dist/{qgsd-token-collector.js → nf-token-collector.js} +12 -4
- package/hooks/dist/nf-token-collector.test.js +262 -0
- package/hooks/dist/unified-mcp-server.mjs +2 -2
- package/package.json +4 -4
- package/scripts/build-hooks.js +13 -6
- package/scripts/secret-audit.sh +1 -1
- package/scripts/verify-hooks-sync.cjs +90 -0
- package/templates/{qgsd.json → nf.json} +4 -4
- package/commands/qgsd/join-discord.md +0 -18
- package/hooks/dist/qgsd-session-start.js +0 -122
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// hooks/
|
|
3
|
-
// PostToolUse hook: when Claude writes to
|
|
2
|
+
// hooks/nf-spec-regen.js
|
|
3
|
+
// PostToolUse hook: when Claude writes to nf-workflow.machine.ts,
|
|
4
4
|
// automatically trigger generate-formal-specs.cjs to regenerate TLA+/Alloy specs.
|
|
5
5
|
//
|
|
6
6
|
// LOOP-02 (v0.21-03): Self-Calibrating Feedback Loops
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
const { spawnSync } = require('child_process');
|
|
17
17
|
const fs = require('fs');
|
|
18
18
|
const path = require('path');
|
|
19
|
+
const { loadConfig, shouldRunHook } = require('./config-loader');
|
|
19
20
|
|
|
20
21
|
let raw = '';
|
|
21
22
|
process.stdin.setEncoding('utf8');
|
|
@@ -23,11 +24,19 @@ process.stdin.on('data', (chunk) => { raw += chunk; });
|
|
|
23
24
|
process.stdin.on('end', () => {
|
|
24
25
|
try {
|
|
25
26
|
const input = JSON.parse(raw);
|
|
27
|
+
|
|
28
|
+
// Profile guard — exit early if this hook is not active for the current profile
|
|
29
|
+
const config = loadConfig(input.cwd || process.cwd());
|
|
30
|
+
const profile = config.hook_profile || 'standard';
|
|
31
|
+
if (!shouldRunHook('nf-spec-regen', profile)) {
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
|
|
26
35
|
const toolName = input.tool_name || '';
|
|
27
36
|
const filePath = (input.tool_input && input.tool_input.file_path) || '';
|
|
28
37
|
|
|
29
|
-
// Only act on Write calls to
|
|
30
|
-
if (toolName !== 'Write' || !filePath.includes('
|
|
38
|
+
// Only act on Write calls to nf-workflow.machine.ts
|
|
39
|
+
if (toolName !== 'Write' || !filePath.includes('nf-workflow.machine.ts')) {
|
|
31
40
|
process.exit(0); // No-op — not a machine file write
|
|
32
41
|
}
|
|
33
42
|
|
|
@@ -50,16 +59,16 @@ process.stdin.on('end', () => {
|
|
|
50
59
|
(result.error ? String(result.error) : '');
|
|
51
60
|
}
|
|
52
61
|
|
|
53
|
-
// Also regenerate
|
|
62
|
+
// Also regenerate NFQuorum_xstate.tla (xstate-to-tla.cjs)
|
|
54
63
|
const xstateScript = path.join(cwd, 'bin', 'xstate-to-tla.cjs');
|
|
55
|
-
const machineFile = path.join(cwd, 'src', 'machines', '
|
|
56
|
-
const guardsConfig = path.join(cwd, '.planning', 'formal', 'tla', 'guards', '
|
|
64
|
+
const machineFile = path.join(cwd, 'src', 'machines', 'nf-workflow.machine.ts');
|
|
65
|
+
const guardsConfig = path.join(cwd, '.planning', 'formal', 'tla', 'guards', 'nf-workflow.json');
|
|
57
66
|
|
|
58
67
|
if (fs.existsSync(xstateScript) && fs.existsSync(guardsConfig)) {
|
|
59
68
|
const xstateResult = spawnSync(process.execPath, [
|
|
60
69
|
xstateScript, machineFile,
|
|
61
70
|
'--config=' + guardsConfig,
|
|
62
|
-
'--module=
|
|
71
|
+
'--module=NFQuorum'
|
|
63
72
|
], {
|
|
64
73
|
encoding: 'utf8',
|
|
65
74
|
cwd: cwd,
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { test } = require('node:test');
|
|
3
|
+
const assert = require('node:assert');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawnSync } = require('child_process');
|
|
6
|
+
|
|
7
|
+
// Wave 0 RED stubs for LOOP-02: hooks/nf-spec-regen.js
|
|
8
|
+
// These tests define the contract. They will fail until Plan 03 implements the hook.
|
|
9
|
+
|
|
10
|
+
function runHook(inputPayload) {
|
|
11
|
+
const hookPath = path.join(__dirname, 'nf-spec-regen.js');
|
|
12
|
+
const result = spawnSync(process.execPath, [hookPath], {
|
|
13
|
+
input: JSON.stringify(inputPayload),
|
|
14
|
+
encoding: 'utf8',
|
|
15
|
+
timeout: 15000,
|
|
16
|
+
});
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test('LOOP-02: nf-spec-regen.js exits 0 and is a no-op for non-Write tool events', () => {
|
|
21
|
+
const result = runHook({
|
|
22
|
+
tool_name: 'Read',
|
|
23
|
+
tool_input: { file_path: '/some/file.ts' },
|
|
24
|
+
tool_response: {},
|
|
25
|
+
cwd: process.cwd(),
|
|
26
|
+
context_window: {}
|
|
27
|
+
});
|
|
28
|
+
// RED: script does not exist yet
|
|
29
|
+
assert.strictEqual(result.status, 0, 'LOOP-02: hook must exit 0 for non-Write tools. Not yet implemented.');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('LOOP-02: nf-spec-regen.js exits 0 and is a no-op for Write to non-machine file', () => {
|
|
33
|
+
const result = runHook({
|
|
34
|
+
tool_name: 'Write',
|
|
35
|
+
tool_input: { file_path: '/some/other-file.ts' },
|
|
36
|
+
tool_response: {},
|
|
37
|
+
cwd: process.cwd(),
|
|
38
|
+
context_window: {}
|
|
39
|
+
});
|
|
40
|
+
assert.strictEqual(result.status, 0, 'LOOP-02: hook must exit 0 for Write to non-machine file. Not yet implemented.');
|
|
41
|
+
// Should produce no output or empty additionalContext for non-matching files
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('LOOP-02: nf-spec-regen.js exits 0 for Write to nf-workflow.machine.ts and returns additionalContext', () => {
|
|
45
|
+
const result = runHook({
|
|
46
|
+
tool_name: 'Write',
|
|
47
|
+
tool_input: { file_path: '/Users/jonathanborduas/code/QGSD/src/machines/nf-workflow.machine.ts' },
|
|
48
|
+
tool_response: {},
|
|
49
|
+
cwd: '/Users/jonathanborduas/code/QGSD',
|
|
50
|
+
context_window: {}
|
|
51
|
+
});
|
|
52
|
+
assert.strictEqual(result.status, 0, 'LOOP-02: hook must exit 0 for machine file write. Not yet implemented.');
|
|
53
|
+
// Output must be valid JSON with additionalContext field
|
|
54
|
+
if (result.stdout && result.stdout.trim()) {
|
|
55
|
+
let parsed;
|
|
56
|
+
assert.doesNotThrow(() => { parsed = JSON.parse(result.stdout); },
|
|
57
|
+
'LOOP-02: stdout must be valid JSON when hook fires. Not yet implemented.');
|
|
58
|
+
assert.ok(
|
|
59
|
+
parsed && parsed.hookSpecificOutput && parsed.hookSpecificOutput.additionalContext,
|
|
60
|
+
'LOOP-02: output must contain hookSpecificOutput.additionalContext. Not yet implemented.'
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('LOOP-02: nf-spec-regen.js exits 0 (fail-open) on malformed stdin JSON', () => {
|
|
66
|
+
const hookPath = path.join(__dirname, 'nf-spec-regen.js');
|
|
67
|
+
const result = spawnSync(process.execPath, [hookPath], {
|
|
68
|
+
input: 'not-valid-json',
|
|
69
|
+
encoding: 'utf8',
|
|
70
|
+
timeout: 10000,
|
|
71
|
+
});
|
|
72
|
+
assert.strictEqual(result.status, 0, 'LOOP-02: hook must exit 0 on malformed JSON (fail-open). Not yet implemented.');
|
|
73
|
+
});
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const os = require('os');
|
|
8
|
+
const { loadConfig, shouldRunHook } = require('./config-loader');
|
|
8
9
|
|
|
9
10
|
// Read JSON from stdin
|
|
10
11
|
let input = '';
|
|
@@ -13,6 +14,14 @@ process.stdin.on('data', chunk => input += chunk);
|
|
|
13
14
|
process.stdin.on('end', () => {
|
|
14
15
|
try {
|
|
15
16
|
const data = JSON.parse(input);
|
|
17
|
+
|
|
18
|
+
// Profile guard — exit early if this hook is not active for the current profile
|
|
19
|
+
const config = loadConfig(data.workspace?.current_dir || process.cwd());
|
|
20
|
+
const profile = config.hook_profile || 'standard';
|
|
21
|
+
if (!shouldRunHook('nf-statusline', profile)) {
|
|
22
|
+
process.exit(0);
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
const model = data.model?.display_name || 'Claude';
|
|
17
26
|
const dir = data.workspace?.current_dir || process.cwd();
|
|
18
27
|
const session = data.session_id || '';
|
|
@@ -66,14 +75,14 @@ process.stdin.on('end', () => {
|
|
|
66
75
|
}
|
|
67
76
|
}
|
|
68
77
|
|
|
69
|
-
//
|
|
78
|
+
// nForma update available?
|
|
70
79
|
let gsdUpdate = '';
|
|
71
|
-
const cacheFile = path.join(homeDir, '.claude', 'cache', '
|
|
80
|
+
const cacheFile = path.join(homeDir, '.claude', 'cache', 'nf-update-check.json');
|
|
72
81
|
if (fs.existsSync(cacheFile)) {
|
|
73
82
|
try {
|
|
74
83
|
const cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
|
75
84
|
if (cache.update_available) {
|
|
76
|
-
gsdUpdate = '\x1b[33m⬆ /
|
|
85
|
+
gsdUpdate = '\x1b[33m⬆ /nf:update\x1b[0m │ ';
|
|
77
86
|
}
|
|
78
87
|
} catch (e) {}
|
|
79
88
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Test suite for hooks/nf-statusline.js
|
|
3
|
+
// Uses Node.js built-in test runner: node --test hooks/nf-statusline.test.js
|
|
4
|
+
//
|
|
5
|
+
// Each test spawns the hook as a child process with mock stdin (JSON payload).
|
|
6
|
+
// Captures stdout + exit code. The hook reads JSON from stdin and writes
|
|
7
|
+
// formatted statusline text to stdout.
|
|
8
|
+
|
|
9
|
+
const { test } = require('node:test');
|
|
10
|
+
const assert = require('node:assert/strict');
|
|
11
|
+
const { spawnSync } = require('child_process');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const HOOK_PATH = path.join(__dirname, 'nf-statusline.js');
|
|
17
|
+
|
|
18
|
+
// Helper: run the hook with a given stdin JSON payload and optional extra env vars
|
|
19
|
+
function runHook(stdinPayload, extraEnv) {
|
|
20
|
+
const input = typeof stdinPayload === 'string'
|
|
21
|
+
? stdinPayload
|
|
22
|
+
: JSON.stringify(stdinPayload);
|
|
23
|
+
|
|
24
|
+
const result = spawnSync('node', [HOOK_PATH], {
|
|
25
|
+
input,
|
|
26
|
+
encoding: 'utf8',
|
|
27
|
+
timeout: 5000,
|
|
28
|
+
env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
|
|
29
|
+
});
|
|
30
|
+
return {
|
|
31
|
+
stdout: result.stdout || '',
|
|
32
|
+
stderr: result.stderr || '',
|
|
33
|
+
exitCode: result.status,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Helper: create a temp directory structure, write a file inside it, return tempDir
|
|
38
|
+
function makeTempDir(suffix) {
|
|
39
|
+
const dir = path.join(os.tmpdir(), `nf-sl-test-${Date.now()}-${suffix}`);
|
|
40
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
41
|
+
return dir;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// --- Test Cases ---
|
|
45
|
+
|
|
46
|
+
// TC1: Minimal payload — stdout contains model name and directory basename
|
|
47
|
+
test('TC1: minimal payload includes model name and directory name', () => {
|
|
48
|
+
const { stdout, exitCode } = runHook({
|
|
49
|
+
model: { display_name: 'TestModel' },
|
|
50
|
+
workspace: { current_dir: '/tmp/myproject' },
|
|
51
|
+
});
|
|
52
|
+
assert.strictEqual(exitCode, 0, 'exit code must be 0');
|
|
53
|
+
assert.ok(stdout.includes('TestModel'), 'stdout must include model name "TestModel"');
|
|
54
|
+
assert.ok(stdout.includes('myproject'), 'stdout must include directory basename "myproject"');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// TC2: Context at 100% remaining (0% used) → green bar, 0%
|
|
58
|
+
// rawUsed = 100 - 100 = 0; scaled = round(0 / 80 * 100) = 0; filled = 0 → all empty blocks
|
|
59
|
+
test('TC2: context at 100% remaining shows all-empty bar at 0%', () => {
|
|
60
|
+
const { stdout, exitCode } = runHook({
|
|
61
|
+
model: { display_name: 'M' },
|
|
62
|
+
context_window: { remaining_percentage: 100 },
|
|
63
|
+
});
|
|
64
|
+
assert.strictEqual(exitCode, 0, 'exit code must be 0');
|
|
65
|
+
assert.ok(stdout.includes('░░░░░░░░░░'), 'stdout must include all-empty bar (0% used)');
|
|
66
|
+
assert.ok(stdout.includes('0%'), 'stdout must include 0%');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// TC3: Context at 20% remaining (80% used → scaled 100%) → full bar, 100%
|
|
70
|
+
// rawUsed = 80; scaled = round(80/80 * 100) = 100; filled = 10 → full blocks
|
|
71
|
+
// At 100% scaled, the hook uses the skull emoji and blink+red ANSI code
|
|
72
|
+
test('TC3: context at 20% remaining shows full bar at 100% (skull zone)', () => {
|
|
73
|
+
const { stdout, exitCode } = runHook({
|
|
74
|
+
model: { display_name: 'M' },
|
|
75
|
+
context_window: { remaining_percentage: 20 },
|
|
76
|
+
});
|
|
77
|
+
assert.strictEqual(exitCode, 0, 'exit code must be 0');
|
|
78
|
+
assert.ok(stdout.includes('100%'), 'stdout must include 100%');
|
|
79
|
+
assert.ok(stdout.includes('██████████'), 'stdout must include full bar (10 filled segments)');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// TC4: Context at 51% remaining (49% used → scaled 61%) → green zone (scaled < 63)
|
|
83
|
+
// rawUsed = 49; scaled = round(49/80 * 100) = round(61.25) = 61; 61 < 63 → green
|
|
84
|
+
test('TC4: context at 51% remaining shows 61% in green (below 63% yellow threshold)', () => {
|
|
85
|
+
const { stdout, exitCode } = runHook({
|
|
86
|
+
model: { display_name: 'M' },
|
|
87
|
+
context_window: { remaining_percentage: 51 },
|
|
88
|
+
});
|
|
89
|
+
assert.strictEqual(exitCode, 0, 'exit code must be 0');
|
|
90
|
+
assert.ok(stdout.includes('61%'), 'stdout must include 61%');
|
|
91
|
+
assert.ok(stdout.includes('\x1b[32m'), 'stdout must include green ANSI code \\x1b[32m');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// TC5: Context at 36% remaining (64% used → scaled 80%) → yellow zone (63 <= scaled < 81)
|
|
95
|
+
// rawUsed = 64; scaled = round(64/80 * 100) = round(80) = 80; 63 <= 80 < 81 → yellow
|
|
96
|
+
test('TC5: context at 36% remaining shows 80% in yellow (63–80% yellow zone)', () => {
|
|
97
|
+
const { stdout, exitCode } = runHook({
|
|
98
|
+
model: { display_name: 'M' },
|
|
99
|
+
context_window: { remaining_percentage: 36 },
|
|
100
|
+
});
|
|
101
|
+
assert.strictEqual(exitCode, 0, 'exit code must be 0');
|
|
102
|
+
assert.ok(stdout.includes('80%'), 'stdout must include 80%');
|
|
103
|
+
assert.ok(stdout.includes('\x1b[33m'), 'stdout must include yellow ANSI code \\x1b[33m');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// TC6: Malformed JSON input → exits 0, stdout is empty (silent fail)
|
|
107
|
+
test('TC6: malformed JSON input exits 0 with empty stdout (silent fail)', () => {
|
|
108
|
+
const { stdout, exitCode } = runHook('this is not valid json');
|
|
109
|
+
assert.strictEqual(exitCode, 0, 'exit code must be 0');
|
|
110
|
+
assert.strictEqual(stdout, '', 'stdout must be empty on malformed JSON input');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// TC7: Update available — output includes '/nf:update'
|
|
114
|
+
test('TC7: update available banner shows /nf:update in output', () => {
|
|
115
|
+
const tempHome = makeTempDir('tc7');
|
|
116
|
+
const cacheDir = path.join(tempHome, '.claude', 'cache');
|
|
117
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
118
|
+
const cacheFile = path.join(cacheDir, 'nf-update-check.json');
|
|
119
|
+
fs.writeFileSync(cacheFile, JSON.stringify({ update_available: true, latest: '1.0.1' }), 'utf8');
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const { stdout, exitCode } = runHook(
|
|
123
|
+
{ model: { display_name: 'M' } },
|
|
124
|
+
{ HOME: tempHome }
|
|
125
|
+
);
|
|
126
|
+
assert.strictEqual(exitCode, 0, 'exit code must be 0');
|
|
127
|
+
assert.ok(stdout.includes('/nf:update'), 'stdout must include /nf:update when update is available');
|
|
128
|
+
} finally {
|
|
129
|
+
fs.rmSync(tempHome, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// TC8: Task in progress — output includes the task's activeForm text
|
|
134
|
+
test('TC8: in-progress task is shown in statusline output', () => {
|
|
135
|
+
const tempHome = makeTempDir('tc8');
|
|
136
|
+
const todosDir = path.join(tempHome, '.claude', 'todos');
|
|
137
|
+
fs.mkdirSync(todosDir, { recursive: true });
|
|
138
|
+
|
|
139
|
+
const sessionId = 'sess123';
|
|
140
|
+
const todosFile = path.join(todosDir, `${sessionId}-agent-0.json`);
|
|
141
|
+
fs.writeFileSync(
|
|
142
|
+
todosFile,
|
|
143
|
+
JSON.stringify([{ status: 'in_progress', activeForm: 'Fix the thing' }]),
|
|
144
|
+
'utf8'
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const { stdout, exitCode } = runHook(
|
|
149
|
+
{ model: { display_name: 'M' }, session_id: sessionId },
|
|
150
|
+
{ HOME: tempHome }
|
|
151
|
+
);
|
|
152
|
+
assert.strictEqual(exitCode, 0, 'exit code must be 0');
|
|
153
|
+
assert.ok(stdout.includes('Fix the thing'), 'stdout must include the in-progress task activeForm text');
|
|
154
|
+
} finally {
|
|
155
|
+
fs.rmSync(tempHome, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// hooks/
|
|
2
|
+
// hooks/nf-stop.js
|
|
3
3
|
// Stop hook — quorum verification gate for GSD planning commands.
|
|
4
4
|
//
|
|
5
5
|
// Reads JSON from stdin (Claude Code Stop event payload), applies guards in
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
// evidence. Blocks with decision:block if a planning command was issued but
|
|
8
8
|
// quorum tool calls are missing. Fails open on all errors.
|
|
9
9
|
//
|
|
10
|
-
// Config: ~/.claude/
|
|
10
|
+
// Config: ~/.claude/nf.json (two-layer merge via shared config-loader)
|
|
11
11
|
// Unavailability: reads ~/.claude.json mcpServers to detect which models are installed
|
|
12
|
-
// (
|
|
12
|
+
// (NF_CLAUDE_JSON env var overrides the path — for testing only)
|
|
13
13
|
|
|
14
14
|
'use strict';
|
|
15
15
|
|
|
@@ -17,9 +17,15 @@ const fs = require('fs');
|
|
|
17
17
|
const path = require('path');
|
|
18
18
|
const os = require('os');
|
|
19
19
|
|
|
20
|
-
const { loadConfig, DEFAULT_CONFIG, slotToToolCall } = require('./config-loader');
|
|
20
|
+
const { loadConfig, DEFAULT_CONFIG, slotToToolCall, shouldRunHook } = require('./config-loader');
|
|
21
21
|
const { schema_version } = require('./conformance-schema.cjs');
|
|
22
22
|
|
|
23
|
+
// Cache module — fail-open: if require fails, all cache logic is skipped
|
|
24
|
+
let cacheModule = null;
|
|
25
|
+
try {
|
|
26
|
+
cacheModule = require(path.join(__dirname, '..', 'bin', 'quorum-cache.cjs'));
|
|
27
|
+
} catch (_) { /* fail-open: cache unavailable */ }
|
|
28
|
+
|
|
23
29
|
// Appends a structured conformance event to .planning/conformance-events.jsonl.
|
|
24
30
|
// Uses appendFileSync (atomic for writes < POSIX PIPE_BUF = 4096 bytes).
|
|
25
31
|
// Always wrapped in try/catch — hooks are fail-open; never crashes on logging failure.
|
|
@@ -30,14 +36,14 @@ function appendConformanceEvent(event) {
|
|
|
30
36
|
const logPath = pp.resolve(process.cwd(), 'conformance-events');
|
|
31
37
|
fs.appendFileSync(logPath, JSON.stringify(event) + '\n', 'utf8');
|
|
32
38
|
} catch (err) {
|
|
33
|
-
process.stderr.write('[
|
|
39
|
+
process.stderr.write('[nf] conformance log write failed: ' + err.message + '\n');
|
|
34
40
|
}
|
|
35
41
|
}
|
|
36
42
|
|
|
37
|
-
// Builds the regex that matches /gsd:<
|
|
43
|
+
// Builds the regex that matches /nf:<cmd>, /gsd:<cmd>, or /qgsd:<cmd> in any text.
|
|
38
44
|
function buildCommandPattern(quorumCommands) {
|
|
39
45
|
const escaped = quorumCommands.map(c => c.replace(/-/g, '\\-'));
|
|
40
|
-
return new RegExp('\\/q?gsd:(' + escaped.join('|') + ')');
|
|
46
|
+
return new RegExp('\\/(nf|q?gsd):(' + escaped.join('|') + ')');
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
// Returns true if a parsed JSONL user entry is a human text message.
|
|
@@ -119,9 +125,9 @@ function hasQuorumCommand(currentTurnLines, cmdPattern) {
|
|
|
119
125
|
return false;
|
|
120
126
|
}
|
|
121
127
|
|
|
122
|
-
// Extracts the matched /gsd:<
|
|
128
|
+
// Extracts the matched /nf:<cmd>, /gsd:<cmd>, or /qgsd:<cmd> text from the first matching user line.
|
|
123
129
|
// Uses XML-tag-first strategy: prefers the <command-name> tag for accurate command identification.
|
|
124
|
-
// Falls back to first 300 chars of message text, then to '/
|
|
130
|
+
// Falls back to first 300 chars of message text, then to '/nf:plan-phase' as ultimate fallback.
|
|
125
131
|
function extractCommand(currentTurnLines, cmdPattern) {
|
|
126
132
|
for (const line of currentTurnLines) {
|
|
127
133
|
try {
|
|
@@ -148,12 +154,12 @@ function extractCommand(currentTurnLines, cmdPattern) {
|
|
|
148
154
|
// Skip malformed lines
|
|
149
155
|
}
|
|
150
156
|
}
|
|
151
|
-
return '/
|
|
157
|
+
return '/nf:plan-phase';
|
|
152
158
|
}
|
|
153
159
|
|
|
154
|
-
// Returns true if any assistant turn used Task(subagent_type=
|
|
160
|
+
// Returns true if any assistant turn used Task(subagent_type=nf-quorum-slot-worker).
|
|
155
161
|
// Slot-workers are the inline dispatch mechanism — one per active slot per round.
|
|
156
|
-
// Replaced
|
|
162
|
+
// Replaced nf-quorum-orchestrator (deprecated quick-103) as full quorum evidence.
|
|
157
163
|
function wasSlotWorkerUsed(currentTurnLines) {
|
|
158
164
|
for (const line of currentTurnLines) {
|
|
159
165
|
try {
|
|
@@ -165,7 +171,7 @@ function wasSlotWorkerUsed(currentTurnLines) {
|
|
|
165
171
|
if (block.type !== 'tool_use' || block.name !== 'Task') continue;
|
|
166
172
|
const input = block.input || {};
|
|
167
173
|
const subagentType = input.subagent_type || input.subagentType || '';
|
|
168
|
-
if (subagentType === '
|
|
174
|
+
if (subagentType === 'nf-quorum-slot-worker') return true;
|
|
169
175
|
}
|
|
170
176
|
} catch { /* skip */ }
|
|
171
177
|
}
|
|
@@ -281,7 +287,7 @@ function buildAgentPool(config) {
|
|
|
281
287
|
// Returns an array of derived tool prefixes (e.g. ['mcp__codex-cli-1__', 'mcp__gemini-cli-1__']).
|
|
282
288
|
// Returns null if the file is missing or malformed — callers treat null as "unknown" (conservative).
|
|
283
289
|
//
|
|
284
|
-
// TESTING ONLY: set
|
|
290
|
+
// TESTING ONLY: set NF_CLAUDE_JSON env var to override the file path.
|
|
285
291
|
// In production, always reads ~/.claude.json.
|
|
286
292
|
//
|
|
287
293
|
// KNOWN LIMITATION: Only reads ~/.claude.json (user-scoped MCPs). Project-scoped MCPs
|
|
@@ -289,14 +295,14 @@ function buildAgentPool(config) {
|
|
|
289
295
|
// project level, it will be classified as unavailable and skipped (fail-open).
|
|
290
296
|
// In practice, quorum models (Codex, Gemini, OpenCode) are global tools.
|
|
291
297
|
function getAvailableMcpPrefixes() {
|
|
292
|
-
const claudeJsonPath = process.env.
|
|
298
|
+
const claudeJsonPath = process.env.NF_CLAUDE_JSON || path.join(os.homedir(), '.claude.json');
|
|
293
299
|
if (!fs.existsSync(claudeJsonPath)) return null;
|
|
294
300
|
try {
|
|
295
301
|
const d = JSON.parse(fs.readFileSync(claudeJsonPath, 'utf8'));
|
|
296
302
|
const servers = d.mcpServers || {};
|
|
297
303
|
return Object.keys(servers).map(name => 'mcp__' + name + '__');
|
|
298
304
|
} catch (e) {
|
|
299
|
-
process.stderr.write('[
|
|
305
|
+
process.stderr.write('[nf] WARNING: Could not parse ~/.claude.json: ' + e.message + '\n');
|
|
300
306
|
return null;
|
|
301
307
|
}
|
|
302
308
|
}
|
|
@@ -313,6 +319,35 @@ const ARTIFACT_PATTERNS = [
|
|
|
313
319
|
/PROJECT\.md/, // PROJECT.md (new-project early commit)
|
|
314
320
|
];
|
|
315
321
|
|
|
322
|
+
// Counts the number of distinct quorum dispatch rounds in the current turn.
|
|
323
|
+
// A dispatch round is one assistant message that contains at least one Task tool_use
|
|
324
|
+
// block targeting nf-quorum-slot-worker. Multiple parallel dispatches within one
|
|
325
|
+
// assistant message count as a single round (they are dispatched simultaneously).
|
|
326
|
+
// Returns Math.max(1, rounds) — at least round 1 even if no explicit dispatch detected.
|
|
327
|
+
function countDeliberationRounds(currentTurnLines) {
|
|
328
|
+
let rounds = 0;
|
|
329
|
+
for (const line of currentTurnLines) {
|
|
330
|
+
try {
|
|
331
|
+
const entry = JSON.parse(line);
|
|
332
|
+
if (entry.type !== 'assistant') continue;
|
|
333
|
+
const content = entry.message && entry.message.content;
|
|
334
|
+
if (!Array.isArray(content)) continue;
|
|
335
|
+
let hasSlotWorker = false;
|
|
336
|
+
for (const block of content) {
|
|
337
|
+
if (block.type === 'tool_use' && block.name === 'Task') {
|
|
338
|
+
const inputStr = JSON.stringify(block.input || {});
|
|
339
|
+
if (inputStr.includes('nf-quorum-slot-worker')) {
|
|
340
|
+
hasSlotWorker = true;
|
|
341
|
+
break; // One per assistant message — no need to check more blocks
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (hasSlotWorker) rounds++;
|
|
346
|
+
} catch { /* skip malformed lines */ }
|
|
347
|
+
}
|
|
348
|
+
return Math.max(1, rounds);
|
|
349
|
+
}
|
|
350
|
+
|
|
316
351
|
// Returns true if the current turn contains a Bash tool_use block that BOTH:
|
|
317
352
|
// (a) invokes gsd-tools.cjs commit, AND
|
|
318
353
|
// (b) references a planning artifact file path (not codebase/*.md).
|
|
@@ -401,6 +436,54 @@ function deriveMissingToolName(modelKey, modelDef) {
|
|
|
401
436
|
return prefix + modelKey;
|
|
402
437
|
}
|
|
403
438
|
|
|
439
|
+
// Returns true if any user entry in currentTurnLines contains the NF_CACHE_HIT marker.
|
|
440
|
+
// This marker is injected by nf-prompt.js when a valid cache hit is found.
|
|
441
|
+
function hasCacheHitMarker(currentTurnLines) {
|
|
442
|
+
for (const line of currentTurnLines) {
|
|
443
|
+
try {
|
|
444
|
+
const entry = JSON.parse(line);
|
|
445
|
+
if (entry.type !== 'user') continue;
|
|
446
|
+
const content = entry.message?.content;
|
|
447
|
+
let text = '';
|
|
448
|
+
if (typeof content === 'string') {
|
|
449
|
+
text = content;
|
|
450
|
+
} else if (Array.isArray(content)) {
|
|
451
|
+
text = content
|
|
452
|
+
.filter(c => c?.type === 'text')
|
|
453
|
+
.map(c => c.text || '')
|
|
454
|
+
.join('');
|
|
455
|
+
}
|
|
456
|
+
if (text.includes('<!-- NF_CACHE_HIT -->')) return true;
|
|
457
|
+
} catch { /* skip malformed lines */ }
|
|
458
|
+
}
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Extracts the cache key from NF_CACHE_KEY marker in currentTurnLines.
|
|
463
|
+
// Returns the hex string or null if not found.
|
|
464
|
+
function extractCacheKey(currentTurnLines) {
|
|
465
|
+
const keyPattern = /<!-- NF_CACHE_KEY:([a-f0-9]+) -->/;
|
|
466
|
+
for (const line of currentTurnLines) {
|
|
467
|
+
try {
|
|
468
|
+
const entry = JSON.parse(line);
|
|
469
|
+
if (entry.type !== 'user') continue;
|
|
470
|
+
const content = entry.message?.content;
|
|
471
|
+
let text = '';
|
|
472
|
+
if (typeof content === 'string') {
|
|
473
|
+
text = content;
|
|
474
|
+
} else if (Array.isArray(content)) {
|
|
475
|
+
text = content
|
|
476
|
+
.filter(c => c?.type === 'text')
|
|
477
|
+
.map(c => c.text || '')
|
|
478
|
+
.join('');
|
|
479
|
+
}
|
|
480
|
+
const m = text.match(keyPattern);
|
|
481
|
+
if (m) return m[1];
|
|
482
|
+
} catch { /* skip malformed lines */ }
|
|
483
|
+
}
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
|
|
404
487
|
function main() {
|
|
405
488
|
let raw = '';
|
|
406
489
|
process.stdin.setEncoding('utf8');
|
|
@@ -426,6 +509,12 @@ function main() {
|
|
|
426
509
|
|
|
427
510
|
const config = loadConfig();
|
|
428
511
|
|
|
512
|
+
// Profile guard — exit early if this hook is not active for the current profile
|
|
513
|
+
const profile = config.hook_profile || 'standard';
|
|
514
|
+
if (!shouldRunHook('nf-stop', profile)) {
|
|
515
|
+
process.exit(0);
|
|
516
|
+
}
|
|
517
|
+
|
|
429
518
|
// Read and split transcript JSONL; skip empty lines
|
|
430
519
|
const lines = fs.readFileSync(input.transcript_path, 'utf8')
|
|
431
520
|
.split('\n')
|
|
@@ -435,7 +524,10 @@ function main() {
|
|
|
435
524
|
const currentTurnLines = getCurrentTurnLines(lines);
|
|
436
525
|
|
|
437
526
|
// Build command pattern once; reuse for detection and extraction
|
|
438
|
-
|
|
527
|
+
// Strict mode: match ANY /nf: or /gsd: or /qgsd: command, not just quorum_commands list.
|
|
528
|
+
const cmdPattern = profile === 'strict'
|
|
529
|
+
? /\/(nf|q?gsd):[\w][\w-]*/
|
|
530
|
+
: buildCommandPattern(config.quorum_commands);
|
|
439
531
|
|
|
440
532
|
// GUARD 4: Only enforce quorum if a planning command is in current turn (STOP-06)
|
|
441
533
|
if (!hasQuorumCommand(currentTurnLines, cmdPattern)) {
|
|
@@ -469,6 +561,24 @@ function main() {
|
|
|
469
561
|
process.exit(0); // Solo mode: Claude's vote is the quorum — no block
|
|
470
562
|
}
|
|
471
563
|
|
|
564
|
+
// GUARD 7: Cache hit bypass — nf-prompt.js validated the cache entry and injected
|
|
565
|
+
// the NF_CACHE_HIT marker. Trust it: the entry was completed, within TTL, and
|
|
566
|
+
// matches git HEAD + quorum_active composition (EventualConsensus preserved).
|
|
567
|
+
if (hasCacheHitMarker(currentTurnLines)) {
|
|
568
|
+
appendConformanceEvent({
|
|
569
|
+
ts: new Date().toISOString(),
|
|
570
|
+
phase: 'DECIDING',
|
|
571
|
+
action: 'quorum_complete',
|
|
572
|
+
cache_hit: true,
|
|
573
|
+
pass_at_k: 0,
|
|
574
|
+
slots_available: 0,
|
|
575
|
+
vote_result: 0,
|
|
576
|
+
outcome: 'APPROVE',
|
|
577
|
+
schema_version,
|
|
578
|
+
});
|
|
579
|
+
process.exit(0); // Cache hit: quorum approved via cached result
|
|
580
|
+
}
|
|
581
|
+
|
|
472
582
|
// Build agent pool from config
|
|
473
583
|
const agentPool = buildAgentPool(config);
|
|
474
584
|
|
|
@@ -481,7 +591,7 @@ function main() {
|
|
|
481
591
|
}
|
|
482
592
|
|
|
483
593
|
// Ceiling: require maxSize successful (non-error) responses from the full pool.
|
|
484
|
-
// Named maxSize for consistency with
|
|
594
|
+
// Named maxSize for consistency with nf-prompt.js and the config schema.
|
|
485
595
|
// --n N override: N total participants means N-1 external models required.
|
|
486
596
|
const maxSize = quorumSizeOverride !== null && quorumSizeOverride > 1
|
|
487
597
|
? quorumSizeOverride - 1 // --n N means N-1 external models required
|
|
@@ -532,10 +642,34 @@ function main() {
|
|
|
532
642
|
process.exit(0);
|
|
533
643
|
}
|
|
534
644
|
|
|
645
|
+
// Cache backfill: update pending cache entry with vote result after successful quorum
|
|
646
|
+
if (cacheModule) {
|
|
647
|
+
try {
|
|
648
|
+
const cKey = extractCacheKey(currentTurnLines);
|
|
649
|
+
if (cKey) {
|
|
650
|
+
const cacheDir = path.join(process.cwd(), '.planning', '.quorum-cache');
|
|
651
|
+
const pendingEntry = cacheModule.readCache(cKey, cacheDir);
|
|
652
|
+
// readCache returns null for pending entries (no completed field) — read raw instead
|
|
653
|
+
const cacheFilePath = path.join(cacheDir, `${cKey}.json`);
|
|
654
|
+
if (fs.existsSync(cacheFilePath)) {
|
|
655
|
+
const rawEntry = JSON.parse(fs.readFileSync(cacheFilePath, 'utf8'));
|
|
656
|
+
rawEntry.vote_result = successCount;
|
|
657
|
+
rawEntry.outcome = 'APPROVE';
|
|
658
|
+
rawEntry.completed = new Date().toISOString();
|
|
659
|
+
cacheModule.writeCache(cKey, rawEntry, cacheDir);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
} catch (backfillErr) {
|
|
663
|
+
// Fail-open: backfill failure never blocks quorum approval
|
|
664
|
+
process.stderr.write('[nf] cache backfill failed (fail-open): ' + (backfillErr.message || backfillErr) + '\n');
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
535
668
|
appendConformanceEvent({
|
|
536
669
|
ts: new Date().toISOString(),
|
|
537
670
|
phase: 'DECIDING',
|
|
538
671
|
action: 'quorum_complete',
|
|
672
|
+
pass_at_k: countDeliberationRounds(currentTurnLines),
|
|
539
673
|
slots_available: agentPool.length,
|
|
540
674
|
vote_result: successCount,
|
|
541
675
|
outcome: 'APPROVE',
|