@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
|
#!/usr/bin/env node
|
|
2
|
-
// hooks/
|
|
3
|
-
// PreCompact hook — injects
|
|
2
|
+
// hooks/nf-precompact.js
|
|
3
|
+
// PreCompact hook — injects nForma session state as additionalContext before context compaction.
|
|
4
4
|
// Reads .planning/STATE.md "Current Position" section and any pending task files.
|
|
5
5
|
// Output survives compaction and appears in the first message of the compacted context.
|
|
6
6
|
// Fails open on all errors — never blocks compaction.
|
|
@@ -30,7 +30,7 @@ function extractCurrentPosition(stateContent) {
|
|
|
30
30
|
return section.trim() || null;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
// Read pending task files without consuming them (unlike
|
|
33
|
+
// Read pending task files without consuming them (unlike nf-prompt.js's consumePendingTask).
|
|
34
34
|
// Checks .claude/pending-task.txt and .claude/pending-task-*.txt files.
|
|
35
35
|
// Returns an array of { filename, content } objects for each file found.
|
|
36
36
|
function readPendingTasks(cwd) {
|
|
@@ -46,7 +46,7 @@ function readPendingTasks(cwd) {
|
|
|
46
46
|
const content = fs.readFileSync(genericFile, 'utf8').trim();
|
|
47
47
|
if (content) results.push({ filename: 'pending-task.txt', content });
|
|
48
48
|
} catch (e) {
|
|
49
|
-
process.stderr.write('[
|
|
49
|
+
process.stderr.write('[nf-precompact] Could not read ' + genericFile + ': ' + e.message + '\n');
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
@@ -60,12 +60,12 @@ function readPendingTasks(cwd) {
|
|
|
60
60
|
const content = fs.readFileSync(filePath, 'utf8').trim();
|
|
61
61
|
if (content) results.push({ filename: entry, content });
|
|
62
62
|
} catch (e) {
|
|
63
|
-
process.stderr.write('[
|
|
63
|
+
process.stderr.write('[nf-precompact] Could not read ' + filePath + ': ' + e.message + '\n');
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
} catch (e) {
|
|
68
|
-
process.stderr.write('[
|
|
68
|
+
process.stderr.write('[nf-precompact] Could not read .claude dir: ' + e.message + '\n');
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
return results;
|
|
@@ -85,14 +85,14 @@ process.stdin.on('end', () => {
|
|
|
85
85
|
|
|
86
86
|
if (!fs.existsSync(statePath)) {
|
|
87
87
|
// No STATE.md — minimal context
|
|
88
|
-
additionalContext = '
|
|
88
|
+
additionalContext = 'nForma session resumed after compaction. Run `cat .planning/STATE.md` for project state.';
|
|
89
89
|
} else {
|
|
90
90
|
let stateContent;
|
|
91
91
|
try {
|
|
92
92
|
stateContent = fs.readFileSync(statePath, 'utf8');
|
|
93
93
|
} catch (e) {
|
|
94
|
-
process.stderr.write('[
|
|
95
|
-
additionalContext = '
|
|
94
|
+
process.stderr.write('[nf-precompact] Could not read STATE.md: ' + e.message + '\n');
|
|
95
|
+
additionalContext = 'nForma session resumed after compaction. Run `cat .planning/STATE.md` for project state.';
|
|
96
96
|
emitOutput(additionalContext);
|
|
97
97
|
return;
|
|
98
98
|
}
|
|
@@ -101,7 +101,7 @@ process.stdin.on('end', () => {
|
|
|
101
101
|
const pendingTasks = readPendingTasks(cwd);
|
|
102
102
|
|
|
103
103
|
const lines = [
|
|
104
|
-
'
|
|
104
|
+
'nForma CONTINUATION CONTEXT (auto-injected at compaction)',
|
|
105
105
|
'',
|
|
106
106
|
'## Current Position',
|
|
107
107
|
currentPosition || '(Could not extract Current Position section — run `cat .planning/STATE.md` for full state.)',
|
|
@@ -113,13 +113,13 @@ process.stdin.on('end', () => {
|
|
|
113
113
|
// Include the first pending task found (generic file takes priority)
|
|
114
114
|
lines.push(pendingTasks[0].content);
|
|
115
115
|
if (pendingTasks.length > 1) {
|
|
116
|
-
process.stderr.write('[
|
|
116
|
+
process.stderr.write('[nf-precompact] Multiple pending task files found; injecting first: ' + pendingTasks[0].filename + '\n');
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
lines.push('');
|
|
121
121
|
lines.push('## Resume Instructions');
|
|
122
|
-
lines.push('You are mid-session on a
|
|
122
|
+
lines.push('You are mid-session on a nForma project. The context above shows where you were.');
|
|
123
123
|
lines.push('- If a PLAN.md is in progress, continue executing from the current plan.');
|
|
124
124
|
lines.push('- If a pending task is shown above, execute it next.');
|
|
125
125
|
lines.push('- Run `cat .planning/STATE.md` to get full project state if needed.');
|
|
@@ -131,7 +131,7 @@ process.stdin.on('end', () => {
|
|
|
131
131
|
emitOutput(additionalContext);
|
|
132
132
|
|
|
133
133
|
} catch (e) {
|
|
134
|
-
process.stderr.write('[
|
|
134
|
+
process.stderr.write('[nf-precompact] Fatal error: ' + e.message + '\n');
|
|
135
135
|
process.exit(0); // Fail open — never block compaction
|
|
136
136
|
}
|
|
137
137
|
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Test suite for hooks/nf-precompact.js
|
|
3
|
+
// Uses Node.js built-in test runner: node --test hooks/nf-precompact.test.js
|
|
4
|
+
//
|
|
5
|
+
// Unit tests target exported helpers (extractCurrentPosition, readPendingTasks).
|
|
6
|
+
// Integration tests spawn the hook as a child process with mock stdin.
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const { test } = require('node:test');
|
|
11
|
+
const assert = require('node:assert/strict');
|
|
12
|
+
const { spawnSync } = require('child_process');
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
const HOOK_PATH = path.join(__dirname, 'nf-precompact.js');
|
|
18
|
+
const { extractCurrentPosition, readPendingTasks } = require(HOOK_PATH);
|
|
19
|
+
|
|
20
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
function makeTmpDir() {
|
|
23
|
+
const dir = path.join(os.tmpdir(), 'nf-pc-' + Date.now() + '-' + Math.random().toString(36).slice(2));
|
|
24
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
25
|
+
return dir;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function runHook(stdinPayload, opts = {}) {
|
|
29
|
+
const result = spawnSync('node', [HOOK_PATH], {
|
|
30
|
+
input: JSON.stringify(stdinPayload),
|
|
31
|
+
encoding: 'utf8',
|
|
32
|
+
timeout: 5000,
|
|
33
|
+
...opts,
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
stdout: result.stdout || '',
|
|
37
|
+
stderr: result.stderr || '',
|
|
38
|
+
exitCode: result.status,
|
|
39
|
+
parsed: (() => { try { return JSON.parse(result.stdout); } catch { return null; } })(),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function writeStateFile(dir, content) {
|
|
44
|
+
const planningDir = path.join(dir, '.planning');
|
|
45
|
+
fs.mkdirSync(planningDir, { recursive: true });
|
|
46
|
+
fs.writeFileSync(path.join(planningDir, 'STATE.md'), content, 'utf8');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function writeClaudeFile(dir, filename, content) {
|
|
50
|
+
const claudeDir = path.join(dir, '.claude');
|
|
51
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
52
|
+
fs.writeFileSync(path.join(claudeDir, filename), content, 'utf8');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── extractCurrentPosition unit tests ──────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
test('extractCurrentPosition: returns section between marker and next header', () => {
|
|
58
|
+
const content = [
|
|
59
|
+
'# Project State',
|
|
60
|
+
'',
|
|
61
|
+
'## Current Position',
|
|
62
|
+
'',
|
|
63
|
+
'Phase: v0.19-04 — COMPLETE',
|
|
64
|
+
'Status: ready to plan v0.19-05',
|
|
65
|
+
'',
|
|
66
|
+
'## Performance Metrics',
|
|
67
|
+
'',
|
|
68
|
+
'some other section',
|
|
69
|
+
].join('\n');
|
|
70
|
+
|
|
71
|
+
const result = extractCurrentPosition(content);
|
|
72
|
+
assert.ok(result.includes('Phase: v0.19-04'), 'Should include phase line');
|
|
73
|
+
assert.ok(!result.includes('Performance Metrics'), 'Should not bleed into next section');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('extractCurrentPosition: returns to EOF when no following header', () => {
|
|
77
|
+
const content = [
|
|
78
|
+
'## Other Section',
|
|
79
|
+
'irrelevant',
|
|
80
|
+
'',
|
|
81
|
+
'## Current Position',
|
|
82
|
+
'',
|
|
83
|
+
'Last section content here',
|
|
84
|
+
].join('\n');
|
|
85
|
+
|
|
86
|
+
const result = extractCurrentPosition(content);
|
|
87
|
+
assert.ok(result.includes('Last section content here'));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('extractCurrentPosition: returns null when marker is absent', () => {
|
|
91
|
+
const content = '## Something Else\ncontent\n## Another\nmore content';
|
|
92
|
+
assert.equal(extractCurrentPosition(content), null);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('extractCurrentPosition: returns null when section is empty', () => {
|
|
96
|
+
const content = '## Current Position\n\n## Next Section\ncontent';
|
|
97
|
+
assert.equal(extractCurrentPosition(content), null);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ─── readPendingTasks unit tests ─────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
test('readPendingTasks: returns empty array when .claude dir does not exist', () => {
|
|
103
|
+
const tmpDir = makeTmpDir();
|
|
104
|
+
const results = readPendingTasks(tmpDir);
|
|
105
|
+
assert.deepEqual(results, []);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('readPendingTasks: returns generic pending-task.txt', () => {
|
|
109
|
+
const tmpDir = makeTmpDir();
|
|
110
|
+
writeClaudeFile(tmpDir, 'pending-task.txt', '/qgsd:execute-phase v0.19-05');
|
|
111
|
+
|
|
112
|
+
const results = readPendingTasks(tmpDir);
|
|
113
|
+
assert.equal(results.length, 1);
|
|
114
|
+
assert.equal(results[0].filename, 'pending-task.txt');
|
|
115
|
+
assert.equal(results[0].content, '/qgsd:execute-phase v0.19-05');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('readPendingTasks: returns session-scoped pending-task-SESSION.txt', () => {
|
|
119
|
+
const tmpDir = makeTmpDir();
|
|
120
|
+
writeClaudeFile(tmpDir, 'pending-task-abc123.txt', '/qgsd:quick --full fix tests');
|
|
121
|
+
|
|
122
|
+
const results = readPendingTasks(tmpDir);
|
|
123
|
+
assert.equal(results.length, 1);
|
|
124
|
+
assert.equal(results[0].filename, 'pending-task-abc123.txt');
|
|
125
|
+
assert.equal(results[0].content, '/qgsd:quick --full fix tests');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('readPendingTasks: excludes .claimed files', () => {
|
|
129
|
+
const tmpDir = makeTmpDir();
|
|
130
|
+
writeClaudeFile(tmpDir, 'pending-task-abc123.txt.claimed', 'already consumed');
|
|
131
|
+
|
|
132
|
+
const results = readPendingTasks(tmpDir);
|
|
133
|
+
assert.deepEqual(results, []);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('readPendingTasks: skips empty pending-task files', () => {
|
|
137
|
+
const tmpDir = makeTmpDir();
|
|
138
|
+
writeClaudeFile(tmpDir, 'pending-task.txt', ' \n ');
|
|
139
|
+
|
|
140
|
+
const results = readPendingTasks(tmpDir);
|
|
141
|
+
assert.deepEqual(results, []);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('readPendingTasks: does NOT delete files (non-consuming)', () => {
|
|
145
|
+
const tmpDir = makeTmpDir();
|
|
146
|
+
writeClaudeFile(tmpDir, 'pending-task.txt', 'some task');
|
|
147
|
+
const filePath = path.join(tmpDir, '.claude', 'pending-task.txt');
|
|
148
|
+
|
|
149
|
+
readPendingTasks(tmpDir);
|
|
150
|
+
|
|
151
|
+
assert.ok(fs.existsSync(filePath), 'File should still exist after read (non-consuming)');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ─── Full subprocess (stdin→stdout) integration tests ───────────────────────
|
|
155
|
+
|
|
156
|
+
test('subprocess: exits 0 and emits additionalContext when STATE.md has Current Position', () => {
|
|
157
|
+
const tmpDir = makeTmpDir();
|
|
158
|
+
writeStateFile(tmpDir, [
|
|
159
|
+
'# Project State',
|
|
160
|
+
'',
|
|
161
|
+
'## Current Position',
|
|
162
|
+
'',
|
|
163
|
+
'Phase: v0.19-05 — in progress',
|
|
164
|
+
'Plan: 01 — DONE',
|
|
165
|
+
'',
|
|
166
|
+
'## Performance Metrics',
|
|
167
|
+
'other stuff',
|
|
168
|
+
].join('\n'));
|
|
169
|
+
|
|
170
|
+
const { exitCode, parsed } = runHook({ cwd: tmpDir });
|
|
171
|
+
|
|
172
|
+
assert.equal(exitCode, 0);
|
|
173
|
+
assert.ok(parsed, 'stdout should be valid JSON');
|
|
174
|
+
assert.ok(parsed.hookSpecificOutput, 'should have hookSpecificOutput');
|
|
175
|
+
assert.equal(parsed.hookSpecificOutput.hookEventName, 'PreCompact');
|
|
176
|
+
const ctx = parsed.hookSpecificOutput.additionalContext;
|
|
177
|
+
assert.ok(ctx.includes('nForma CONTINUATION CONTEXT'), 'should include header');
|
|
178
|
+
assert.ok(ctx.includes('v0.19-05'), 'should include current position content');
|
|
179
|
+
assert.ok(ctx.includes('Resume Instructions'), 'should include resume instructions');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('subprocess: includes pending task when pending-task.txt exists', () => {
|
|
183
|
+
const tmpDir = makeTmpDir();
|
|
184
|
+
writeStateFile(tmpDir, '## Current Position\n\nPhase: v0.19-05\n\n## Other\nstuff');
|
|
185
|
+
writeClaudeFile(tmpDir, 'pending-task.txt', '/qgsd:execute-phase v0.19-05');
|
|
186
|
+
|
|
187
|
+
const { exitCode, parsed } = runHook({ cwd: tmpDir });
|
|
188
|
+
|
|
189
|
+
assert.equal(exitCode, 0);
|
|
190
|
+
const ctx = parsed.hookSpecificOutput.additionalContext;
|
|
191
|
+
assert.ok(ctx.includes('Pending Task'), 'should include Pending Task section');
|
|
192
|
+
assert.ok(ctx.includes('/qgsd:execute-phase v0.19-05'), 'should include task content');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('subprocess: emits minimal fallback when STATE.md is absent', () => {
|
|
196
|
+
const tmpDir = makeTmpDir(); // no STATE.md written
|
|
197
|
+
|
|
198
|
+
const { exitCode, parsed } = runHook({ cwd: tmpDir });
|
|
199
|
+
|
|
200
|
+
assert.equal(exitCode, 0);
|
|
201
|
+
assert.ok(parsed.hookSpecificOutput.additionalContext.includes('resumed after compaction'));
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('subprocess: fails open on invalid JSON stdin (exit 0)', () => {
|
|
205
|
+
const result = spawnSync('node', [HOOK_PATH], {
|
|
206
|
+
input: 'not valid json {{',
|
|
207
|
+
encoding: 'utf8',
|
|
208
|
+
timeout: 5000,
|
|
209
|
+
});
|
|
210
|
+
assert.equal(result.status, 0, 'should exit 0 on JSON parse error (fail-open)');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('subprocess: falls back to process.cwd() when cwd field is absent', () => {
|
|
214
|
+
// Pass empty object — no cwd field. Hook should default to process.cwd() and not crash.
|
|
215
|
+
const { exitCode, parsed } = runHook({});
|
|
216
|
+
|
|
217
|
+
assert.equal(exitCode, 0);
|
|
218
|
+
assert.ok(parsed || true, 'Should produce valid output or minimal fallback');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('subprocess: hookEventName is PreCompact', () => {
|
|
222
|
+
const tmpDir = makeTmpDir();
|
|
223
|
+
writeStateFile(tmpDir, '## Current Position\n\nsome state\n\n## Next\nmore');
|
|
224
|
+
|
|
225
|
+
const { parsed } = runHook({ cwd: tmpDir });
|
|
226
|
+
assert.equal(parsed.hookSpecificOutput.hookEventName, 'PreCompact');
|
|
227
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// hooks/
|
|
2
|
+
// hooks/nf-prompt.js
|
|
3
3
|
// UserPromptSubmit hook — three responsibilities:
|
|
4
4
|
//
|
|
5
5
|
// 1. CIRCUIT BREAKER RECOVERY: If the circuit breaker is active, inject the
|
|
@@ -22,21 +22,21 @@ const fs = require('fs');
|
|
|
22
22
|
const path = require('path');
|
|
23
23
|
const os = require('os');
|
|
24
24
|
const { spawnSync } = require('child_process');
|
|
25
|
-
const { loadConfig, slotToToolCall } = require('./config-loader');
|
|
25
|
+
const { loadConfig, slotToToolCall, shouldRunHook } = require('./config-loader');
|
|
26
26
|
const { schema_version } = require('./conformance-schema.cjs');
|
|
27
27
|
|
|
28
28
|
const DEFAULT_QUORUM_INSTRUCTIONS_FALLBACK = `QUORUM REQUIRED (structural enforcement — Stop hook will verify)
|
|
29
29
|
|
|
30
|
-
Run the full R3 quorum protocol inline (dispatch_pattern from commands/
|
|
30
|
+
Run the full R3 quorum protocol inline (dispatch_pattern from commands/nf/quorum.md):
|
|
31
31
|
|
|
32
32
|
1. State Claude's own position (vote) first — APPROVE or BLOCK with 1-2 sentence rationale
|
|
33
|
-
2. Run provider pre-flight: node ~/.claude/
|
|
34
|
-
3. Build $DISPATCH_LIST first (quorum.md Adaptive Fan-Out: read risk_level → compute FAN_OUT_COUNT → first FAN_OUT_COUNT-1 slots). Then dispatch $DISPATCH_LIST as sibling
|
|
35
|
-
Task(subagent_type="
|
|
33
|
+
2. Run provider pre-flight: node ~/.claude/nf-bin/check-provider-health.cjs --json
|
|
34
|
+
3. Build $DISPATCH_LIST first (quorum.md Adaptive Fan-Out: read risk_level → compute FAN_OUT_COUNT → first FAN_OUT_COUNT-1 slots). Then dispatch $DISPATCH_LIST as sibling nf-quorum-slot-worker Tasks in one message turn — do NOT dispatch slots outside $DISPATCH_LIST:
|
|
35
|
+
Task(subagent_type="nf-quorum-slot-worker", prompt="slot: <slot>\\nround: 1\\n...")
|
|
36
36
|
4. Synthesize results inline. Deliberate up to 10 rounds per R3.3 if no consensus.
|
|
37
|
-
5. Update scoreboard: node ~/.claude/
|
|
37
|
+
5. Update scoreboard: node ~/.claude/nf-bin/update-scoreboard.cjs merge-wave ...
|
|
38
38
|
6. [HEAL-01] After each deliberation round's merge-wave, check early escalation:
|
|
39
|
-
node ~/.claude/
|
|
39
|
+
node ~/.claude/nf-bin/quorum-consensus-gate.cjs --min-quorum=2 --remaining-rounds=R
|
|
40
40
|
(R = maxDeliberation - currentRound). Exit code 1 = stop deliberating, proceed to decision (early escalation — P(consensus|remaining) below threshold).
|
|
41
41
|
7. Include the token <!-- GSD_DECISION --> in your FINAL output (only when delivering
|
|
42
42
|
the completed plan, research, verification report, or filtered question list)
|
|
@@ -55,16 +55,16 @@ function appendConformanceEvent(event) {
|
|
|
55
55
|
const logPath = pp.resolve(process.cwd(), 'conformance-events');
|
|
56
56
|
fs.appendFileSync(logPath, JSON.stringify(event) + '\n', 'utf8');
|
|
57
57
|
} catch (err) {
|
|
58
|
-
process.stderr.write('[
|
|
58
|
+
process.stderr.write('[nf] conformance log write failed: ' + err.message + '\n');
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
// Locate the oscillation-resolution-mode workflow.
|
|
63
|
-
// Tries global install path first (~/.claude/
|
|
63
|
+
// Tries global install path first (~/.claude/nf/), then local (.claude/nf/).
|
|
64
64
|
function findResolutionWorkflow(cwd) {
|
|
65
65
|
const candidates = [
|
|
66
|
-
path.join(os.homedir(), '.claude', '
|
|
67
|
-
path.join(cwd, '.claude', '
|
|
66
|
+
path.join(os.homedir(), '.claude', 'nf', 'workflows', 'oscillation-resolution-mode.md'),
|
|
67
|
+
path.join(cwd, '.claude', 'nf', 'workflows', 'oscillation-resolution-mode.md'),
|
|
68
68
|
];
|
|
69
69
|
for (const p of candidates) {
|
|
70
70
|
if (fs.existsSync(p)) return fs.readFileSync(p, 'utf8');
|
|
@@ -150,7 +150,7 @@ function getRecentlyTimedOutSlots(cwd, ttlMinutes = 30) {
|
|
|
150
150
|
function findProviders() {
|
|
151
151
|
const searchPaths = [
|
|
152
152
|
path.join(__dirname, '..', 'bin', 'providers.json'),
|
|
153
|
-
path.join(os.homedir(), '.claude', '
|
|
153
|
+
path.join(os.homedir(), '.claude', 'nf-bin', 'providers.json'),
|
|
154
154
|
];
|
|
155
155
|
for (const p of searchPaths) {
|
|
156
156
|
try {
|
|
@@ -169,7 +169,7 @@ function triggerHealthProbe() {
|
|
|
169
169
|
try {
|
|
170
170
|
const searchPaths = [
|
|
171
171
|
path.join(__dirname, '..', 'bin', 'check-provider-health.cjs'),
|
|
172
|
-
path.join(os.homedir(), '.claude', '
|
|
172
|
+
path.join(os.homedir(), '.claude', 'nf-bin', 'check-provider-health.cjs'),
|
|
173
173
|
];
|
|
174
174
|
let checkPath = null;
|
|
175
175
|
for (const p of searchPaths) {
|
|
@@ -199,7 +199,7 @@ function getAvailableSlots(slots, cwd) {
|
|
|
199
199
|
const ts = new Date(avail.available_at_iso).getTime();
|
|
200
200
|
if (isNaN(ts)) return true; // malformed date: fail-open
|
|
201
201
|
if (ts > now) {
|
|
202
|
-
console.error(`[
|
|
202
|
+
console.error(`[nf-dispatch] AVAILABILITY EXCLUDE: ${s.slot} -- available_at_iso=${avail.available_at_iso} is in the future (now=${new Date().toISOString()})`);
|
|
203
203
|
return false;
|
|
204
204
|
}
|
|
205
205
|
return true;
|
|
@@ -254,18 +254,18 @@ function sortBySuccessRate(slots, cwd) {
|
|
|
254
254
|
return getRate(b.slot) - getRate(a.slot);
|
|
255
255
|
});
|
|
256
256
|
|
|
257
|
-
console.error('[
|
|
257
|
+
console.error('[nf-dispatch] DISPATCH ORDER (flakiness,rate): [' +
|
|
258
258
|
sorted.map(s => `${s.slot}(f=${getFlakiness(s.slot).toFixed(2)},r=${getRate(s.slot).toFixed(3)})`).join(', ') + ']');
|
|
259
259
|
return sorted;
|
|
260
260
|
} catch (_) { return [...slots]; } // fail-open: any error → return original order
|
|
261
261
|
}
|
|
262
262
|
|
|
263
263
|
// Returns slot names whose backing provider is DOWN (probed unhealthy).
|
|
264
|
-
// Reads ~/.claude/
|
|
264
|
+
// Reads ~/.claude/nf-provider-cache.json and matches providers from providers.json.
|
|
265
265
|
// When a provider's endpoint is DOWN, ALL slots backed by that provider are skipped.
|
|
266
266
|
function getDownProviderSlots() {
|
|
267
267
|
try {
|
|
268
|
-
const cachePath = path.join(os.homedir(), '.claude', '
|
|
268
|
+
const cachePath = path.join(os.homedir(), '.claude', 'nf-provider-cache.json');
|
|
269
269
|
if (!fs.existsSync(cachePath)) return [];
|
|
270
270
|
const cache = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
|
|
271
271
|
if (!cache || !cache.entries) return [];
|
|
@@ -342,7 +342,7 @@ process.stdin.on('end', () => {
|
|
|
342
342
|
const workflow = findResolutionWorkflow(cwd);
|
|
343
343
|
const context = workflow
|
|
344
344
|
? `CIRCUIT BREAKER ACTIVE — OSCILLATION RESOLUTION MODE\n\nYou MUST follow this procedure immediately before doing anything else:\n\n${workflow}`
|
|
345
|
-
: `CIRCUIT BREAKER ACTIVE — OSCILLATION RESOLUTION MODE\n\nOscillation has been detected in recent commits. Tool calls are NOT blocked — you can still read and write files — but you MUST resolve the oscillation before making further commits.\nFollow the oscillation resolution procedure in R5 of CLAUDE.md:\n1. Run: git log --oneline --name-only -6 to identify the oscillating file set.\n2. Run quorum diagnosis with structural coupling framing.\n3. Present unified solution to user for approval.\n4. Do NOT commit until user approves AND runs: npx
|
|
345
|
+
: `CIRCUIT BREAKER ACTIVE — OSCILLATION RESOLUTION MODE\n\nOscillation has been detected in recent commits. Tool calls are NOT blocked — you can still read and write files — but you MUST resolve the oscillation before making further commits.\nFollow the oscillation resolution procedure in R5 of CLAUDE.md:\n1. Run: git log --oneline --name-only -6 to identify the oscillating file set.\n2. Run quorum diagnosis with structural coupling framing.\n3. Present unified solution to user for approval.\n4. Do NOT commit until user approves AND runs: npx nforma --reset-breaker`;
|
|
346
346
|
process.stdout.write(JSON.stringify({
|
|
347
347
|
hookSpecificOutput: {
|
|
348
348
|
hookEventName: 'UserPromptSubmit',
|
|
@@ -358,7 +358,7 @@ process.stdin.on('end', () => {
|
|
|
358
358
|
process.stdout.write(JSON.stringify({
|
|
359
359
|
hookSpecificOutput: {
|
|
360
360
|
hookEventName: 'UserPromptSubmit',
|
|
361
|
-
additionalContext: `PENDING QUEUED TASK — Execute this immediately before anything else:\n\n${pendingTask}\n\n(This task was queued via /
|
|
361
|
+
additionalContext: `PENDING QUEUED TASK — Execute this immediately before anything else:\n\n${pendingTask}\n\n(This task was queued via /nf:queue before the previous /clear.)`,
|
|
362
362
|
}
|
|
363
363
|
}));
|
|
364
364
|
process.exit(0);
|
|
@@ -366,6 +366,13 @@ process.stdin.on('end', () => {
|
|
|
366
366
|
|
|
367
367
|
// ── Priority 3: Planning command → inject quorum instructions ────────────
|
|
368
368
|
const config = loadConfig(cwd);
|
|
369
|
+
|
|
370
|
+
// Profile guard — exit early if this hook is not active for the current profile
|
|
371
|
+
const profile = config.hook_profile || 'standard';
|
|
372
|
+
if (!shouldRunHook('nf-prompt', profile)) {
|
|
373
|
+
process.exit(0);
|
|
374
|
+
}
|
|
375
|
+
|
|
369
376
|
const commands = config.quorum_commands;
|
|
370
377
|
|
|
371
378
|
// Parse --n N override from the raw prompt
|
|
@@ -380,7 +387,7 @@ process.stdin.on('end', () => {
|
|
|
380
387
|
|
|
381
388
|
// Solo mode: --n 1 means Claude-only quorum — bypass all external slot dispatches
|
|
382
389
|
if (quorumSizeOverride === 1) {
|
|
383
|
-
instructions = `<!--
|
|
390
|
+
instructions = `<!-- NF_SOLO_MODE -->\nSOLO MODE ACTIVE (--n 1): Self-quorum only. Skip ALL external slot-worker Task dispatches. Claude's vote is the quorum. Write <!-- GSD_DECISION --> in your final output. The Stop hook is informed.\n\n`;
|
|
384
391
|
} else if (config.quorum_instructions) {
|
|
385
392
|
// Explicit quorum_instructions in config — use as-is
|
|
386
393
|
instructions = config.quorum_instructions;
|
|
@@ -427,8 +434,8 @@ process.stdin.on('end', () => {
|
|
|
427
434
|
// Guard: empty roster — no external agents configured at all
|
|
428
435
|
if (orderedSlots.length === 0) {
|
|
429
436
|
// Fail-open to solo mode: Claude is the only quorum participant
|
|
430
|
-
console.error('[
|
|
431
|
-
instructions = `<!--
|
|
437
|
+
console.error('[nf-dispatch] WARNING: no external agents in roster — falling back to solo quorum');
|
|
438
|
+
instructions = `<!-- NF_SOLO_MODE -->\nSOLO MODE ACTIVE (empty roster): No external agents configured in providers.json or quorum_active. Claude's vote is the quorum. Write <!-- GSD_DECISION --> in your final output. The Stop hook is informed.\n\nTo add agents, run /nf:mcp-setup or edit ~/.claude/nf.json quorum_active.\n`;
|
|
432
439
|
} else {
|
|
433
440
|
if (preferSub) {
|
|
434
441
|
orderedSlots.sort((a, b) => {
|
|
@@ -460,6 +467,54 @@ process.stdin.on('end', () => {
|
|
|
460
467
|
// DISP-03: Sort by descending success rate
|
|
461
468
|
cappedSlots = sortBySuccessRate(cappedSlots, cwd);
|
|
462
469
|
|
|
470
|
+
// ── CACHE CHECK: Short-circuit quorum dispatch on valid cache hit ──────
|
|
471
|
+
try {
|
|
472
|
+
const cacheModule = require(path.join(__dirname, '..', 'bin', 'quorum-cache.cjs'));
|
|
473
|
+
const cacheDir = path.join(cwd, '.planning', '.quorum-cache');
|
|
474
|
+
const cacheKey = cacheModule.computeCacheKey(prompt, contextYaml, cappedSlots, config.quorum_active, cacheModule.getGitHead());
|
|
475
|
+
|
|
476
|
+
const cachedEntry = cacheModule.readCache(cacheKey, cacheDir);
|
|
477
|
+
if (cachedEntry && cacheModule.isCacheValid(cachedEntry, cacheModule.getGitHead(), config.quorum_active || [])) {
|
|
478
|
+
// Cache hit — serve cached result without dispatching slots
|
|
479
|
+
const ageMs = Date.now() - new Date(cachedEntry.created).getTime();
|
|
480
|
+
const timeAgo = ageMs < 3600000
|
|
481
|
+
? Math.round(ageMs / 60000) + 'm ago'
|
|
482
|
+
: Math.round(ageMs / 3600000) + 'h ago';
|
|
483
|
+
|
|
484
|
+
appendConformanceEvent({
|
|
485
|
+
ts: new Date().toISOString(),
|
|
486
|
+
phase: 'DECIDING',
|
|
487
|
+
action: 'cache_hit',
|
|
488
|
+
cache_key: cacheKey.slice(0, 12),
|
|
489
|
+
slots_available: cachedEntry.slot_count,
|
|
490
|
+
vote_result: cachedEntry.vote_result,
|
|
491
|
+
outcome: 'APPROVE',
|
|
492
|
+
schema_version,
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
const cacheInstructions = `<!-- NF_CACHE_HIT -->\n<!-- NF_CACHE_KEY:${cacheKey} -->\nQUORUM CACHE HIT: Identical dispatch was completed ${timeAgo}.\nCached result: ${cachedEntry.vote_result} of ${cachedEntry.slot_count} slots approved.\nDecision: ${cachedEntry.outcome}\nSkip all slot-worker Task dispatches. Use this cached quorum result.\nInclude <!-- GSD_DECISION --> in your final output.`;
|
|
496
|
+
|
|
497
|
+
process.stdout.write(JSON.stringify({
|
|
498
|
+
hookSpecificOutput: {
|
|
499
|
+
hookEventName: 'UserPromptSubmit',
|
|
500
|
+
additionalContext: cacheInstructions,
|
|
501
|
+
}
|
|
502
|
+
}));
|
|
503
|
+
process.exit(0);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Cache miss — store key for embedding in instructions and pending entry write
|
|
507
|
+
var _nfCacheKey = cacheKey;
|
|
508
|
+
var _nfCacheModule = cacheModule;
|
|
509
|
+
var _nfCacheDir = cacheDir;
|
|
510
|
+
} catch (cacheErr) {
|
|
511
|
+
// Fail-open: cache errors never prevent normal quorum dispatch
|
|
512
|
+
process.stderr.write('[nf] cache check failed (fail-open): ' + (cacheErr.message || cacheErr) + '\n');
|
|
513
|
+
var _nfCacheKey = null;
|
|
514
|
+
var _nfCacheModule = null;
|
|
515
|
+
var _nfCacheDir = null;
|
|
516
|
+
}
|
|
517
|
+
|
|
463
518
|
// SC-4: Graceful fallback — ensure at least one slot in dispatch list
|
|
464
519
|
if (cappedSlots.length === 0 && orderedSlots.length > 0) {
|
|
465
520
|
const relaxedSlots = orderedSlots.filter(s => !skipSet.has(s.slot));
|
|
@@ -468,7 +523,7 @@ process.stdin.on('end', () => {
|
|
|
468
523
|
} else {
|
|
469
524
|
cappedSlots = [orderedSlots[0]]; // last resort: any slot at all
|
|
470
525
|
}
|
|
471
|
-
console.error(`[
|
|
526
|
+
console.error(`[nf-dispatch] FALLBACK: all slots filtered, restored ${cappedSlots[0].slot}`);
|
|
472
527
|
}
|
|
473
528
|
|
|
474
529
|
// Generate step list, with optional section headers when preferSub is on
|
|
@@ -481,7 +536,7 @@ process.stdin.on('end', () => {
|
|
|
481
536
|
stepLines.push(' [API agents — overflow if sub count insufficient]');
|
|
482
537
|
inApiSection = true;
|
|
483
538
|
}
|
|
484
|
-
stepLines.push(` ${stepNum}. Task(subagent_type="
|
|
539
|
+
stepLines.push(` ${stepNum}. Task(subagent_type="nf-quorum-slot-worker", prompt="slot: ${slot}\\nround: 1\\ntimeout_ms: 60000\\nrepo_dir: <cwd>\\nmode: A\\nquestion: <question>")`);
|
|
485
540
|
stepNum++;
|
|
486
541
|
}
|
|
487
542
|
const dynamicSteps = stepLines.join('\n');
|
|
@@ -559,18 +614,18 @@ process.stdin.on('end', () => {
|
|
|
559
614
|
}
|
|
560
615
|
|
|
561
616
|
instructions = `QUORUM REQUIRED${minNote} (structural enforcement — Stop hook will verify)\n\n` +
|
|
562
|
-
`Run the full R3 quorum protocol inline (dispatch_pattern from commands/
|
|
563
|
-
`Dispatch ALL active slots as parallel sibling
|
|
564
|
-
`NEVER call mcp__*__* tools directly — use Task(subagent_type="
|
|
617
|
+
`Run the full R3 quorum protocol inline (dispatch_pattern from commands/nf/quorum.md):\n` +
|
|
618
|
+
`Dispatch ALL active slots as parallel sibling nf-quorum-slot-worker Tasks in ONE message turn.\n` +
|
|
619
|
+
`NEVER call mcp__*__* tools directly — use Task(subagent_type="nf-quorum-slot-worker") ONLY:\n` +
|
|
565
620
|
(hasMixed ? ' [Subscription agents — preferred, flat-fee]\n' : '') +
|
|
566
621
|
dynamicSteps + '\n\n' +
|
|
567
622
|
skipNote +
|
|
568
623
|
failoverRule + '\n\n' +
|
|
569
624
|
`After quorum:\n` +
|
|
570
625
|
` ${afterSteps}. Synthesize results inline. Deliberate up to 10 rounds per R3.3 if no consensus.\n` +
|
|
571
|
-
` ${afterSteps + 1}. Update scoreboard: node ~/.claude/
|
|
626
|
+
` ${afterSteps + 1}. Update scoreboard: node ~/.claude/nf-bin/update-scoreboard.cjs merge-wave ...\n` +
|
|
572
627
|
` ${afterSteps + 2}. [HEAL-01] After EACH deliberation round's merge-wave, check early escalation:\n` +
|
|
573
|
-
` node ~/.claude/
|
|
628
|
+
` node ~/.claude/nf-bin/quorum-consensus-gate.cjs --min-quorum=2 --remaining-rounds=R\n` +
|
|
574
629
|
` where R = (maxDeliberation - currentRound). For example, on round 2 of 7 max: --remaining-rounds=5.\n` +
|
|
575
630
|
` If exit code 1 (shouldEscalate=true, P(consensus|remaining) below 10% threshold), stop deliberating and proceed to decision immediately.\n` +
|
|
576
631
|
` This prevents wasting rounds when consensus is mathematically unlikely.\n` +
|
|
@@ -607,17 +662,39 @@ process.stdin.on('end', () => {
|
|
|
607
662
|
const tool = AGENT_TOOL_MAP[agent] || ('mcp__' + agent);
|
|
608
663
|
return ' - When calling ' + tool + ', include model="' + model + '" in the tool input';
|
|
609
664
|
}).join('\n');
|
|
610
|
-
instructions += '\n\nModel overrides (from
|
|
665
|
+
instructions += '\n\nModel overrides (from nf.json model_preferences):\n' +
|
|
611
666
|
'The following agents have preferred models configured. Pass the specified model parameter:\n' +
|
|
612
667
|
lines;
|
|
613
668
|
}
|
|
614
669
|
|
|
615
|
-
// Anchored allowlist — requires /gsd
|
|
616
|
-
|
|
670
|
+
// Anchored allowlist — requires /nf:, /gsd:, or /qgsd: prefix and word boundary after command name.
|
|
671
|
+
// Strict mode: match ANY /nf: or /gsd: or /qgsd: command, not just quorum_commands list.
|
|
672
|
+
const cmdPattern = profile === 'strict'
|
|
673
|
+
? /^\s*\/(nf|q?gsd):[\w][\w-]*(\s|$)/
|
|
674
|
+
: new RegExp('^\\s*\\/(nf|q?gsd):(' + commands.join('|') + ')(\\s|$)');
|
|
617
675
|
if (!cmdPattern.test(prompt)) {
|
|
618
676
|
process.exit(0); // Silent pass — UPS-05
|
|
619
677
|
}
|
|
620
678
|
|
|
679
|
+
// ── CACHE MISS: Embed cache key marker and write pending entry ──────────
|
|
680
|
+
if (typeof _nfCacheKey === 'string' && _nfCacheKey && _nfCacheModule && _nfCacheDir) {
|
|
681
|
+
try {
|
|
682
|
+
instructions = `<!-- NF_CACHE_KEY:${_nfCacheKey} -->\n` + instructions;
|
|
683
|
+
_nfCacheModule.writeCache(_nfCacheKey, {
|
|
684
|
+
version: 1,
|
|
685
|
+
key: _nfCacheKey,
|
|
686
|
+
created: new Date().toISOString(),
|
|
687
|
+
ttl_ms: (config.cache_ttl_ms || 3600000),
|
|
688
|
+
git_head: _nfCacheModule.getGitHead(),
|
|
689
|
+
quorum_active: (config.quorum_active || []).slice(),
|
|
690
|
+
slot_count: cappedSlots ? cappedSlots.length : 0,
|
|
691
|
+
}, _nfCacheDir);
|
|
692
|
+
} catch (pendingErr) {
|
|
693
|
+
// Fail-open: pending entry write failure never blocks dispatch
|
|
694
|
+
process.stderr.write('[nf] cache pending write failed (fail-open): ' + (pendingErr.message || pendingErr) + '\n');
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
621
698
|
appendConformanceEvent({
|
|
622
699
|
ts: new Date().toISOString(),
|
|
623
700
|
phase: 'IDLE',
|