@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
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Test suite for hooks/nf-prompt.js
|
|
3
|
+
// Uses Node.js built-in test runner: node --test hooks/nf-prompt.test.js
|
|
4
|
+
//
|
|
5
|
+
// Each test spawns the hook as a child process with mock stdin.
|
|
6
|
+
// The hook reads JSON from stdin and writes JSON to stdout.
|
|
7
|
+
|
|
8
|
+
const { test } = require('node:test');
|
|
9
|
+
const assert = require('node:assert/strict');
|
|
10
|
+
const { spawnSync } = require('child_process');
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const HOOK_PATH = path.join(__dirname, 'nf-prompt.js');
|
|
16
|
+
|
|
17
|
+
// Helper: run the hook with a given stdin payload, return { stdout, stderr, exitCode }
|
|
18
|
+
function runHook(stdinPayload, extraEnv) {
|
|
19
|
+
const result = spawnSync('node', [HOOK_PATH], {
|
|
20
|
+
input: typeof stdinPayload === 'string' ? stdinPayload : JSON.stringify(stdinPayload),
|
|
21
|
+
encoding: 'utf8',
|
|
22
|
+
timeout: 5000,
|
|
23
|
+
env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
|
|
24
|
+
});
|
|
25
|
+
return {
|
|
26
|
+
stdout: result.stdout || '',
|
|
27
|
+
stderr: result.stderr || '',
|
|
28
|
+
exitCode: result.status,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// TC1: non-planning command exits 0 with no stdout output
|
|
33
|
+
// /qgsd:execute-phase is NOT in default quorum_commands → silent pass
|
|
34
|
+
test('TC1: non-planning command (/qgsd:execute-phase) exits 0 with no stdout', () => {
|
|
35
|
+
const { stdout, exitCode } = runHook({
|
|
36
|
+
prompt: '/qgsd:execute-phase',
|
|
37
|
+
cwd: process.cwd(),
|
|
38
|
+
});
|
|
39
|
+
assert.strictEqual(exitCode, 0, 'exit code must be 0');
|
|
40
|
+
assert.strictEqual(stdout, '', 'stdout must be empty for non-planning command');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// TC2: planning command triggers quorum injection
|
|
44
|
+
// /qgsd:plan-phase is in quorum_commands → should inject additionalContext with "QUORUM REQUIRED"
|
|
45
|
+
test('TC2: planning command (/qgsd:plan-phase) triggers quorum injection', () => {
|
|
46
|
+
const { stdout, exitCode } = runHook({
|
|
47
|
+
prompt: '/qgsd:plan-phase 03-auth',
|
|
48
|
+
cwd: process.cwd(),
|
|
49
|
+
});
|
|
50
|
+
assert.strictEqual(exitCode, 0, 'exit code must be 0');
|
|
51
|
+
assert.ok(stdout.length > 0, 'stdout must contain quorum injection JSON');
|
|
52
|
+
const parsed = JSON.parse(stdout);
|
|
53
|
+
assert.ok(parsed.hookSpecificOutput, 'output must have hookSpecificOutput');
|
|
54
|
+
assert.ok(
|
|
55
|
+
parsed.hookSpecificOutput.additionalContext.includes('QUORUM REQUIRED'),
|
|
56
|
+
'additionalContext must include "QUORUM REQUIRED"'
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// TC3: /gsd:plan-phase (GSD prefix) also triggers injection
|
|
61
|
+
// The hook accepts both /gsd: and /qgsd: prefixes via the ^\\s*\\/q?gsd: pattern
|
|
62
|
+
test('TC3: /gsd:plan-phase (GSD prefix) also triggers quorum injection', () => {
|
|
63
|
+
const { stdout, exitCode } = runHook({
|
|
64
|
+
prompt: '/gsd:plan-phase 03-auth',
|
|
65
|
+
cwd: process.cwd(),
|
|
66
|
+
});
|
|
67
|
+
assert.strictEqual(exitCode, 0, 'exit code must be 0');
|
|
68
|
+
assert.ok(stdout.length > 0, 'stdout must contain quorum injection JSON');
|
|
69
|
+
const parsed = JSON.parse(stdout);
|
|
70
|
+
assert.ok(
|
|
71
|
+
parsed.hookSpecificOutput.additionalContext.includes('QUORUM REQUIRED'),
|
|
72
|
+
'additionalContext must include "QUORUM REQUIRED"'
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// TC4: /qgsd:research-phase triggers injection
|
|
77
|
+
test('TC4: /qgsd:research-phase triggers quorum injection', () => {
|
|
78
|
+
const { stdout, exitCode } = runHook({
|
|
79
|
+
prompt: '/qgsd:research-phase',
|
|
80
|
+
cwd: process.cwd(),
|
|
81
|
+
});
|
|
82
|
+
assert.strictEqual(exitCode, 0, 'exit code must be 0');
|
|
83
|
+
assert.ok(stdout.length > 0, 'stdout must contain quorum injection JSON');
|
|
84
|
+
const parsed = JSON.parse(stdout);
|
|
85
|
+
assert.ok(
|
|
86
|
+
parsed.hookSpecificOutput.additionalContext.includes('QUORUM REQUIRED'),
|
|
87
|
+
'additionalContext must include "QUORUM REQUIRED"'
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// TC5: /qgsd:verify-work triggers injection
|
|
92
|
+
test('TC5: /qgsd:verify-work triggers quorum injection', () => {
|
|
93
|
+
const { stdout, exitCode } = runHook({
|
|
94
|
+
prompt: '/qgsd:verify-work',
|
|
95
|
+
cwd: process.cwd(),
|
|
96
|
+
});
|
|
97
|
+
assert.strictEqual(exitCode, 0, 'exit code must be 0');
|
|
98
|
+
assert.ok(stdout.length > 0, 'stdout must contain quorum injection JSON');
|
|
99
|
+
const parsed = JSON.parse(stdout);
|
|
100
|
+
assert.ok(
|
|
101
|
+
parsed.hookSpecificOutput.additionalContext.includes('QUORUM REQUIRED'),
|
|
102
|
+
'additionalContext must include "QUORUM REQUIRED"'
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// TC6: /qgsd:discuss-phase triggers injection
|
|
107
|
+
test('TC6: /qgsd:discuss-phase triggers quorum injection', () => {
|
|
108
|
+
const { stdout, exitCode } = runHook({
|
|
109
|
+
prompt: '/qgsd:discuss-phase',
|
|
110
|
+
cwd: process.cwd(),
|
|
111
|
+
});
|
|
112
|
+
assert.strictEqual(exitCode, 0, 'exit code must be 0');
|
|
113
|
+
assert.ok(stdout.length > 0, 'stdout must contain quorum injection JSON');
|
|
114
|
+
const parsed = JSON.parse(stdout);
|
|
115
|
+
assert.ok(
|
|
116
|
+
parsed.hookSpecificOutput.additionalContext.includes('QUORUM REQUIRED'),
|
|
117
|
+
'additionalContext must include "QUORUM REQUIRED"'
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// TC7: malformed JSON stdin exits 0 with no output (fail-open)
|
|
122
|
+
test('TC7: malformed JSON stdin exits 0 with no output (fail-open)', () => {
|
|
123
|
+
const { stdout, exitCode } = runHook('not json');
|
|
124
|
+
assert.strictEqual(exitCode, 0, 'exit code must be 0 — fail-open on malformed input');
|
|
125
|
+
assert.strictEqual(stdout, '', 'stdout must be empty — fail-open produces no output');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// TC8: prefix boundary — /qgsd:plan-phase-extra does NOT trigger (trailing non-space after command)
|
|
129
|
+
// The regex uses (\s|$) word boundary, so "plan-phase-extra" must not match "plan-phase"
|
|
130
|
+
test('TC8: /qgsd:plan-phase-extra does NOT trigger injection (word boundary enforced)', () => {
|
|
131
|
+
const { stdout, exitCode } = runHook({
|
|
132
|
+
prompt: '/qgsd:plan-phase-extra something',
|
|
133
|
+
cwd: process.cwd(),
|
|
134
|
+
});
|
|
135
|
+
assert.strictEqual(exitCode, 0, 'exit code must be 0');
|
|
136
|
+
assert.strictEqual(stdout, '', 'stdout must be empty — word boundary prevents false match');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// TC9: circuit breaker active in temp dir → injects resolution context
|
|
140
|
+
// Creates a temp git repo with .claude/circuit-breaker-state.json { active: true }
|
|
141
|
+
test('TC9: circuit breaker active → injects CIRCUIT BREAKER ACTIVE context', () => {
|
|
142
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nf-prompt-tc9-'));
|
|
143
|
+
try {
|
|
144
|
+
// Init git repo so isBreakerActive can find the git root
|
|
145
|
+
spawnSync('git', ['init'], { cwd: tempDir, encoding: 'utf8', timeout: 5000 });
|
|
146
|
+
|
|
147
|
+
// Write circuit breaker state file
|
|
148
|
+
const claudeDir = path.join(tempDir, '.claude');
|
|
149
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
150
|
+
fs.writeFileSync(
|
|
151
|
+
path.join(claudeDir, 'circuit-breaker-state.json'),
|
|
152
|
+
JSON.stringify({ active: true }),
|
|
153
|
+
'utf8'
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const { stdout, exitCode } = runHook({
|
|
157
|
+
prompt: '/qgsd:execute-phase',
|
|
158
|
+
cwd: tempDir,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
assert.strictEqual(exitCode, 0, 'exit code must be 0');
|
|
162
|
+
assert.ok(stdout.length > 0, 'stdout must contain circuit breaker injection JSON');
|
|
163
|
+
const parsed = JSON.parse(stdout);
|
|
164
|
+
assert.ok(
|
|
165
|
+
parsed.hookSpecificOutput.additionalContext.includes('CIRCUIT BREAKER ACTIVE'),
|
|
166
|
+
'additionalContext must include "CIRCUIT BREAKER ACTIVE"'
|
|
167
|
+
);
|
|
168
|
+
} finally {
|
|
169
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// TC11: activeSlots path — instructions use Task dispatch syntax, not direct MCP calls
|
|
174
|
+
// When quorum_active is configured, the step list must contain nf-quorum-slot-worker Tasks
|
|
175
|
+
// and must NOT contain mcp__*__* tool names (the escape hatch must be absent).
|
|
176
|
+
test('TC11: activeSlots path uses Task dispatch syntax (not direct MCP calls)', () => {
|
|
177
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nf-prompt-tc11-'));
|
|
178
|
+
try {
|
|
179
|
+
spawnSync('git', ['init'], { cwd: tempDir, encoding: 'utf8', timeout: 5000 });
|
|
180
|
+
const claudeDir = path.join(tempDir, '.claude');
|
|
181
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
182
|
+
fs.writeFileSync(
|
|
183
|
+
path.join(claudeDir, 'nf.json'),
|
|
184
|
+
JSON.stringify({ quorum_active: ['codex-1', 'gemini-1'] }),
|
|
185
|
+
'utf8'
|
|
186
|
+
);
|
|
187
|
+
const { stdout } = runHook({ prompt: '/qgsd:plan-phase', cwd: tempDir });
|
|
188
|
+
const ctx = JSON.parse(stdout).hookSpecificOutput.additionalContext;
|
|
189
|
+
assert.ok(
|
|
190
|
+
ctx.includes('nf-quorum-slot-worker'),
|
|
191
|
+
'instructions must contain nf-quorum-slot-worker Task dispatch syntax'
|
|
192
|
+
);
|
|
193
|
+
} finally {
|
|
194
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// TC12: activeSlots path — no mcp__*__* tool names in injected instructions
|
|
199
|
+
// The escape hatch "fall back to direct MCP calls" must be absent entirely.
|
|
200
|
+
test('TC12: activeSlots path has no mcp__*__* tool names in instructions', () => {
|
|
201
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nf-prompt-tc12-'));
|
|
202
|
+
try {
|
|
203
|
+
spawnSync('git', ['init'], { cwd: tempDir, encoding: 'utf8', timeout: 5000 });
|
|
204
|
+
const claudeDir = path.join(tempDir, '.claude');
|
|
205
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
206
|
+
fs.writeFileSync(
|
|
207
|
+
path.join(claudeDir, 'nf.json'),
|
|
208
|
+
JSON.stringify({ quorum_active: ['codex-1', 'gemini-1'] }),
|
|
209
|
+
'utf8'
|
|
210
|
+
);
|
|
211
|
+
const { stdout } = runHook({ prompt: '/qgsd:plan-phase', cwd: tempDir });
|
|
212
|
+
const ctx = JSON.parse(stdout).hookSpecificOutput.additionalContext;
|
|
213
|
+
// The escape hatch was "fall back to direct MCP calls" + a step list of "Call mcp__X__Y".
|
|
214
|
+
// The NEVER directive legitimately contains "mcp__*__*" as a warning, so we test for
|
|
215
|
+
// the actual escape hatch phrases, not a generic mcp__ regex.
|
|
216
|
+
assert.ok(
|
|
217
|
+
!ctx.includes('fall back to direct MCP calls'),
|
|
218
|
+
'instructions must NOT contain the fallback escape hatch phrase'
|
|
219
|
+
);
|
|
220
|
+
assert.ok(
|
|
221
|
+
!(/\bCall mcp__[a-z]/.test(ctx)),
|
|
222
|
+
'instructions must NOT contain "Call mcp__<slot>" step lines'
|
|
223
|
+
);
|
|
224
|
+
} finally {
|
|
225
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// TC13: activeSlots path — model_preferences override block is suppressed
|
|
230
|
+
// Even when model_preferences is configured, mcp__*__* names must not appear
|
|
231
|
+
// (the !activeSlots guard must prevent the AGENT_TOOL_MAP block from running).
|
|
232
|
+
test('TC13: activeSlots path suppresses model_preferences override (no mcp__ leak)', () => {
|
|
233
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nf-prompt-tc13-'));
|
|
234
|
+
try {
|
|
235
|
+
spawnSync('git', ['init'], { cwd: tempDir, encoding: 'utf8', timeout: 5000 });
|
|
236
|
+
const claudeDir = path.join(tempDir, '.claude');
|
|
237
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
238
|
+
fs.writeFileSync(
|
|
239
|
+
path.join(claudeDir, 'nf.json'),
|
|
240
|
+
JSON.stringify({
|
|
241
|
+
quorum_active: ['codex-1', 'gemini-1'],
|
|
242
|
+
model_preferences: { 'codex-1': 'gpt-5-turbo' },
|
|
243
|
+
}),
|
|
244
|
+
'utf8'
|
|
245
|
+
);
|
|
246
|
+
const { stdout } = runHook({ prompt: '/qgsd:plan-phase', cwd: tempDir });
|
|
247
|
+
const ctx = JSON.parse(stdout).hookSpecificOutput.additionalContext;
|
|
248
|
+
// The AGENT_TOOL_MAP block generates "When calling mcp__<slot>__<tool>, include model=..."
|
|
249
|
+
// This must not appear when activeSlots is configured.
|
|
250
|
+
assert.ok(
|
|
251
|
+
!(/When calling mcp__[a-z]/.test(ctx)),
|
|
252
|
+
'model_preferences override block must not inject "When calling mcp__<slot>" when activeSlots is configured'
|
|
253
|
+
);
|
|
254
|
+
} finally {
|
|
255
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// TC10: circuit breaker disabled flag → does NOT inject resolution context
|
|
260
|
+
// Same temp dir setup but state = { active: true, disabled: true }
|
|
261
|
+
test('TC10: circuit breaker disabled flag → no injection (silent pass)', () => {
|
|
262
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nf-prompt-tc10-'));
|
|
263
|
+
try {
|
|
264
|
+
// Init git repo
|
|
265
|
+
spawnSync('git', ['init'], { cwd: tempDir, encoding: 'utf8', timeout: 5000 });
|
|
266
|
+
|
|
267
|
+
// Write circuit breaker state with disabled: true
|
|
268
|
+
const claudeDir = path.join(tempDir, '.claude');
|
|
269
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
270
|
+
fs.writeFileSync(
|
|
271
|
+
path.join(claudeDir, 'circuit-breaker-state.json'),
|
|
272
|
+
JSON.stringify({ active: true, disabled: true }),
|
|
273
|
+
'utf8'
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const { stdout, exitCode } = runHook({
|
|
277
|
+
prompt: '/qgsd:execute-phase',
|
|
278
|
+
cwd: tempDir,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
assert.strictEqual(exitCode, 0, 'exit code must be 0');
|
|
282
|
+
assert.strictEqual(stdout, '', 'stdout must be empty — disabled breaker produces no injection');
|
|
283
|
+
} finally {
|
|
284
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// TC-PROMPT-N-CAP: --n 3 caps injected slot list to N-1=2 external slots
|
|
289
|
+
test('TC-PROMPT-N-CAP: --n 3 caps injected slot list to N-1=2 external slots', () => {
|
|
290
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nf-prompt-nc-'));
|
|
291
|
+
try {
|
|
292
|
+
spawnSync('git', ['init'], { cwd: tempDir, encoding: 'utf8', timeout: 5000 });
|
|
293
|
+
const claudeDir = path.join(tempDir, '.claude');
|
|
294
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
295
|
+
fs.writeFileSync(
|
|
296
|
+
path.join(claudeDir, 'nf.json'),
|
|
297
|
+
JSON.stringify({ quorum_active: ['codex-1', 'gemini-1', 'opencode-1', 'copilot-1', 'claude-1'] }),
|
|
298
|
+
'utf8'
|
|
299
|
+
);
|
|
300
|
+
const { stdout } = runHook({ prompt: '/qgsd:plan-phase --n 3', cwd: tempDir });
|
|
301
|
+
const ctx = JSON.parse(stdout).hookSpecificOutput.additionalContext;
|
|
302
|
+
// Must announce the override
|
|
303
|
+
assert.ok(ctx.includes('QUORUM SIZE OVERRIDE (--n 3)'), 'must announce --n 3 override');
|
|
304
|
+
// Must cap to 2 numbered step Task lines (N-1 = 2). Regex matches numbered steps, not header prose.
|
|
305
|
+
const taskLineCount = (ctx.match(/\d+\. Task\(subagent_type="nf-quorum-slot-worker"/g) || []).length;
|
|
306
|
+
assert.strictEqual(taskLineCount, 2, '--n 3 must produce exactly 2 slot-worker Task lines (N-1=2)');
|
|
307
|
+
} finally {
|
|
308
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// TC-PROMPT-SOLO: --n 1 injects SOLO MODE ACTIVE, no Task slot lines
|
|
313
|
+
test('TC-PROMPT-SOLO: --n 1 injects SOLO MODE ACTIVE, no Task slot lines', () => {
|
|
314
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nf-prompt-solo-'));
|
|
315
|
+
try {
|
|
316
|
+
spawnSync('git', ['init'], { cwd: tempDir, encoding: 'utf8', timeout: 5000 });
|
|
317
|
+
const claudeDir = path.join(tempDir, '.claude');
|
|
318
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
319
|
+
fs.writeFileSync(
|
|
320
|
+
path.join(claudeDir, 'nf.json'),
|
|
321
|
+
JSON.stringify({ quorum_active: ['codex-1', 'gemini-1'] }),
|
|
322
|
+
'utf8'
|
|
323
|
+
);
|
|
324
|
+
const { stdout } = runHook({ prompt: '/qgsd:plan-phase --n 1', cwd: tempDir });
|
|
325
|
+
const ctx = JSON.parse(stdout).hookSpecificOutput.additionalContext;
|
|
326
|
+
assert.ok(ctx.includes('SOLO MODE ACTIVE (--n 1)'), 'must inject SOLO MODE ACTIVE marker');
|
|
327
|
+
assert.ok(ctx.includes('<!-- NF_SOLO_MODE -->'), 'must include NF_SOLO_MODE XML comment');
|
|
328
|
+
const taskLineCount = (ctx.match(/\d+\. Task\(subagent_type="nf-quorum-slot-worker"/g) || []).length;
|
|
329
|
+
assert.strictEqual(taskLineCount, 0, '--n 1 solo mode must produce zero slot-worker Task lines');
|
|
330
|
+
} finally {
|
|
331
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// TC-PROMPT-PREFER-SUB-DEFAULT: no preferSub config → defaults true, sub slots appear before api slots
|
|
336
|
+
test('TC-PROMPT-PREFER-SUB-DEFAULT: no preferSub config → defaults true, sub slots appear before api slots', () => {
|
|
337
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nf-prompt-psub-'));
|
|
338
|
+
try {
|
|
339
|
+
spawnSync('git', ['init'], { cwd: tempDir, encoding: 'utf8', timeout: 5000 });
|
|
340
|
+
const claudeDir = path.join(tempDir, '.claude');
|
|
341
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
342
|
+
// sub-1 listed AFTER api-1 in quorum_active — default preferSub must reorder
|
|
343
|
+
fs.writeFileSync(
|
|
344
|
+
path.join(claudeDir, 'nf.json'),
|
|
345
|
+
JSON.stringify({
|
|
346
|
+
quorum_active: ['api-slot-1', 'sub-slot-1'],
|
|
347
|
+
agent_config: {
|
|
348
|
+
'api-slot-1': { auth_type: 'api' },
|
|
349
|
+
'sub-slot-1': { auth_type: 'sub' },
|
|
350
|
+
},
|
|
351
|
+
// No quorum.preferSub key → defaults to true
|
|
352
|
+
}),
|
|
353
|
+
'utf8'
|
|
354
|
+
);
|
|
355
|
+
const { stdout } = runHook({ prompt: '/qgsd:plan-phase', cwd: tempDir });
|
|
356
|
+
const ctx = JSON.parse(stdout).hookSpecificOutput.additionalContext;
|
|
357
|
+
const subPos = ctx.indexOf('sub-slot-1');
|
|
358
|
+
const apiPos = ctx.indexOf('api-slot-1');
|
|
359
|
+
assert.ok(subPos !== -1, 'sub-slot-1 must appear in step list');
|
|
360
|
+
assert.ok(apiPos !== -1, 'api-slot-1 must appear in step list');
|
|
361
|
+
assert.ok(subPos < apiPos, 'sub slot must appear before api slot (preferSub default=true)');
|
|
362
|
+
} finally {
|
|
363
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// TC-PROMPT-FAILOVER-RULE: injected context must instruct Claude to skip UNAVAIL slots.
|
|
368
|
+
// This is the runtime bridge that determines polledCount in NFQuorum.tla: Claude
|
|
369
|
+
// follows these injected instructions to skip unresponsive slot-workers, reducing
|
|
370
|
+
// polledCount from MaxSize to however many slots actually responded.
|
|
371
|
+
test('TC-PROMPT-FAILOVER-RULE: injected context includes skip-if-UNAVAIL failover rule', () => {
|
|
372
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nf-prompt-fr-'));
|
|
373
|
+
try {
|
|
374
|
+
spawnSync('git', ['init'], { cwd: tempDir, encoding: 'utf8', timeout: 5000 });
|
|
375
|
+
const claudeDir = path.join(tempDir, '.claude');
|
|
376
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
377
|
+
fs.writeFileSync(
|
|
378
|
+
path.join(claudeDir, 'nf.json'),
|
|
379
|
+
JSON.stringify({ quorum_active: ['gemini-1', 'opencode-1', 'copilot-1'] }),
|
|
380
|
+
'utf8'
|
|
381
|
+
);
|
|
382
|
+
const { stdout } = runHook({ prompt: '/qgsd:plan-phase', cwd: tempDir });
|
|
383
|
+
const ctx = JSON.parse(stdout).hookSpecificOutput.additionalContext;
|
|
384
|
+
// Two variants: simple "Failover rule: ...skip..." or structured "SLOT DISPATCH SEQUENCE (FALLBACK-01)..."
|
|
385
|
+
// Both must reference UNAVAIL and contain "skip" (skip individual unavail slots or skip to next step).
|
|
386
|
+
assert.ok(
|
|
387
|
+
ctx.includes('Failover rule') || ctx.includes('SLOT DISPATCH SEQUENCE'),
|
|
388
|
+
'injected context must contain a failover rule (simple or FALLBACK-01 structured)'
|
|
389
|
+
);
|
|
390
|
+
assert.ok(ctx.includes('UNAVAIL'), 'failover rule must reference UNAVAIL state');
|
|
391
|
+
// Both variants instruct to "skip" unavail slots (simple: "skip it"; structured: "skip UNAVAIL").
|
|
392
|
+
assert.ok(ctx.includes('skip'), 'failover rule must instruct to skip unresponsive slots');
|
|
393
|
+
// Verify the rule explicitly says errors do not count toward the required total,
|
|
394
|
+
// confirming that a failed slot does not reduce the consensus threshold.
|
|
395
|
+
assert.ok(
|
|
396
|
+
ctx.includes('do not count toward'),
|
|
397
|
+
'failover rule must state that errors do not count toward required total'
|
|
398
|
+
);
|
|
399
|
+
} finally {
|
|
400
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// TC-PROMPT-FALLBACK-T1-PRIORITY: when sub-CLI slots exceed the fan-out cap, the injected
|
|
405
|
+
// instructions must name the unused sub-CLI slots (T1) in the Failover rule before any
|
|
406
|
+
// ccr/api slots (T2). This regression test prevents the bug where both primary slots
|
|
407
|
+
// (codex-1, gemini-1) were UNAVAIL and the fallback jumped directly to claude-1/claude-2
|
|
408
|
+
// instead of trying opencode-1 and copilot-1 first.
|
|
409
|
+
test('TC-PROMPT-FALLBACK-T1-PRIORITY: unused sub-CLI slots listed as T1 before ccr in Failover rule', () => {
|
|
410
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nf-prompt-t1-'));
|
|
411
|
+
try {
|
|
412
|
+
spawnSync('git', ['init'], { cwd: tempDir, encoding: 'utf8', timeout: 5000 });
|
|
413
|
+
const claudeDir = path.join(tempDir, '.claude');
|
|
414
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
415
|
+
// 4 sub slots, maxSize=3 → externalSlotCap=2 → 2 are dispatched, 2 are T1 unused
|
|
416
|
+
fs.writeFileSync(
|
|
417
|
+
path.join(claudeDir, 'nf.json'),
|
|
418
|
+
JSON.stringify({
|
|
419
|
+
quorum_active: ['codex-1', 'gemini-1', 'opencode-1', 'copilot-1'],
|
|
420
|
+
quorum: { maxSize: 3 },
|
|
421
|
+
agent_config: {
|
|
422
|
+
'codex-1': { auth_type: 'sub' },
|
|
423
|
+
'gemini-1': { auth_type: 'sub' },
|
|
424
|
+
'opencode-1': { auth_type: 'sub' },
|
|
425
|
+
'copilot-1': { auth_type: 'sub' },
|
|
426
|
+
},
|
|
427
|
+
}),
|
|
428
|
+
'utf8'
|
|
429
|
+
);
|
|
430
|
+
const { stdout } = runHook({ prompt: '/qgsd:plan-phase', cwd: tempDir });
|
|
431
|
+
const ctx = JSON.parse(stdout).hookSpecificOutput.additionalContext;
|
|
432
|
+
|
|
433
|
+
// Must use the FALLBACK-01 tiered rule, not the simple skip rule
|
|
434
|
+
assert.ok(ctx.includes('FALLBACK-01'), 'Must use tiered FALLBACK-01 rule when T1 slots are unused');
|
|
435
|
+
|
|
436
|
+
// T1 unused slots (opencode-1, copilot-1) must be named explicitly in the failover rule
|
|
437
|
+
assert.ok(ctx.includes('opencode-1'), 'T1 unused slot opencode-1 must appear in failover rule');
|
|
438
|
+
assert.ok(ctx.includes('copilot-1'), 'T1 unused slot copilot-1 must appear in failover rule');
|
|
439
|
+
|
|
440
|
+
// The dispatched steps (1., 2.) must only contain the first 2 capped sub slots
|
|
441
|
+
const dispatchedSlots = [...ctx.matchAll(/\d+\. Task\(subagent_type="nf-quorum-slot-worker", prompt="slot: (\S+)\\n/g)]
|
|
442
|
+
.map(m => m[1]);
|
|
443
|
+
assert.equal(dispatchedSlots.length, 2, 'Exactly 2 slots should be in the dispatch list (externalSlotCap=2)');
|
|
444
|
+
assert.ok(!dispatchedSlots.includes('opencode-1'), 'opencode-1 should NOT be in initial dispatch (it is T1 unused)');
|
|
445
|
+
assert.ok(!dispatchedSlots.includes('copilot-1'), 'copilot-1 should NOT be in initial dispatch (it is T1 unused)');
|
|
446
|
+
|
|
447
|
+
// T1 step must appear BEFORE T2 step in the structured dispatch sequence
|
|
448
|
+
const t1Pos = ctx.indexOf('Step 2 T1');
|
|
449
|
+
const t2Pos = ctx.indexOf('Step 3 T2');
|
|
450
|
+
assert.ok(t1Pos !== -1, 'Step 2 T1 label must appear in the structured sequence');
|
|
451
|
+
assert.ok(t2Pos !== -1, 'Step 3 T2 label must appear in the structured sequence');
|
|
452
|
+
assert.ok(t1Pos < t2Pos, 'T1 tier (Step 2) must appear before T2 tier (Step 3)');
|
|
453
|
+
} finally {
|
|
454
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// TC-PROMPT-FALLBACK-ROUTINE: FAN_OUT_COUNT=2 (routine risk, 1 external slot) leaves 3 sub
|
|
459
|
+
// slots unused as T1. The structured sequence must name all 3 in Step 2.
|
|
460
|
+
test('TC-PROMPT-FALLBACK-ROUTINE: routine risk_level → FAN_OUT_COUNT=2, 3 T1 slots in sequence', () => {
|
|
461
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nf-prompt-routine-'));
|
|
462
|
+
try {
|
|
463
|
+
spawnSync('git', ['init'], { cwd: tempDir, encoding: 'utf8', timeout: 5000 });
|
|
464
|
+
const claudeDir = path.join(tempDir, '.claude');
|
|
465
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
466
|
+
fs.writeFileSync(
|
|
467
|
+
path.join(claudeDir, 'nf.json'),
|
|
468
|
+
JSON.stringify({
|
|
469
|
+
quorum_active: ['codex-1', 'gemini-1', 'opencode-1', 'copilot-1'],
|
|
470
|
+
quorum: { maxSize: 5 },
|
|
471
|
+
agent_config: {
|
|
472
|
+
'codex-1': { auth_type: 'sub' },
|
|
473
|
+
'gemini-1': { auth_type: 'sub' },
|
|
474
|
+
'opencode-1': { auth_type: 'sub' },
|
|
475
|
+
'copilot-1': { auth_type: 'sub' },
|
|
476
|
+
},
|
|
477
|
+
}),
|
|
478
|
+
'utf8'
|
|
479
|
+
);
|
|
480
|
+
// Simulate routine risk_level via context_yaml in the hook payload
|
|
481
|
+
const payload = {
|
|
482
|
+
prompt: '/qgsd:plan-phase',
|
|
483
|
+
cwd: tempDir,
|
|
484
|
+
context_yaml: 'risk_level: routine\n',
|
|
485
|
+
};
|
|
486
|
+
const { stdout } = runHook(payload);
|
|
487
|
+
const ctx = JSON.parse(stdout).hookSpecificOutput.additionalContext;
|
|
488
|
+
|
|
489
|
+
// FAN_OUT_COUNT=2 → externalSlotCap=1 → 1 primary, 3 T1 unused
|
|
490
|
+
assert.ok(ctx.includes('FALLBACK-01'), 'Must use FALLBACK-01 when T1 slots exist');
|
|
491
|
+
const dispatchedSlots = [...ctx.matchAll(/\d+\. Task\(subagent_type="nf-quorum-slot-worker", prompt="slot: (\S+)\\n/g)]
|
|
492
|
+
.map(m => m[1]);
|
|
493
|
+
assert.equal(dispatchedSlots.length, 1, 'Only 1 external slot dispatched for routine risk');
|
|
494
|
+
|
|
495
|
+
// The 3 unused sub slots must appear in Step 2 T1 line
|
|
496
|
+
assert.ok(ctx.includes('Step 2 T1 sub-CLI'), 'Step 2 T1 label must appear in sequence');
|
|
497
|
+
const t1LineMatch = ctx.match(/Step 2 T1 sub-CLI:\s+\[([^\]]+)\]/);
|
|
498
|
+
assert.ok(t1LineMatch, 'Step 2 T1 must list slots in brackets');
|
|
499
|
+
const t1Slots = t1LineMatch[1].split(',').map(s => s.trim());
|
|
500
|
+
assert.equal(t1Slots.length, 3, 'Step 2 T1 must have 3 unused slots');
|
|
501
|
+
} finally {
|
|
502
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// TC-PROMPT-FALLBACK-NO-T1: all sub slots fit within the cap → no T1 unused → simple rule.
|
|
507
|
+
test('TC-PROMPT-FALLBACK-NO-T1: all sub slots dispatched → no FALLBACK-01, simple rule', () => {
|
|
508
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nf-prompt-not1-'));
|
|
509
|
+
try {
|
|
510
|
+
spawnSync('git', ['init'], { cwd: tempDir, encoding: 'utf8', timeout: 5000 });
|
|
511
|
+
const claudeDir = path.join(tempDir, '.claude');
|
|
512
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
513
|
+
// 2 sub slots, maxSize=5 → externalSlotCap=4 → both sub slots fit, no T1 unused
|
|
514
|
+
fs.writeFileSync(
|
|
515
|
+
path.join(claudeDir, 'nf.json'),
|
|
516
|
+
JSON.stringify({
|
|
517
|
+
quorum_active: ['gemini-1', 'opencode-1'],
|
|
518
|
+
quorum: { maxSize: 5 },
|
|
519
|
+
agent_config: {
|
|
520
|
+
'gemini-1': { auth_type: 'sub' },
|
|
521
|
+
'opencode-1': { auth_type: 'sub' },
|
|
522
|
+
},
|
|
523
|
+
}),
|
|
524
|
+
'utf8'
|
|
525
|
+
);
|
|
526
|
+
const { stdout } = runHook({ prompt: '/qgsd:plan-phase', cwd: tempDir });
|
|
527
|
+
const ctx = JSON.parse(stdout).hookSpecificOutput.additionalContext;
|
|
528
|
+
|
|
529
|
+
// No T1 unused → should NOT emit FALLBACK-01 or Step 2 T1
|
|
530
|
+
assert.ok(!ctx.includes('FALLBACK-01'), 'Must NOT use FALLBACK-01 when all sub slots are dispatched');
|
|
531
|
+
assert.ok(!ctx.includes('Step 2 T1'), 'Must NOT emit Step 2 T1 when no T1 slots exist');
|
|
532
|
+
// Should use the simple failover rule
|
|
533
|
+
assert.ok(ctx.includes('Failover rule'), 'Simple Failover rule must appear');
|
|
534
|
+
assert.ok(ctx.includes('do not count toward'), 'Must still state errors don\'t count');
|
|
535
|
+
} finally {
|
|
536
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
// TC-PROMPT-FALLBACK-T1-EXCLUDES-PRIMARIES: T1 list must not include slots already dispatched.
|
|
541
|
+
// Regression: if opencode-1 is in the primary dispatch, it must not also appear in Step 2 T1.
|
|
542
|
+
test('TC-PROMPT-FALLBACK-T1-EXCLUDES-PRIMARIES: T1 list excludes slots already in primary dispatch', () => {
|
|
543
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nf-prompt-excl-'));
|
|
544
|
+
try {
|
|
545
|
+
spawnSync('git', ['init'], { cwd: tempDir, encoding: 'utf8', timeout: 5000 });
|
|
546
|
+
const claudeDir = path.join(tempDir, '.claude');
|
|
547
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
548
|
+
// 4 sub slots, maxSize=4 → externalSlotCap=3 → 3 dispatched, 1 T1 unused (copilot-1)
|
|
549
|
+
fs.writeFileSync(
|
|
550
|
+
path.join(claudeDir, 'nf.json'),
|
|
551
|
+
JSON.stringify({
|
|
552
|
+
quorum_active: ['codex-1', 'gemini-1', 'opencode-1', 'copilot-1'],
|
|
553
|
+
quorum: { maxSize: 4 },
|
|
554
|
+
agent_config: {
|
|
555
|
+
'codex-1': { auth_type: 'sub' },
|
|
556
|
+
'gemini-1': { auth_type: 'sub' },
|
|
557
|
+
'opencode-1': { auth_type: 'sub' },
|
|
558
|
+
'copilot-1': { auth_type: 'sub' },
|
|
559
|
+
},
|
|
560
|
+
}),
|
|
561
|
+
'utf8'
|
|
562
|
+
);
|
|
563
|
+
const { stdout } = runHook({ prompt: '/qgsd:plan-phase', cwd: tempDir });
|
|
564
|
+
const ctx = JSON.parse(stdout).hookSpecificOutput.additionalContext;
|
|
565
|
+
|
|
566
|
+
assert.ok(ctx.includes('FALLBACK-01'), 'FALLBACK-01 must fire (1 T1 unused)');
|
|
567
|
+
|
|
568
|
+
// The dispatched primary slots (Step 1) must NOT appear in the T1 line (Step 2)
|
|
569
|
+
const t1LineMatch = ctx.match(/Step 2 T1 sub-CLI:\s+\[([^\]]+)\]/);
|
|
570
|
+
assert.ok(t1LineMatch, 'Step 2 T1 must list slots');
|
|
571
|
+
const t1Slots = new Set(t1LineMatch[1].split(',').map(s => s.trim()));
|
|
572
|
+
|
|
573
|
+
const primaryLineMatch = ctx.match(/Step 1 PRIMARY:\s+\[([^\]]+)\]/);
|
|
574
|
+
assert.ok(primaryLineMatch, 'Step 1 PRIMARY must list slots');
|
|
575
|
+
const primarySlots = primaryLineMatch[1].split(',').map(s => s.trim());
|
|
576
|
+
|
|
577
|
+
for (const primary of primarySlots) {
|
|
578
|
+
assert.ok(!t1Slots.has(primary), `Primary slot "${primary}" must NOT appear in T1 list`);
|
|
579
|
+
}
|
|
580
|
+
// Only copilot-1 should be in T1
|
|
581
|
+
assert.deepEqual([...t1Slots], ['copilot-1']);
|
|
582
|
+
} finally {
|
|
583
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// TC-PROMPT-FALLBACK-AUTHTYPE-DYNAMIC: T1/T2 classification must be driven by runtime auth_type,
|
|
588
|
+
// NOT by slot naming convention. A "ccr" named slot with auth_type=sub must land in T1;
|
|
589
|
+
// a "native CLI" named slot with auth_type=api must land in T2.
|
|
590
|
+
test('TC-PROMPT-FALLBACK-AUTHTYPE-DYNAMIC: T1/T2 classification driven by auth_type, not slot name', () => {
|
|
591
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nf-prompt-authtype-'));
|
|
592
|
+
try {
|
|
593
|
+
spawnSync('git', ['init'], { cwd: tempDir, encoding: 'utf8', timeout: 5000 });
|
|
594
|
+
const claudeDir = path.join(tempDir, '.claude');
|
|
595
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
596
|
+
// Non-standard config: claude-1 (typically T2 name) → auth_type=sub (→ T1)
|
|
597
|
+
// codex-1 (typically T1 name) → auth_type=api (→ T2)
|
|
598
|
+
// maxSize=3 → externalSlotCap=2 → dispatch gemini-1 + claude-1 (ordered by sub-first preference)
|
|
599
|
+
// T1 unused = sub slots not in dispatch: none extra sub here
|
|
600
|
+
// Actually: orderedSlots = sub-first: gemini-1(sub), claude-1(sub), codex-1(api)
|
|
601
|
+
// cappedSlots (externalSlotCap=2) = [gemini-1, claude-1]
|
|
602
|
+
// T1_UNUSED = sub slots not in capped = [] (both sub slots are dispatched)
|
|
603
|
+
// T2 = [codex-1] (api)
|
|
604
|
+
// Result: no FALLBACK-01, simple Failover rule (no T1 unused)
|
|
605
|
+
//
|
|
606
|
+
// Better scenario for the test: add a third sub slot to ensure T1 unused exists.
|
|
607
|
+
// quorum_active: [gemini-1(sub), claude-1(sub), claude-2(sub), codex-1(api)]
|
|
608
|
+
// maxSize=3 → externalSlotCap=2 → dispatch [gemini-1, claude-1]
|
|
609
|
+
// T1_UNUSED = [claude-2] (sub, not dispatched)
|
|
610
|
+
// T2 = [codex-1] (api)
|
|
611
|
+
// FALLBACK-01 must fire with claude-2 in Step 2 T1 and codex-1 in Step 3 T2
|
|
612
|
+
fs.writeFileSync(
|
|
613
|
+
path.join(claudeDir, 'nf.json'),
|
|
614
|
+
JSON.stringify({
|
|
615
|
+
quorum_active: ['gemini-1', 'claude-1', 'claude-2', 'codex-1'],
|
|
616
|
+
quorum: { maxSize: 3 },
|
|
617
|
+
agent_config: {
|
|
618
|
+
'gemini-1': { auth_type: 'sub' },
|
|
619
|
+
'claude-1': { auth_type: 'sub' }, // normally api — overridden to sub
|
|
620
|
+
'claude-2': { auth_type: 'sub' }, // normally api — overridden to sub
|
|
621
|
+
'codex-1': { auth_type: 'api' }, // normally sub — overridden to api
|
|
622
|
+
},
|
|
623
|
+
}),
|
|
624
|
+
'utf8'
|
|
625
|
+
);
|
|
626
|
+
const { stdout } = runHook({ prompt: '/qgsd:plan-phase', cwd: tempDir });
|
|
627
|
+
const ctx = JSON.parse(stdout).hookSpecificOutput.additionalContext;
|
|
628
|
+
|
|
629
|
+
// FALLBACK-01 must fire — claude-2 is an unused sub slot
|
|
630
|
+
assert.ok(ctx.includes('FALLBACK-01'), 'FALLBACK-01 must fire when unused sub slot (claude-2) exists');
|
|
631
|
+
|
|
632
|
+
// claude-2 is sub but not dispatched → must appear in Step 2 T1
|
|
633
|
+
const t1LineMatch = ctx.match(/Step 2 T1 sub-CLI:\s+\[([^\]]+)\]/);
|
|
634
|
+
assert.ok(t1LineMatch, 'Step 2 T1 must list slots');
|
|
635
|
+
const t1Slots = t1LineMatch[1].split(',').map(s => s.trim());
|
|
636
|
+
assert.ok(t1Slots.includes('claude-2'), 'claude-2 (auth_type=sub) must appear in T1 despite its "ccr" name');
|
|
637
|
+
|
|
638
|
+
// codex-1 is api → must appear in Step 3 T2, NOT in Step 2 T1
|
|
639
|
+
assert.ok(!t1Slots.includes('codex-1'), 'codex-1 (auth_type=api) must NOT appear in T1 despite its "native CLI" name');
|
|
640
|
+
const t2LineMatch = ctx.match(/Step 3 T2 ccr:\s+\[([^\]]+)\]/);
|
|
641
|
+
assert.ok(t2LineMatch, 'Step 3 T2 must list slots');
|
|
642
|
+
const t2Slots = t2LineMatch[1].split(',').map(s => s.trim());
|
|
643
|
+
assert.ok(t2Slots.includes('codex-1'), 'codex-1 (auth_type=api) must appear in T2 despite its "native CLI" name');
|
|
644
|
+
} finally {
|
|
645
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
// TC-PROMPT-FALLBACK-T2-EXCLUDES-PRIMARIES: an api slot dispatched as primary must NOT
|
|
650
|
+
// appear in Step 3 T2. Regression for the bug where t2Slots filtered all non-sub slots
|
|
651
|
+
// without excluding cappedSlots, causing primary api slots to appear in both Step 1 and Step 3.
|
|
652
|
+
test('TC-PROMPT-FALLBACK-T2-EXCLUDES-PRIMARIES: api slots dispatched as primary excluded from T2 list', () => {
|
|
653
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nf-prompt-t2excl-'));
|
|
654
|
+
try {
|
|
655
|
+
spawnSync('git', ['init'], { cwd: tempDir, encoding: 'utf8', timeout: 5000 });
|
|
656
|
+
const claudeDir = path.join(tempDir, '.claude');
|
|
657
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
658
|
+
// Config: api-slot-A dispatched as primary (externalSlotCap=1); sub-slot-B is T1 unused;
|
|
659
|
+
// api-slot-C is the only genuine T2. api-slot-A must NOT appear in T2.
|
|
660
|
+
// quorum_active: ['api-slot-A', 'sub-slot-B', 'api-slot-C']
|
|
661
|
+
// preferSub: false → orderedSlots order preserved: api-slot-A, sub-slot-B, api-slot-C
|
|
662
|
+
// maxSize=2 → externalSlotCap=1 → cappedSlots=[api-slot-A]
|
|
663
|
+
// t1Unused = sub slots not in capped = [sub-slot-B]
|
|
664
|
+
// t2Slots (correct) = api slots not in capped = [api-slot-C] (excludes api-slot-A)
|
|
665
|
+
// t2Slots (buggy) = all api slots = [api-slot-A, api-slot-C] (includes primary!)
|
|
666
|
+
fs.writeFileSync(
|
|
667
|
+
path.join(claudeDir, 'nf.json'),
|
|
668
|
+
JSON.stringify({
|
|
669
|
+
quorum_active: ['api-slot-A', 'sub-slot-B', 'api-slot-C'],
|
|
670
|
+
quorum: { maxSize: 2, preferSub: false },
|
|
671
|
+
agent_config: {
|
|
672
|
+
'api-slot-A': { auth_type: 'api' },
|
|
673
|
+
'sub-slot-B': { auth_type: 'sub' },
|
|
674
|
+
'api-slot-C': { auth_type: 'api' },
|
|
675
|
+
},
|
|
676
|
+
}),
|
|
677
|
+
'utf8'
|
|
678
|
+
);
|
|
679
|
+
const { stdout } = runHook({ prompt: '/qgsd:plan-phase', cwd: tempDir });
|
|
680
|
+
const ctx = JSON.parse(stdout).hookSpecificOutput.additionalContext;
|
|
681
|
+
|
|
682
|
+
assert.ok(ctx.includes('FALLBACK-01'), 'FALLBACK-01 must fire (sub-slot-B is T1 unused)');
|
|
683
|
+
|
|
684
|
+
const t2LineMatch = ctx.match(/Step 3 T2 ccr:\s+\[([^\]]+)\]/);
|
|
685
|
+
assert.ok(t2LineMatch, 'Step 3 T2 must be present');
|
|
686
|
+
const t2Slots = t2LineMatch[1].split(',').map(s => s.trim());
|
|
687
|
+
|
|
688
|
+
assert.ok(!t2Slots.includes('api-slot-A'), 'api-slot-A is primary — must NOT appear in Step 3 T2');
|
|
689
|
+
assert.ok(t2Slots.includes('api-slot-C'), 'api-slot-C is the only genuine T2 slot');
|
|
690
|
+
|
|
691
|
+
const primaryLineMatch = ctx.match(/Step 1 PRIMARY:\s+\[([^\]]+)\]/);
|
|
692
|
+
assert.ok(primaryLineMatch, 'Step 1 PRIMARY must be present');
|
|
693
|
+
const primarySlots = primaryLineMatch[1].split(',').map(s => s.trim());
|
|
694
|
+
assert.ok(primarySlots.includes('api-slot-A'), 'api-slot-A must appear in Step 1 PRIMARY');
|
|
695
|
+
} finally {
|
|
696
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
697
|
+
}
|
|
698
|
+
});
|