nubos-pilot 0.1.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/agents/np-ai-researcher.md +140 -0
- package/agents/np-code-fixer.md +363 -0
- package/agents/np-code-reviewer.md +351 -0
- package/agents/np-domain-researcher.md +136 -0
- package/agents/np-eval-auditor.md +167 -0
- package/agents/np-eval-planner.md +153 -0
- package/agents/np-executor.md +72 -0
- package/agents/np-framework-selector.md +171 -0
- package/agents/np-nyquist-auditor.md +185 -0
- package/agents/np-plan-checker.md +165 -0
- package/agents/np-planner.md +199 -0
- package/agents/np-researcher.md +150 -0
- package/agents/np-security-auditor.md +206 -0
- package/agents/np-ui-auditor.md +369 -0
- package/agents/np-ui-checker.md +192 -0
- package/agents/np-ui-researcher.md +324 -0
- package/agents/np-verifier.md +79 -0
- package/bin/check-coverage.cjs +40 -0
- package/bin/check-workflows.cjs +171 -0
- package/bin/check-workflows.test.cjs +208 -0
- package/bin/install.js +500 -0
- package/bin/np-tools/_commands.cjs +70 -0
- package/bin/np-tools/add-tests.cjs +171 -0
- package/bin/np-tools/add-tests.test.cjs +122 -0
- package/bin/np-tools/add-todo.cjs +108 -0
- package/bin/np-tools/add-todo.test.cjs +112 -0
- package/bin/np-tools/agent-skills.cjs +14 -0
- package/bin/np-tools/agent-skills.test.cjs +42 -0
- package/bin/np-tools/ai-integration-phase.cjs +109 -0
- package/bin/np-tools/ai-integration-phase.test.cjs +123 -0
- package/bin/np-tools/askuser.cjs +53 -0
- package/bin/np-tools/askuser.test.cjs +49 -0
- package/bin/np-tools/autonomous.cjs +69 -0
- package/bin/np-tools/autonomous.test.cjs +74 -0
- package/bin/np-tools/checkpoint.cjs +101 -0
- package/bin/np-tools/checkpoint.test.cjs +119 -0
- package/bin/np-tools/code-review.cjs +133 -0
- package/bin/np-tools/code-review.test.cjs +96 -0
- package/bin/np-tools/commit-task.cjs +120 -0
- package/bin/np-tools/commit-task.test.cjs +160 -0
- package/bin/np-tools/commit.cjs +103 -0
- package/bin/np-tools/commit.test.cjs +93 -0
- package/bin/np-tools/config.cjs +101 -0
- package/bin/np-tools/config.test.cjs +71 -0
- package/bin/np-tools/discuss-phase-power.cjs +265 -0
- package/bin/np-tools/discuss-phase-power.test.cjs +242 -0
- package/bin/np-tools/discuss-phase.cjs +132 -0
- package/bin/np-tools/discuss-phase.test.cjs +148 -0
- package/bin/np-tools/dispatch.cjs +116 -0
- package/bin/np-tools/doctor.cjs +242 -0
- package/bin/np-tools/eval-review.cjs +116 -0
- package/bin/np-tools/eval-review.test.cjs +123 -0
- package/bin/np-tools/execute-phase.cjs +182 -0
- package/bin/np-tools/execute-phase.test.cjs +116 -0
- package/bin/np-tools/execute-plan.cjs +124 -0
- package/bin/np-tools/execute-plan.test.cjs +82 -0
- package/bin/np-tools/help.cjs +28 -0
- package/bin/np-tools/help.test.cjs +29 -0
- package/bin/np-tools/init-dispatch.test.cjs +91 -0
- package/bin/np-tools/metrics.cjs +97 -0
- package/bin/np-tools/metrics.test.cjs +188 -0
- package/bin/np-tools/new-milestone.cjs +288 -0
- package/bin/np-tools/new-milestone.test.cjs +166 -0
- package/bin/np-tools/new-project.cjs +284 -0
- package/bin/np-tools/new-project.test.cjs +165 -0
- package/bin/np-tools/next.cjs +7 -0
- package/bin/np-tools/next.test.cjs +30 -0
- package/bin/np-tools/park.cjs +48 -0
- package/bin/np-tools/park.test.cjs +50 -0
- package/bin/np-tools/pause-work.cjs +24 -0
- package/bin/np-tools/pause-work.test.cjs +74 -0
- package/bin/np-tools/phase.cjs +71 -0
- package/bin/np-tools/phase.test.cjs +81 -0
- package/bin/np-tools/plan-diff.cjs +57 -0
- package/bin/np-tools/plan-diff.test.cjs +134 -0
- package/bin/np-tools/plan-milestone-gaps.cjs +115 -0
- package/bin/np-tools/plan-milestone-gaps.test.cjs +122 -0
- package/bin/np-tools/plan-phase.cjs +350 -0
- package/bin/np-tools/plan-phase.test.cjs +263 -0
- package/bin/np-tools/progress.cjs +7 -0
- package/bin/np-tools/progress.test.cjs +44 -0
- package/bin/np-tools/queue.cjs +213 -0
- package/bin/np-tools/research-phase.cjs +144 -0
- package/bin/np-tools/research-phase.test.cjs +154 -0
- package/bin/np-tools/reset-slice.cjs +17 -0
- package/bin/np-tools/reset-slice.test.cjs +96 -0
- package/bin/np-tools/resolve-model.cjs +110 -0
- package/bin/np-tools/resolve-model.test.cjs +200 -0
- package/bin/np-tools/resume-work.cjs +76 -0
- package/bin/np-tools/resume-work.test.cjs +91 -0
- package/bin/np-tools/skip.cjs +48 -0
- package/bin/np-tools/skip.test.cjs +66 -0
- package/bin/np-tools/slug.cjs +34 -0
- package/bin/np-tools/slug.test.cjs +46 -0
- package/bin/np-tools/state.cjs +16 -0
- package/bin/np-tools/state.test.cjs +40 -0
- package/bin/np-tools/stats.cjs +151 -0
- package/bin/np-tools/stats.test.cjs +118 -0
- package/bin/np-tools/triage.cjs +128 -0
- package/bin/np-tools/ui-phase.cjs +108 -0
- package/bin/np-tools/ui-phase.test.cjs +121 -0
- package/bin/np-tools/ui-review.cjs +108 -0
- package/bin/np-tools/ui-review.test.cjs +120 -0
- package/bin/np-tools/undo-task.cjs +31 -0
- package/bin/np-tools/undo-task.test.cjs +117 -0
- package/bin/np-tools/undo.cjs +43 -0
- package/bin/np-tools/undo.test.cjs +120 -0
- package/bin/np-tools/unpark.cjs +48 -0
- package/bin/np-tools/unpark.test.cjs +50 -0
- package/bin/np-tools/verify-work.cjs +186 -0
- package/bin/np-tools/verify-work.test.cjs +97 -0
- package/docs/adr/0001-no-daemon-invariant.md +82 -0
- package/docs/adr/0002-zero-runtime-dependencies.md +90 -0
- package/docs/adr/0003-max-six-unit-types.md +85 -0
- package/docs/adr/0004-atomic-commit-per-unit.md +102 -0
- package/docs/adr/0005-three-orthogonal-file-trees.md +98 -0
- package/docs/adr/0006-yaml-dependency-amendment.md +60 -0
- package/docs/adr/README.md +27 -0
- package/docs/agent-frontmatter-schema.md +84 -0
- package/docs/phase-artifact-schemas.md +292 -0
- package/docs/phase-directory-layout.md +82 -0
- package/lib/__tests__/README.md +1 -0
- package/lib/agents.cjs +98 -0
- package/lib/agents.test.cjs +286 -0
- package/lib/askuser.cjs +36 -0
- package/lib/askuser.test.cjs +310 -0
- package/lib/checkpoint.cjs +135 -0
- package/lib/checkpoint.test.cjs +184 -0
- package/lib/core.cjs +165 -0
- package/lib/core.test.cjs +405 -0
- package/lib/fixtures/README.md +1 -0
- package/lib/fixtures/phase-tree/README.md +1 -0
- package/lib/fixtures/plans/cycle/PLAN.md +16 -0
- package/lib/fixtures/plans/cycle/tasks/T-01.md +20 -0
- package/lib/fixtures/plans/cycle/tasks/T-02.md +20 -0
- package/lib/fixtures/plans/cycle/tasks/T-03.md +20 -0
- package/lib/fixtures/plans/linear/PLAN.md +16 -0
- package/lib/fixtures/plans/linear/tasks/T-01.md +20 -0
- package/lib/fixtures/plans/linear/tasks/T-02.md +20 -0
- package/lib/fixtures/plans/linear/tasks/T-03.md +20 -0
- package/lib/fixtures/plans/parallel/PLAN.md +16 -0
- package/lib/fixtures/plans/parallel/tasks/T-01.md +20 -0
- package/lib/fixtures/plans/parallel/tasks/T-02.md +20 -0
- package/lib/fixtures/plans/parallel/tasks/T-03.md +20 -0
- package/lib/fixtures/plans/wave-conflict/PLAN.md +16 -0
- package/lib/fixtures/plans/wave-conflict/tasks/T-01.md +20 -0
- package/lib/fixtures/plans/wave-conflict/tasks/T-02.md +20 -0
- package/lib/fixtures/roadmap/ROADMAP-malformed.md +3 -0
- package/lib/fixtures/roadmap/ROADMAP-minimal.md +51 -0
- package/lib/fixtures/roadmap/roadmap-malformed.yaml +7 -0
- package/lib/fixtures/roadmap/roadmap-minimal.yaml +40 -0
- package/lib/fixtures/roadmap/roadmap-ten-phases.yaml +101 -0
- package/lib/fixtures/templates/phase-context.md +6 -0
- package/lib/fixtures/templates/plan-skeleton.md +6 -0
- package/lib/frontmatter.cjs +251 -0
- package/lib/frontmatter.test.cjs +177 -0
- package/lib/gaps.cjs +197 -0
- package/lib/gaps.test.cjs +200 -0
- package/lib/git.cjs +207 -0
- package/lib/git.test.cjs +305 -0
- package/lib/install/agents-md.cjs +77 -0
- package/lib/install/backup.cjs +70 -0
- package/lib/install/codex-toml.cjs +440 -0
- package/lib/install/managed-block.cjs +30 -0
- package/lib/install/manifest.cjs +148 -0
- package/lib/install/mcp-writer.cjs +127 -0
- package/lib/install/runtime-detect.cjs +44 -0
- package/lib/install/staging.cjs +149 -0
- package/lib/metrics-aggregate.cjs +229 -0
- package/lib/metrics-aggregate.test.cjs +192 -0
- package/lib/metrics.cjs +120 -0
- package/lib/metrics.test.cjs +182 -0
- package/lib/model-aliases.regression.test.cjs +16 -0
- package/lib/model-profiles.cjs +42 -0
- package/lib/model-profiles.test.cjs +61 -0
- package/lib/next.cjs +236 -0
- package/lib/next.test.cjs +194 -0
- package/lib/phase.cjs +95 -0
- package/lib/phase.test.cjs +189 -0
- package/lib/plan-checker-contract.test.cjs +72 -0
- package/lib/plan-diff.cjs +173 -0
- package/lib/plan-diff.test.cjs +217 -0
- package/lib/plan.cjs +85 -0
- package/lib/plan.test.cjs +263 -0
- package/lib/progress.cjs +95 -0
- package/lib/progress.test.cjs +116 -0
- package/lib/researcher-contract.test.cjs +61 -0
- package/lib/roadmap-render.cjs +206 -0
- package/lib/roadmap-render.test.cjs +121 -0
- package/lib/roadmap.cjs +416 -0
- package/lib/roadmap.test.cjs +371 -0
- package/lib/runtime/_contract.test.cjs +61 -0
- package/lib/runtime/_readline.cjs +119 -0
- package/lib/runtime/_readline.test.cjs +126 -0
- package/lib/runtime/claude.cjs +48 -0
- package/lib/runtime/claude.test.cjs +101 -0
- package/lib/runtime/codex.cjs +35 -0
- package/lib/runtime/codex.test.cjs +114 -0
- package/lib/runtime/gemini.cjs +35 -0
- package/lib/runtime/gemini.test.cjs +109 -0
- package/lib/runtime/index.cjs +49 -0
- package/lib/runtime/index.test.cjs +181 -0
- package/lib/runtime/opencode.cjs +35 -0
- package/lib/runtime/opencode.test.cjs +124 -0
- package/lib/state.cjs +205 -0
- package/lib/state.test.cjs +264 -0
- package/lib/surface-audit.test.cjs +46 -0
- package/lib/tasks.cjs +327 -0
- package/lib/tasks.test.cjs +389 -0
- package/lib/template.cjs +66 -0
- package/lib/template.test.cjs +159 -0
- package/lib/undo.cjs +179 -0
- package/lib/undo.test.cjs +261 -0
- package/lib/verify.cjs +116 -0
- package/lib/verify.test.cjs +187 -0
- package/np-tools.cjs +303 -0
- package/package.json +39 -0
- package/templates/AI-SPEC.md +90 -0
- package/templates/CONTEXT.md +32 -0
- package/templates/PLAN.md +69 -0
- package/templates/PROJECT.md +60 -0
- package/templates/REQUIREMENTS.md +38 -0
- package/templates/SECURITY.md +61 -0
- package/templates/UI-SPEC.md +64 -0
- package/templates/VALIDATION.md +76 -0
- package/templates/claude/payload/README.md +11 -0
- package/templates/opencode/opencode.json +6 -0
- package/templates/opencode/payload/AGENTS.md +9 -0
- package/workflows/add-backlog.md +212 -0
- package/workflows/add-tests.md +69 -0
- package/workflows/add-todo.md +222 -0
- package/workflows/ai-integration-phase.md +230 -0
- package/workflows/autonomous.md +94 -0
- package/workflows/cleanup.md +325 -0
- package/workflows/code-review-fix.md +435 -0
- package/workflows/code-review.md +447 -0
- package/workflows/discuss-phase-assumptions.md +269 -0
- package/workflows/discuss-phase-power.md +139 -0
- package/workflows/discuss-phase.md +386 -0
- package/workflows/dispatch.md +9 -0
- package/workflows/doctor.md +10 -0
- package/workflows/eval-review.md +243 -0
- package/workflows/execute-phase.md +142 -0
- package/workflows/execute-plan.md +82 -0
- package/workflows/help.md +8 -0
- package/workflows/new-milestone.md +166 -0
- package/workflows/new-project.md +213 -0
- package/workflows/next.md +8 -0
- package/workflows/note.md +244 -0
- package/workflows/park.md +29 -0
- package/workflows/pause-work.md +34 -0
- package/workflows/plan-milestone-gaps.md +233 -0
- package/workflows/plan-phase.md +351 -0
- package/workflows/progress.md +8 -0
- package/workflows/queue.md +9 -0
- package/workflows/research-phase.md +327 -0
- package/workflows/reset-slice.md +39 -0
- package/workflows/resume-work.md +79 -0
- package/workflows/review.md +489 -0
- package/workflows/secure-phase.md +209 -0
- package/workflows/session-report.md +243 -0
- package/workflows/skip.md +29 -0
- package/workflows/state.md +7 -0
- package/workflows/stats.md +170 -0
- package/workflows/thread.md +214 -0
- package/workflows/triage.md +9 -0
- package/workflows/ui-phase.md +246 -0
- package/workflows/ui-review.md +222 -0
- package/workflows/undo-task.md +42 -0
- package/workflows/undo.md +55 -0
- package/workflows/unpark.md +29 -0
- package/workflows/validate-phase.md +231 -0
- package/workflows/verify-work.md +83 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const { askUserReadline } = require('./_readline.cjs');
|
|
2
|
+
|
|
3
|
+
async function askUser(spec) {
|
|
4
|
+
return askUserReadline({
|
|
5
|
+
type: spec && spec.type,
|
|
6
|
+
question: spec && spec.question,
|
|
7
|
+
options: spec && spec.options,
|
|
8
|
+
def: spec ? spec.default : undefined,
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
module.exports = {
|
|
13
|
+
name: 'opencode',
|
|
14
|
+
detectHints: {
|
|
15
|
+
env: ['OPENCODE', 'OPENCODE_VERSION'],
|
|
16
|
+
pathBinary: 'opencode',
|
|
17
|
+
diskMarkers: ['.opencode/', 'opencode.json'],
|
|
18
|
+
},
|
|
19
|
+
capabilities: {
|
|
20
|
+
askUserQuestion: false,
|
|
21
|
+
slashCommands: false,
|
|
22
|
+
agentsMd: 'AGENTS.md',
|
|
23
|
+
textMode: 'auto',
|
|
24
|
+
modelResolution: 'inherit',
|
|
25
|
+
},
|
|
26
|
+
paths: {
|
|
27
|
+
payload: '.opencode/nubos-pilot/',
|
|
28
|
+
config: 'opencode.json',
|
|
29
|
+
agentsMd: '.opencode/nubos-pilot/AGENTS.md',
|
|
30
|
+
},
|
|
31
|
+
runtimeNotice:
|
|
32
|
+
'> **Runtime-Hinweis:** Diese Datei (.opencode/nubos-pilot/AGENTS.md) wird von OpenCode konsumiert. '
|
|
33
|
+
+ 'Interaktive Prompts laufen über readline (stderr); Subagents erben das Modell vom Caller (`/model inherit`).',
|
|
34
|
+
askUser,
|
|
35
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const { _setReadlineImplForTests } = require('./_readline.cjs');
|
|
5
|
+
const oc = require('./opencode.cjs');
|
|
6
|
+
|
|
7
|
+
function captureStderr(fn) {
|
|
8
|
+
const chunks = [];
|
|
9
|
+
const orig = process.stderr.write.bind(process.stderr);
|
|
10
|
+
process.stderr.write = (chunk) => { chunks.push(chunk.toString()); return true; };
|
|
11
|
+
return Promise.resolve(fn()).then(
|
|
12
|
+
(val) => { process.stderr.write = orig; return { val, out: chunks.join('') }; },
|
|
13
|
+
(err) => { process.stderr.write = orig; throw err; },
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test('opencode-adapter: exports five-key contract', () => {
|
|
18
|
+
for (const k of ['name', 'detectHints', 'capabilities', 'paths', 'askUser']) {
|
|
19
|
+
assert.ok(k in oc, 'missing ' + k);
|
|
20
|
+
}
|
|
21
|
+
assert.equal(oc.name, 'opencode');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('opencode-adapter: capabilities match D-07 + RESEARCH refinement', () => {
|
|
25
|
+
const c = oc.capabilities;
|
|
26
|
+
assert.equal(c.askUserQuestion, false);
|
|
27
|
+
assert.equal(c.slashCommands, false);
|
|
28
|
+
assert.equal(c.agentsMd, 'AGENTS.md');
|
|
29
|
+
assert.equal(c.textMode, 'auto');
|
|
30
|
+
assert.equal(
|
|
31
|
+
c.modelResolution,
|
|
32
|
+
'inherit',
|
|
33
|
+
'RUN-02 RESEARCH: OpenCode inheritance is signaled by OMITTING model field; capability flag must read "inherit"',
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('opencode-adapter: paths.config is project-root opencode.json (NOT .opencode/config.json)', () => {
|
|
38
|
+
assert.equal(
|
|
39
|
+
oc.paths.config,
|
|
40
|
+
'opencode.json',
|
|
41
|
+
'RESEARCH refinement of D-13: OpenCode config lives at project root as opencode.json',
|
|
42
|
+
);
|
|
43
|
+
assert.notEqual(oc.paths.config, '.opencode/config.json');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('opencode-adapter: paths.payload and paths.agentsMd live under .opencode/nubos-pilot/ (8.1 D-02)', () => {
|
|
47
|
+
assert.equal(oc.paths.payload, '.opencode/nubos-pilot/');
|
|
48
|
+
assert.equal(oc.paths.agentsMd, '.opencode/nubos-pilot/AGENTS.md');
|
|
49
|
+
assert.notEqual(oc.paths.agentsMd, '.opencode/AGENTS.md');
|
|
50
|
+
assert.notEqual(oc.paths.payload, '.opencode/');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('opencode-adapter: readline select parses 1-based index', async () => {
|
|
54
|
+
_setReadlineImplForTests(async () => '2');
|
|
55
|
+
try {
|
|
56
|
+
const { val } = await captureStderr(() =>
|
|
57
|
+
oc.askUser({ type: 'select', question: 'P', options: ['A', 'B', 'C'] }),
|
|
58
|
+
);
|
|
59
|
+
assert.equal(val.value, 'B');
|
|
60
|
+
assert.equal(val.source, 'readline');
|
|
61
|
+
} finally {
|
|
62
|
+
_setReadlineImplForTests(null);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('opencode-adapter: readline multiselect parses comma-indices', async () => {
|
|
67
|
+
_setReadlineImplForTests(async () => '1,3');
|
|
68
|
+
try {
|
|
69
|
+
const { val } = await captureStderr(() =>
|
|
70
|
+
oc.askUser({ type: 'multiselect', question: 'P', options: ['A', 'B', 'C'] }),
|
|
71
|
+
);
|
|
72
|
+
assert.deepEqual(val.value, ['A', 'C']);
|
|
73
|
+
} finally {
|
|
74
|
+
_setReadlineImplForTests(null);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('opencode-adapter: input with injected line', async () => {
|
|
79
|
+
_setReadlineImplForTests(async () => 'hello');
|
|
80
|
+
try {
|
|
81
|
+
const { val } = await captureStderr(() =>
|
|
82
|
+
oc.askUser({ type: 'input', question: 'Q' }),
|
|
83
|
+
);
|
|
84
|
+
assert.equal(val.value, 'hello');
|
|
85
|
+
assert.equal(val.source, 'readline');
|
|
86
|
+
} finally {
|
|
87
|
+
_setReadlineImplForTests(null);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('opencode-adapter: confirm y/n', async () => {
|
|
92
|
+
_setReadlineImplForTests(async () => 'n');
|
|
93
|
+
try {
|
|
94
|
+
const { val } = await captureStderr(() =>
|
|
95
|
+
oc.askUser({ type: 'confirm', question: 'OK' }),
|
|
96
|
+
);
|
|
97
|
+
assert.equal(val.value, false);
|
|
98
|
+
} finally {
|
|
99
|
+
_setReadlineImplForTests(null);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('opencode-adapter: does not require install-layer modules (single responsibility)', () => {
|
|
104
|
+
const src = fs.readFileSync(require.resolve('./opencode.cjs'), 'utf-8');
|
|
105
|
+
assert.ok(
|
|
106
|
+
!/require\(['"]\.\.\/install\//.test(src),
|
|
107
|
+
'adapter must not reach into lib/install/ — install logic stays in install layer',
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('opencode-adapter: exports runtimeNotice compatible with agents-md SC-5 check', () => {
|
|
112
|
+
const notice = oc.runtimeNotice;
|
|
113
|
+
assert.equal(typeof notice, 'string');
|
|
114
|
+
assert.ok(notice.length > 0);
|
|
115
|
+
assert.match(notice, /readline|prompt/i, 'runtimeNotice must match /readline|prompt/i');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('opencode-adapter: runtimeNotice references .opencode/nubos-pilot/AGENTS.md (8.1 D-02)', () => {
|
|
119
|
+
assert.ok(oc.runtimeNotice.includes('.opencode/nubos-pilot/AGENTS.md'));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('opencode-adapter: runtimeNotice does not contain the forbidden joined Claude-tool literal (SC-5 guard)', () => {
|
|
123
|
+
assert.ok(!/Ask-User-Question/.test(oc.runtimeNotice));
|
|
124
|
+
});
|
package/lib/state.cjs
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const { withFileLock, atomicWriteFileSync, projectStateDir, NubosPilotError } = require('./core.cjs');
|
|
4
|
+
|
|
5
|
+
const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
6
|
+
const KV_RE = /^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/;
|
|
7
|
+
const CANONICAL_KEYS = [
|
|
8
|
+
'schema_version',
|
|
9
|
+
'milestone',
|
|
10
|
+
'milestone_name',
|
|
11
|
+
'current_phase',
|
|
12
|
+
'current_plan',
|
|
13
|
+
'current_task',
|
|
14
|
+
'last_updated',
|
|
15
|
+
'progress',
|
|
16
|
+
'session',
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const NESTED_KEYS = new Set(['progress', 'session']);
|
|
20
|
+
|
|
21
|
+
function _coerceScalar(v) {
|
|
22
|
+
if (v === 'null' || v === '') return null;
|
|
23
|
+
if (v === 'true') return true;
|
|
24
|
+
if (v === 'false') return false;
|
|
25
|
+
if (/^-?\d+$/.test(v)) return Number(v);
|
|
26
|
+
if (/^-?\d+\.\d+$/.test(v)) return Number(v);
|
|
27
|
+
if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) return v.slice(1, -1);
|
|
28
|
+
return v;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function _parseFlatOrNested(yamlText) {
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
const fm = {};
|
|
35
|
+
const lines = yamlText.split(/\r?\n/);
|
|
36
|
+
let currentNestedKey = null;
|
|
37
|
+
for (let i = 0; i < lines.length; i++) {
|
|
38
|
+
const line = lines[i];
|
|
39
|
+
if (line.trim() === '') continue;
|
|
40
|
+
const indent = (line.match(/^ */) || [''])[0].length;
|
|
41
|
+
const trimmed = line.trim();
|
|
42
|
+
|
|
43
|
+
if (indent === 0) {
|
|
44
|
+
const kv = trimmed.match(KV_RE);
|
|
45
|
+
if (!kv) continue;
|
|
46
|
+
const key = kv[1];
|
|
47
|
+
const rawVal = kv[2].trim();
|
|
48
|
+
if (NESTED_KEYS.has(key) && rawVal === '') {
|
|
49
|
+
|
|
50
|
+
fm[key] = {};
|
|
51
|
+
currentNestedKey = key;
|
|
52
|
+
} else {
|
|
53
|
+
fm[key] = _coerceScalar(rawVal);
|
|
54
|
+
currentNestedKey = null;
|
|
55
|
+
}
|
|
56
|
+
} else if (currentNestedKey) {
|
|
57
|
+
|
|
58
|
+
const kv = trimmed.match(KV_RE);
|
|
59
|
+
if (!kv) continue;
|
|
60
|
+
fm[currentNestedKey][kv[1]] = _coerceScalar(kv[2].trim());
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return fm;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function _defaultProgress() {
|
|
67
|
+
return {
|
|
68
|
+
total_phases: 0,
|
|
69
|
+
completed_phases: 0,
|
|
70
|
+
total_plans: 0,
|
|
71
|
+
completed_plans: 0,
|
|
72
|
+
percent: 0,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function _defaultSession(preservedLastActivity) {
|
|
77
|
+
return {
|
|
78
|
+
stopped_at: null,
|
|
79
|
+
resume_file: null,
|
|
80
|
+
last_activity: preservedLastActivity == null ? null : preservedLastActivity,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function migrateV1ToV2(fmV1) {
|
|
85
|
+
const out = {
|
|
86
|
+
schema_version: 2,
|
|
87
|
+
milestone: fmV1.milestone == null ? null : fmV1.milestone,
|
|
88
|
+
milestone_name: fmV1.milestone_name == null ? null : fmV1.milestone_name,
|
|
89
|
+
current_phase: fmV1.current_phase == null ? null : fmV1.current_phase,
|
|
90
|
+
current_plan: fmV1.current_plan == null ? null : fmV1.current_plan,
|
|
91
|
+
current_task: 'current_task' in fmV1 ? fmV1.current_task : null,
|
|
92
|
+
last_updated: fmV1.last_updated == null ? null : fmV1.last_updated,
|
|
93
|
+
progress: _isPlainObject(fmV1.progress) ? { ..._defaultProgress(), ...fmV1.progress } : _defaultProgress(),
|
|
94
|
+
session: _isPlainObject(fmV1.session)
|
|
95
|
+
? { ..._defaultSession(fmV1.last_updated), ...fmV1.session }
|
|
96
|
+
: _defaultSession(fmV1.last_updated),
|
|
97
|
+
};
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function _isPlainObject(v) {
|
|
102
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseState(raw) {
|
|
106
|
+
const m = raw.match(FM_RE);
|
|
107
|
+
if (!m) {
|
|
108
|
+
throw new NubosPilotError(
|
|
109
|
+
'schema-version-mismatch',
|
|
110
|
+
'STATE.md missing frontmatter',
|
|
111
|
+
{ raw: raw.slice(0, 200) },
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const fm = _parseFlatOrNested(m[1]);
|
|
115
|
+
|
|
116
|
+
if (fm.schema_version === 1) {
|
|
117
|
+
return { frontmatter: migrateV1ToV2(fm), body: m[2] };
|
|
118
|
+
}
|
|
119
|
+
if (fm.schema_version === 2) {
|
|
120
|
+
|
|
121
|
+
if (!_isPlainObject(fm.progress)) fm.progress = _defaultProgress();
|
|
122
|
+
if (!_isPlainObject(fm.session)) fm.session = _defaultSession(fm.last_updated);
|
|
123
|
+
return { frontmatter: fm, body: m[2] };
|
|
124
|
+
}
|
|
125
|
+
throw new NubosPilotError(
|
|
126
|
+
'schema-version-mismatch',
|
|
127
|
+
`STATE.md schema_version=${fm.schema_version}, supported: [1, 2]`,
|
|
128
|
+
{ got: fm.schema_version, supported: [1, 2] },
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function _formatScalar(v) {
|
|
133
|
+
if (v === null || v === undefined) return 'null';
|
|
134
|
+
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
|
135
|
+
const s = String(v);
|
|
136
|
+
if (s.includes(':') || /^\s/.test(s)) return `"${s}"`;
|
|
137
|
+
return s;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function serializeState({ frontmatter, body }) {
|
|
141
|
+
|
|
142
|
+
const fm = { ...frontmatter, schema_version: 2 };
|
|
143
|
+
|
|
144
|
+
const lines = ['---'];
|
|
145
|
+
const seen = new Set();
|
|
146
|
+
|
|
147
|
+
function emitKey(k) {
|
|
148
|
+
if (!(k in fm)) return;
|
|
149
|
+
const v = fm[k];
|
|
150
|
+
if (NESTED_KEYS.has(k) && _isPlainObject(v)) {
|
|
151
|
+
lines.push(`${k}:`);
|
|
152
|
+
for (const nk of Object.keys(v)) {
|
|
153
|
+
lines.push(` ${nk}: ${_formatScalar(v[nk])}`);
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
lines.push(`${k}: ${_formatScalar(v)}`);
|
|
157
|
+
}
|
|
158
|
+
seen.add(k);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
for (const k of CANONICAL_KEYS) emitKey(k);
|
|
162
|
+
for (const k of Object.keys(fm)) {
|
|
163
|
+
if (seen.has(k)) continue;
|
|
164
|
+
emitKey(k);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
lines.push('---', '');
|
|
168
|
+
const bodyClean = String(body == null ? '' : body).replace(/^\n+/, '');
|
|
169
|
+
return lines.join('\n') + bodyClean;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function statePath(cwd = process.cwd()) {
|
|
173
|
+
return path.join(projectStateDir(cwd), 'STATE.md');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function readState(cwd = process.cwd()) {
|
|
177
|
+
const p = statePath(cwd);
|
|
178
|
+
return parseState(fs.readFileSync(p, 'utf-8'));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function writeState(next, cwd = process.cwd()) {
|
|
182
|
+
const p = statePath(cwd);
|
|
183
|
+
return withFileLock(p, () => atomicWriteFileSync(p, serializeState(next)));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function mutateState(mutator, cwd = process.cwd()) {
|
|
187
|
+
const p = statePath(cwd);
|
|
188
|
+
return withFileLock(p, () => {
|
|
189
|
+
const current = parseState(fs.readFileSync(p, 'utf-8'));
|
|
190
|
+
const next = mutator(current);
|
|
191
|
+
atomicWriteFileSync(p, serializeState(next));
|
|
192
|
+
return next;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
module.exports = {
|
|
197
|
+
readState,
|
|
198
|
+
writeState,
|
|
199
|
+
mutateState,
|
|
200
|
+
statePath,
|
|
201
|
+
parseState,
|
|
202
|
+
serializeState,
|
|
203
|
+
migrateV1ToV2,
|
|
204
|
+
CANONICAL_KEYS,
|
|
205
|
+
};
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
const { test, beforeEach, afterEach } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const state = require('./state.cjs');
|
|
8
|
+
|
|
9
|
+
const CANONICAL_STATE_MD =
|
|
10
|
+
'---\n' +
|
|
11
|
+
'schema_version: 1\n' +
|
|
12
|
+
'current_phase: 2\n' +
|
|
13
|
+
'current_plan: 02-02\n' +
|
|
14
|
+
'last_updated: 2026-04-14T19:30:00Z\n' +
|
|
15
|
+
'---\n' +
|
|
16
|
+
'\n' +
|
|
17
|
+
'# nubos-pilot State\n' +
|
|
18
|
+
'\n' +
|
|
19
|
+
'(freeform prose body)\n';
|
|
20
|
+
|
|
21
|
+
const sandboxes = [];
|
|
22
|
+
|
|
23
|
+
function makeSandbox(initialStateMd) {
|
|
24
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'np-test-'));
|
|
25
|
+
fs.mkdirSync(path.join(root, '.nubos-pilot'));
|
|
26
|
+
if (initialStateMd !== null && initialStateMd !== undefined) {
|
|
27
|
+
fs.writeFileSync(path.join(root, '.nubos-pilot', 'STATE.md'), initialStateMd);
|
|
28
|
+
}
|
|
29
|
+
sandboxes.push(root);
|
|
30
|
+
return root;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
while (sandboxes.length) {
|
|
35
|
+
const p = sandboxes.pop();
|
|
36
|
+
try { fs.rmSync(p, { recursive: true, force: true }); } catch {}
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('R1: readState on seeded v1 STATE.md auto-migrates to v2 shape', () => {
|
|
41
|
+
const root = makeSandbox(CANONICAL_STATE_MD);
|
|
42
|
+
const s = state.readState(root);
|
|
43
|
+
assert.equal(s.frontmatter.schema_version, 2, 'v1 inputs are read back as v2');
|
|
44
|
+
assert.equal(s.frontmatter.current_phase, 2);
|
|
45
|
+
assert.equal(s.frontmatter.current_plan, '02-02');
|
|
46
|
+
assert.equal(s.frontmatter.current_task, null, 'v1 migration defaults current_task to null');
|
|
47
|
+
assert.equal(s.frontmatter.last_updated, '2026-04-14T19:30:00Z');
|
|
48
|
+
assert.ok(s.frontmatter.progress && typeof s.frontmatter.progress === 'object', 'progress block filled');
|
|
49
|
+
assert.ok(s.frontmatter.session && typeof s.frontmatter.session === 'object', 'session block filled');
|
|
50
|
+
assert.match(s.body, /nubos-pilot State/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('R2: writeState(next) + readState() round-trip preserves frontmatter (v2)', () => {
|
|
54
|
+
const root = makeSandbox(CANONICAL_STATE_MD);
|
|
55
|
+
const cur = state.readState(root);
|
|
56
|
+
const next = {
|
|
57
|
+
...cur,
|
|
58
|
+
frontmatter: { ...cur.frontmatter, current_phase: 42 },
|
|
59
|
+
};
|
|
60
|
+
state.writeState(next, root);
|
|
61
|
+
const back = state.readState(root);
|
|
62
|
+
assert.equal(back.frontmatter.current_phase, 42);
|
|
63
|
+
assert.equal(back.frontmatter.schema_version, 2);
|
|
64
|
+
assert.equal(back.frontmatter.current_plan, '02-02');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('R3: write then re-read reproduces identical state (stable round-trip)', () => {
|
|
68
|
+
const root = makeSandbox(CANONICAL_STATE_MD);
|
|
69
|
+
const first = state.readState(root);
|
|
70
|
+
state.writeState(first, root);
|
|
71
|
+
const afterWrite = fs.readFileSync(path.join(root, '.nubos-pilot', 'STATE.md'), 'utf-8');
|
|
72
|
+
const second = state.readState(root);
|
|
73
|
+
state.writeState(second, root);
|
|
74
|
+
const afterWrite2 = fs.readFileSync(path.join(root, '.nubos-pilot', 'STATE.md'), 'utf-8');
|
|
75
|
+
assert.equal(afterWrite, afterWrite2, 'serialize(parse(serialize(parse(x)))) is stable');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('R4: parseState on input without frontmatter throws schema-version-mismatch', () => {
|
|
79
|
+
const root = makeSandbox('no frontmatter here just body text\n');
|
|
80
|
+
assert.throws(
|
|
81
|
+
() => state.readState(root),
|
|
82
|
+
(err) => err && err.code === 'schema-version-mismatch',
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('R5: parseState on unsupported schema_version (e.g. 3) throws schema-version-mismatch with supported list', () => {
|
|
87
|
+
const bad =
|
|
88
|
+
'---\n' +
|
|
89
|
+
'schema_version: 3\n' +
|
|
90
|
+
'current_phase: null\n' +
|
|
91
|
+
'current_plan: null\n' +
|
|
92
|
+
'last_updated: 2026-04-14T00:00:00Z\n' +
|
|
93
|
+
'---\n\nbody\n';
|
|
94
|
+
const root = makeSandbox(bad);
|
|
95
|
+
assert.throws(
|
|
96
|
+
() => state.readState(root),
|
|
97
|
+
(err) => {
|
|
98
|
+
return err
|
|
99
|
+
&& err.code === 'schema-version-mismatch'
|
|
100
|
+
&& err.details
|
|
101
|
+
&& err.details.got === 3
|
|
102
|
+
&& Array.isArray(err.details.supported)
|
|
103
|
+
&& err.details.supported.includes(1)
|
|
104
|
+
&& err.details.supported.includes(2);
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('R5b: parseState accepts schema_version:2 natively (pass-through, no migration)', () => {
|
|
110
|
+
const v2 =
|
|
111
|
+
'---\n' +
|
|
112
|
+
'schema_version: 2\n' +
|
|
113
|
+
'milestone: null\n' +
|
|
114
|
+
'milestone_name: null\n' +
|
|
115
|
+
'current_phase: 3\n' +
|
|
116
|
+
'current_plan: 03-04\n' +
|
|
117
|
+
'current_task: null\n' +
|
|
118
|
+
'last_updated: "2026-04-15T00:00:00Z"\n' +
|
|
119
|
+
'progress:\n' +
|
|
120
|
+
' total_phases: 10\n' +
|
|
121
|
+
' completed_phases: 2\n' +
|
|
122
|
+
' total_plans: 12\n' +
|
|
123
|
+
' completed_plans: 6\n' +
|
|
124
|
+
' percent: 50\n' +
|
|
125
|
+
'session:\n' +
|
|
126
|
+
' stopped_at: null\n' +
|
|
127
|
+
' resume_file: null\n' +
|
|
128
|
+
' last_activity: "2026-04-15T00:00:00Z"\n' +
|
|
129
|
+
'---\n\nbody\n';
|
|
130
|
+
const root = makeSandbox(v2);
|
|
131
|
+
const s = state.readState(root);
|
|
132
|
+
assert.equal(s.frontmatter.schema_version, 2);
|
|
133
|
+
assert.equal(s.frontmatter.current_phase, 3);
|
|
134
|
+
assert.equal(s.frontmatter.current_plan, '03-04');
|
|
135
|
+
assert.equal(s.frontmatter.progress.total_phases, 10);
|
|
136
|
+
assert.equal(s.frontmatter.progress.percent, 50);
|
|
137
|
+
assert.equal(s.frontmatter.session.last_activity, '2026-04-15T00:00:00Z');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('R5c: nested progress/session serialize as block and round-trip losslessly', () => {
|
|
141
|
+
const v2 =
|
|
142
|
+
'---\n' +
|
|
143
|
+
'schema_version: 2\n' +
|
|
144
|
+
'current_phase: 4\n' +
|
|
145
|
+
'current_plan: 04-01\n' +
|
|
146
|
+
'current_task: 04-01-T03\n' +
|
|
147
|
+
'last_updated: "2026-04-15T00:00:00Z"\n' +
|
|
148
|
+
'progress:\n' +
|
|
149
|
+
' total_phases: 10\n' +
|
|
150
|
+
' completed_phases: 3\n' +
|
|
151
|
+
' total_plans: 13\n' +
|
|
152
|
+
' completed_plans: 10\n' +
|
|
153
|
+
' percent: 77\n' +
|
|
154
|
+
'session:\n' +
|
|
155
|
+
' stopped_at: "mid-plan"\n' +
|
|
156
|
+
' resume_file: .planning/phases/04-base/04-01-PLAN.md\n' +
|
|
157
|
+
' last_activity: "2026-04-15T00:00:00Z"\n' +
|
|
158
|
+
'---\n\nbody\n';
|
|
159
|
+
const root = makeSandbox(v2);
|
|
160
|
+
const first = state.readState(root);
|
|
161
|
+
state.writeState(first, root);
|
|
162
|
+
const after = fs.readFileSync(path.join(root, '.nubos-pilot', 'STATE.md'), 'utf-8');
|
|
163
|
+
|
|
164
|
+
assert.match(after, /\nprogress:\n total_phases: 10\n/);
|
|
165
|
+
assert.match(after, /\nsession:\n stopped_at: /);
|
|
166
|
+
const back = state.readState(root);
|
|
167
|
+
assert.equal(back.frontmatter.progress.completed_plans, 10);
|
|
168
|
+
assert.equal(back.frontmatter.progress.percent, 77);
|
|
169
|
+
assert.equal(back.frontmatter.session.resume_file, '.planning/phases/04-base/04-01-PLAN.md');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('R6: frontmatter scalar types — number / string / null / quoted-string (v1 migrates to v2)', () => {
|
|
173
|
+
const raw =
|
|
174
|
+
'---\n' +
|
|
175
|
+
'schema_version: 1\n' +
|
|
176
|
+
'current_phase: 7\n' +
|
|
177
|
+
'current_plan: null\n' +
|
|
178
|
+
'last_updated: "2026-04-14T00:00:00Z"\n' +
|
|
179
|
+
'---\n\nbody\n';
|
|
180
|
+
const root = makeSandbox(raw);
|
|
181
|
+
const s = state.readState(root);
|
|
182
|
+
assert.strictEqual(s.frontmatter.schema_version, 2);
|
|
183
|
+
assert.strictEqual(s.frontmatter.current_phase, 7);
|
|
184
|
+
assert.strictEqual(s.frontmatter.current_plan, null);
|
|
185
|
+
assert.strictEqual(s.frontmatter.last_updated, '2026-04-14T00:00:00Z');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('R7: CANONICAL_KEYS exports the full Phase-4 v2 key list', () => {
|
|
189
|
+
for (const k of ['schema_version', 'milestone', 'milestone_name', 'current_phase', 'current_plan', 'current_task', 'last_updated', 'progress', 'session']) {
|
|
190
|
+
assert.ok(state.CANONICAL_KEYS.includes(k), `missing ${k}`);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('PR1: statePath(sandboxRoot) equals <root>/.nubos-pilot/STATE.md', () => {
|
|
195
|
+
const root = makeSandbox(CANONICAL_STATE_MD);
|
|
196
|
+
const p = state.statePath(root);
|
|
197
|
+
assert.equal(p, path.join(root, '.nubos-pilot', 'STATE.md'));
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('PR2: readState with no .nubos-pilot ancestor surfaces not-in-project error', () => {
|
|
201
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'np-noproj-'));
|
|
202
|
+
sandboxes.push(root);
|
|
203
|
+
assert.throws(
|
|
204
|
+
() => state.readState(root),
|
|
205
|
+
(err) => err && err.code === 'not-in-project',
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('C1: two concurrent mutateState calls — final STATE is one of the two writes, atomic', async () => {
|
|
210
|
+
const root = makeSandbox(CANONICAL_STATE_MD);
|
|
211
|
+
let counter = 0;
|
|
212
|
+
const mutatorA = (s) => {
|
|
213
|
+
counter++;
|
|
214
|
+
return { ...s, frontmatter: { ...s.frontmatter, current_phase: 10, last_updated: '2030-01-01T00:00:00Z' } };
|
|
215
|
+
};
|
|
216
|
+
const mutatorB = (s) => {
|
|
217
|
+
counter++;
|
|
218
|
+
return { ...s, frontmatter: { ...s.frontmatter, current_phase: 20, last_updated: '2030-02-02T00:00:00Z' } };
|
|
219
|
+
};
|
|
220
|
+
await Promise.all([
|
|
221
|
+
Promise.resolve().then(() => state.mutateState(mutatorA, root)),
|
|
222
|
+
Promise.resolve().then(() => state.mutateState(mutatorB, root)),
|
|
223
|
+
]);
|
|
224
|
+
assert.equal(counter, 2, 'both mutators ran exactly once');
|
|
225
|
+
const final = state.readState(root);
|
|
226
|
+
assert.ok(
|
|
227
|
+
final.frontmatter.current_phase === 10 || final.frontmatter.current_phase === 20,
|
|
228
|
+
'final phase is one of the two writes (not mixed)',
|
|
229
|
+
);
|
|
230
|
+
if (final.frontmatter.current_phase === 10) {
|
|
231
|
+
assert.equal(final.frontmatter.last_updated, '2030-01-01T00:00:00Z', 'timestamp matches chosen phase (A)');
|
|
232
|
+
} else {
|
|
233
|
+
assert.equal(final.frontmatter.last_updated, '2030-02-02T00:00:00Z', 'timestamp matches chosen phase (B)');
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('C2: 10-iteration stress — every iteration passes C1 assertions', async () => {
|
|
238
|
+
for (let i = 0; i < 10; i++) {
|
|
239
|
+
const root = makeSandbox(CANONICAL_STATE_MD);
|
|
240
|
+
let counter = 0;
|
|
241
|
+
const a = (s) => { counter++; return { ...s, frontmatter: { ...s.frontmatter, current_phase: 10, last_updated: '2030-01-01T00:00:00Z' } }; };
|
|
242
|
+
const b = (s) => { counter++; return { ...s, frontmatter: { ...s.frontmatter, current_phase: 20, last_updated: '2030-02-02T00:00:00Z' } }; };
|
|
243
|
+
await Promise.all([
|
|
244
|
+
Promise.resolve().then(() => state.mutateState(a, root)),
|
|
245
|
+
Promise.resolve().then(() => state.mutateState(b, root)),
|
|
246
|
+
]);
|
|
247
|
+
assert.equal(counter, 2, `iteration ${i}: both mutators ran`);
|
|
248
|
+
const f = state.readState(root);
|
|
249
|
+
assert.ok(f.frontmatter.current_phase === 10 || f.frontmatter.current_phase === 20, `iteration ${i}: clean one-of-two`);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('C3: mutateState releases lock when mutator throws (subsequent writeState succeeds)', () => {
|
|
254
|
+
const root = makeSandbox(CANONICAL_STATE_MD);
|
|
255
|
+
const throwing = () => { throw new Error('boom'); };
|
|
256
|
+
assert.throws(() => state.mutateState(throwing, root), /boom/);
|
|
257
|
+
const start = Date.now();
|
|
258
|
+
const next = { ...state.readState(root), frontmatter: { schema_version: 1, current_phase: 99, current_plan: 'x', last_updated: '2030-03-03T00:00:00Z' } };
|
|
259
|
+
state.writeState(next, root);
|
|
260
|
+
const elapsed = Date.now() - start;
|
|
261
|
+
assert.ok(elapsed < 500, `writeState completed in ${elapsed}ms (lock was released)`);
|
|
262
|
+
const back = state.readState(root);
|
|
263
|
+
assert.equal(back.frontmatter.current_phase, 99);
|
|
264
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const LIB_DIR = path.resolve(__dirname);
|
|
7
|
+
const BAN_RE = /require\s*\(\s*['"](node:)?child_process['"]\s*\)|from\s+['"](node:)?child_process['"]/;
|
|
8
|
+
const SUBPROCESS_IDENTIFIERS = /\b(execSync|execFileSync|spawnSync|spawn|exec|execFile|fork)\s*\(/;
|
|
9
|
+
|
|
10
|
+
const SUBPROCESS_WHITELIST = new Set(['git.cjs']);
|
|
11
|
+
|
|
12
|
+
function libFiles() {
|
|
13
|
+
return fs
|
|
14
|
+
.readdirSync(LIB_DIR)
|
|
15
|
+
.filter(
|
|
16
|
+
(f) =>
|
|
17
|
+
f.endsWith('.cjs') &&
|
|
18
|
+
!f.endsWith('.test.cjs') &&
|
|
19
|
+
f !== 'surface-audit.test.cjs' &&
|
|
20
|
+
!SUBPROCESS_WHITELIST.has(f)
|
|
21
|
+
)
|
|
22
|
+
.map((f) => path.join(LIB_DIR, f));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('SC 4 / ADR-0001: no lib/*.cjs imports child_process', () => {
|
|
26
|
+
const files = libFiles();
|
|
27
|
+
assert.ok(files.length >= 2, `expected >=2 lib/*.cjs files, scanned ${files.length}`);
|
|
28
|
+
for (const file of files) {
|
|
29
|
+
const src = fs.readFileSync(file, 'utf-8');
|
|
30
|
+
assert.ok(
|
|
31
|
+
!BAN_RE.test(src),
|
|
32
|
+
`SC 4 violation in ${path.relative(LIB_DIR, file)}: lib/ must NOT import child_process (see ADR-0001 / CONTEXT D-14).`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('SC 4 (defense-in-depth): no subprocess identifiers in lib/*.cjs', () => {
|
|
38
|
+
const files = libFiles();
|
|
39
|
+
for (const file of files) {
|
|
40
|
+
const src = fs.readFileSync(file, 'utf-8');
|
|
41
|
+
assert.ok(
|
|
42
|
+
!SUBPROCESS_IDENTIFIERS.test(src),
|
|
43
|
+
`SC 4 violation in ${path.relative(LIB_DIR, file)}: lib/ must not call subprocess APIs (see ADR-0001 / CONTEXT D-14).`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
});
|