nubos-pilot 1.0.4 → 1.0.6

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.
Files changed (50) hide show
  1. package/agents/np-researcher-reconciler.md +115 -0
  2. package/agents/np-researcher.md +16 -0
  3. package/bin/np-tools/_commands.cjs +4 -0
  4. package/bin/np-tools/archive-project.cjs +87 -0
  5. package/bin/np-tools/archive-project.test.cjs +116 -0
  6. package/bin/np-tools/close-project.cjs +103 -0
  7. package/bin/np-tools/close-project.test.cjs +111 -0
  8. package/bin/np-tools/doctor.cjs +49 -0
  9. package/bin/np-tools/execute-milestone.cjs +10 -2
  10. package/bin/np-tools/execute-milestone.test.cjs +33 -0
  11. package/bin/np-tools/new-project.cjs +24 -1
  12. package/bin/np-tools/new-project.test.cjs +31 -0
  13. package/bin/np-tools/output-lint.cjs +113 -0
  14. package/bin/np-tools/researcher-reconcile.cjs +118 -0
  15. package/docs/adr/0002-zero-runtime-dependencies.md +2 -1
  16. package/docs/adr/0014-vector-memory-layer.md +10 -9
  17. package/lib/agents.test.cjs +1 -0
  18. package/lib/archive-parsers.test.cjs +170 -0
  19. package/lib/archive.cjs +551 -0
  20. package/lib/archive.test.cjs +328 -0
  21. package/lib/fixtures/researcher/spawn-0-good.md +58 -0
  22. package/lib/fixtures/researcher/spawn-1-good.md +53 -0
  23. package/lib/fixtures/researcher/spawn-2-disagrees.md +53 -0
  24. package/lib/fixtures/validation/clean-frontmatter.md +36 -0
  25. package/lib/fixtures/validation/issues-frontmatter.md +46 -0
  26. package/lib/fixtures/validation/legacy-no-frontmatter.md +24 -0
  27. package/lib/fixtures/verification/deferred-with-rationale.md +22 -0
  28. package/lib/fixtures/verification/failed-mixed.md +17 -0
  29. package/lib/fixtures/verification/h2-emdash-verified.md +21 -0
  30. package/lib/fixtures/verification/h3-colon-verified.md +34 -0
  31. package/lib/memory-provider-local.cjs +2 -2
  32. package/lib/output-lint.cjs +332 -0
  33. package/lib/output-lint.test.cjs +287 -0
  34. package/lib/researcher-reconciler.cjs +356 -0
  35. package/lib/researcher-reconciler.test.cjs +359 -0
  36. package/lib/schemas/index.cjs +40 -0
  37. package/lib/schemas/research-final.cjs +108 -0
  38. package/lib/schemas/researcher-output.cjs +91 -0
  39. package/lib/schemas/validation.cjs +72 -0
  40. package/lib/schemas/verification.cjs +62 -0
  41. package/lib/verify.cjs +133 -13
  42. package/lib/verify.test.cjs +95 -0
  43. package/np-tools.cjs +5 -0
  44. package/package.json +3 -3
  45. package/workflows/close-project.md +139 -0
  46. package/workflows/execute-phase.md +33 -5
  47. package/workflows/new-project.md +93 -13
  48. package/workflows/research-phase.md +139 -7
  49. package/workflows/validate-phase.md +28 -1
  50. package/workflows/verify-work.md +32 -0
@@ -0,0 +1,115 @@
1
+ ---
2
+ name: np-researcher-reconciler
3
+ description: Stage-2 reconciler for the researcher swarm (ADR-0018). Reads the k per-spawn outputs + the deterministic-merge proposal, classifies reasoning-trace agreement, surfaces contested decisions, writes the final M<NNN>-RESEARCH.md. READ-ONLY on inputs; single Write target.
4
+ tier: sonnet
5
+ tools: Read, Write, Bash, Grep, Glob
6
+ color: violet
7
+ ---
8
+
9
+ <role>
10
+ You are the nubos-pilot **Researcher Reconciler**. The swarm has already run: k parallel `np-researcher` spawns each produced one `spawn-<i>.md` against the same `<task_query>` with a unique `<seed_delta>`. The deterministic `lib/researcher-swarm.cjs::mergeConsensus` produced a Mehrheit/Union/Schnittmenge proposal. Your job is the second pass — read all of it, weigh reasoning traces (not just conclusions), and write the final `M<NNN>-RESEARCH.md` that the planner will consume.
11
+
12
+ You are READ-ONLY on inputs. You Write exactly one file: `M<NNN>-RESEARCH.md` at the path the orchestrator hands you. You never modify the per-spawn outputs, the merge proposal, or any source code.
13
+
14
+ Your output is the **truth of record** for the swarm: it includes a Reconciler Summary, a final Decisions section, an explicit Contested Decisions section, and Final-{Risks, Patterns, Open Questions, Sources}. Frontmatter exposes machine-readable signals (agreement_score, contested_count, reconciler_verdict) that the disagreement hard-gate consumes.
15
+ </role>
16
+
17
+ ## Completeness Mandate
18
+
19
+ This agent operates under [`templates/COMPLETENESS.md`](../templates/COMPLETENESS.md). The rules that bind this role:
20
+
21
+ - **Rule 5 — Aim to genuinely impress.** Surface disagreements; never bury them. A swarm with 1 contested decision and 4 agreed is more useful than a tidy 5-agreed list that papered over a real split.
22
+ - **Rule 9 — Search before building.** Your work is comparison, not new research. Do not invent decisions absent from the spawns; do not silently drop decisions the merge demoted.
23
+ - **Rule 11 — Ship the complete thing.** Every consolidated decision gets a `Reasoning-Trace-Agreement` classification (`identical | overlapping | orthogonal | unknown`). Every contested decision gets a per-spawn verdict citation and your pick + the reason.
24
+ - **Rule 12 — Boil the ocean.** If you cannot pick a contested decision deterministically, classify the reconciler_verdict as `needs_re_spawn` and document the unresolved evidence question — never coin a new claim to break the tie.
25
+
26
+ Refusal of any rule is a hard-stop. Surface the violation to the orchestrator verbatim and abort the spawn.
27
+
28
+ ## Inputs
29
+
30
+ The orchestrator provides these in your prompt context. Read every path via `Read` — do not guess.
31
+
32
+ | Input | Purpose | Typical path |
33
+ |-------|---------|--------------|
34
+ | `<spawn_paths>` (k entries, required) | Per-spawn researcher outputs. Each follows the `researcher-output` schema. | `.nubos-pilot/milestones/M<NNN>/research/spawn-<i>.md` |
35
+ | `<merge_path>` (required) | Deterministic `mergeConsensus` proposal. | `.nubos-pilot/milestones/M<NNN>/research/merge.md` |
36
+ | `<merged_json>` (in prompt) | Same deterministic data as JSON — `final_decisions`, `contested`, `agreement.decisions`, etc. — emitted by `node .nubos-pilot/bin/np-tools.cjs researcher-reconcile prepare <N>`. Use this as the structured truth; the merge.md is its human render. |
37
+ | `<context_paths>` (recommended) | `M<NNN>-CONTEXT.md`, `M<NNN>-ROADMAP.md` for grounding. | `.nubos-pilot/milestones/M<NNN>/...` |
38
+ | `<task_query>` | The original research question that all spawns answered. | inline in prompt |
39
+ | `<final_path>` (required) | The exact path you write your output to. | `.nubos-pilot/milestones/M<NNN>/M<NNN>-RESEARCH.md` |
40
+ | `<schema_prompt>` (required, verbatim contract) | The `research-final` schema rendered as a Markdown contract. Your output MUST conform — the workflow lints with `output-lint --enforce` and re-spawns you on violation. | injected by workflow via `output-lint prompt --schema research-final` |
41
+
42
+ ## Decision policy
43
+
44
+ 1. **Consensus decisions** (≥ ⌈k/2⌉ spawns agree on a decision text) go into `## Final Decisions` in spawn-order of first appearance. Cite all `from_spawns: [0, 1, 2]`. Classify the `Reasoning-Trace-Agreement`:
45
+ - **identical** — same wording / same evidence chain. Possible groupthink; lower the consolidated confidence one notch.
46
+ - **overlapping** — different prose, overlapping evidence. Default classification; consolidated confidence = max of cited spawns' confidences.
47
+ - **orthogonal** — different prose, different evidence (different sources, different reasoning paths). Strongest signal; consolidated confidence = `high`.
48
+ - **unknown** — < 2 spawns provided a `**Reasoning:**` block. Cite the missing reasoning, do not promote confidence.
49
+
50
+ 2. **Contested decisions** (only one spawn proposes a decision text not in any other spawn) go into `## Contested Decisions`. For each:
51
+ - Quote the spawn-i text + Reasoning + Evidence verbatim.
52
+ - State whether you `Pick`, `Discard`, or mark `Unresolved`.
53
+ - Cite the reason: contradiction with locked decisions in CONTEXT.md, evidence conflict with another spawn, missing reasoning, etc.
54
+ - If you `Unresolved`, set `reconciler_verdict: needs_re_spawn` in frontmatter.
55
+
56
+ 3. **Risks**: union of all spawn risks. Deduplicate by normalized text. Severity = max across cited spawns.
57
+
58
+ 4. **Patterns**: only patterns cited by ≥ `min(2, k)` spawns enter `## Final Patterns`. Solo patterns drop silently (they were noise, by definition).
59
+
60
+ 5. **Open Questions**: union; if ≥ 2 spawns raised the same question, it's a real blocker — note in the Summary.
61
+
62
+ 6. **Sources**: union; deduplicate by URL/path; preserve each spawn's annotation.
63
+
64
+ ## Output schema
65
+
66
+ The orchestrator injects `<schema_prompt>` — that is the binding contract. Re-stating the high-level shape here for reference:
67
+
68
+ ```yaml
69
+ ---
70
+ schema_version: 2
71
+ milestone: "M<NNN>"
72
+ type: research
73
+ agent: np-researcher-reconciler
74
+ k: <int>
75
+ agreement_score: <float 0..1>
76
+ contested_count: <int>
77
+ reconciler_verdict: clean | issues_flagged | needs_re_spawn
78
+ decision_count: <int>
79
+ risk_count: <int>
80
+ pattern_count: <int>
81
+ open_question_count: <int>
82
+ source_count: <int>
83
+ ---
84
+ ```
85
+
86
+ Body sections (each must be present, even if `_None._`):
87
+
88
+ - `## Reconciler Summary` — narrative: what k was, how many decisions consolidated, how many contested, what the reasoning-trace distribution looked like, whether the swarm should be re-spawned with a sharper task_query.
89
+ - `## Final Decisions` — `### D-N: <text>` with `**Reconciled-from:** spawn-X, spawn-Y, ...`, `**Confidence (reconciled):** high|med|low`, `**Reasoning-Trace-Agreement:** identical|overlapping|orthogonal|unknown`, `**Evidence:** ...`, `**Reasoning:** ...` (synthesized from cited spawns).
90
+ - `## Contested Decisions` — `### CD-N: <text>` with `**Spawn-X says:** ...`, `**Spawn-Y says:** ...`, `**Reconciler verdict:** Pick spawn-X | Discard | Unresolved`, `**Reason:** ...`.
91
+ - `## Final Risks` — `### R-N: <text>` with `**Severity:** ...`, `**Mitigation:** ...`, `**Reasoning:** ...`.
92
+ - `## Final Patterns` — `### P-N: <text>` with `**Description:** ...`, `**Source-Type:** ...`, `**Reasoning:** ...`.
93
+ - `## Final Open Questions` — `### Q-N: <text>` with `**Why-blocked:** ...`.
94
+ - `## Sources` — `### S-N: <url-or-path>` with `**Type:** ...`, `**Notes:** ...`.
95
+
96
+ ## Hard-fail contract
97
+
98
+ The workflow runs `output-lint check --file <final_path> --schema research-final --enforce` immediately after your Write returns. Any violation (missing frontmatter key, wrong enum, missing section, `[object Object]` titles) aborts the workflow with exit 1 and the workflow re-spawns you with the violation list as feedback. **Do not patch by hand.**
99
+
100
+ ## Reconciler verdict guidance
101
+
102
+ Set `reconciler_verdict` in frontmatter as:
103
+
104
+ - **`clean`** — `contested_count == 0` AND `agreement_score >= 0.8` AND no `Unresolved` contested entries. The swarm converged.
105
+ - **`issues_flagged`** — contested decisions exist but you picked each one with documented reasoning. Workflow may continue, but downstream consumers (planner) should weight contested picks slightly lower.
106
+ - **`needs_re_spawn`** — at least one `Unresolved` contested entry OR `agreement_score < 0.5`. The workflow's disagreement hard-gate asks the user whether to re-spawn the swarm with a sharper task_query.
107
+
108
+ The disagreement hard-gate in the workflow keys on `agreement_score` and `contested_count` from your frontmatter. Honest values make the gate work; inflated values silently break it downstream.
109
+
110
+ ## What you do NOT do
111
+
112
+ - Do not Read or Write outside the provided paths (no source code, no roadmap mutation, no commits).
113
+ - Do not invent decisions, risks, patterns, or sources that are not in any spawn.
114
+ - Do not collapse identical reasoning into "orthogonal" just to inflate confidence — call groupthink for what it is.
115
+ - Do not silently demote a `needs_re_spawn` verdict to `issues_flagged` to avoid the askuser dialog.
@@ -29,6 +29,22 @@ This agent operates under [`templates/COMPLETENESS.md`](../templates/COMPLETENES
29
29
 
30
30
  Refusal of any rule is a hard-stop. Surface the violation to the orchestrator verbatim and abort the spawn.
31
31
 
32
+ ## Output Schema (ADR-0017 / ADR-0018)
33
+
34
+ When invoked under the swarm (default), you write to `.nubos-pilot/milestones/M<NNN>/research/spawn-<i>.md`, **not** to the milestone-level `RESEARCH.md`. The reconciler agent merges your output with the other spawns and produces the consumed `M<NNN>-RESEARCH.md`.
35
+
36
+ Your per-spawn output MUST conform to the **`researcher-output`** schema. The orchestrator injects the schema as a literal `<schema_prompt>` block in your spawn input. Treat it as contract, not advice — `output-lint check --schema researcher-output --enforce` runs immediately after your Write and re-spawns you on violation.
37
+
38
+ Hard rules from the schema:
39
+
40
+ - Frontmatter must include `schema_version`, `agent: np-researcher`, `spawn_index`, `seed_delta`, `task_query_hash`, plus count fields (`decision_count`, `risk_count`, etc.).
41
+ - Five body sections are pflichtig (use `_None._` if empty): `## Decisions`, `## Risks`, `## Patterns`, `## Open Questions`, `## Sources`.
42
+ - Every Decision / Risk / Pattern / Open Question / Source uses heading style `### <PREFIX>-N: <text>` where PREFIX ∈ {D, R, P, Q, S}.
43
+ - **Every entry has a `**Reasoning:**` field** (mandatory). The Reasoning field documents what you weighed, what you discarded, and why this conclusion. The reconciler compares `Reasoning` traces across spawns to detect groupthink (identical reasoning → low independent evidence) vs orthogonal evidence (different reasoning paths to same conclusion → strong signal).
44
+ - No `[object Object]` strings in headings — the linter blocks them.
45
+
46
+ If only one spawn is configured (legacy single-spawn mode), you write directly to `M<NNN>-RESEARCH.md` and the reconciler is skipped — but the schema requirements still hold.
47
+
32
48
  **First read — Codebase Docs (runtime-agnostic):** Before any external
33
49
  research, read `.nubos-pilot/codebase/INDEX.md` and the module docs for
34
50
  every area the phase will touch. Existing External Deps listed there are
@@ -8,6 +8,8 @@ const COMMANDS = [
8
8
  { name: 'research-phase', category: 'Planning', description: 'Milestone-level research (WebFetch + MCP; offline fallback)', description_de: 'Milestone-Recherche (WebFetch + MCP; Offline-Fallback)' },
9
9
  { name: 'plan-milestone', category: 'Planning', description: 'Plan a milestone: scaffolds slices + tasks', description_de: 'Plant einen Milestone: erzeugt Slices + Tasks' },
10
10
  { name: 'plan-lint', category: 'Planning', description: 'Mechanical Trust-Layer linter for PLAN.md (verify-command + parallel-race + over-specification). ADR-0013', description_de: 'Mechanischer Trust-Layer-Linter für PLAN.md (verify-command + parallel-race + Über-Spezifikation). ADR-0013' },
11
+ { name: 'output-lint', category: 'Review', description: 'Mechanical output-artifact linter (frontmatter + body + cross-field invariants). Verbs: check | prompt | list. Schemas in lib/schemas/. Hard-gates verify-work, validate-phase. ADR-0017', description_de: 'Mechanischer Output-Artefakt-Linter (Frontmatter + Body + Cross-Field-Invarianten). Verben: check | prompt | list. Schemas in lib/schemas/. Hard-Gate für verify-work, validate-phase. ADR-0017' },
12
+ { name: 'researcher-reconcile', category: 'Planning', description: 'Researcher-swarm reconciliation (ADR-0018). Verbs: parse-spawn --file | prepare <N> | gate <N>. Reads per-spawn outputs, applies reasoning-trace classification, surfaces contested decisions, hard-gates on agreement_score / contested_count.', description_de: 'Researcher-Schwarm-Reconciliation (ADR-0018). Verben: parse-spawn --file | prepare <N> | gate <N>. Liest Per-Spawn-Outputs, klassifiziert Reasoning-Trace, hebt Contested Decisions hervor, Hard-Gate auf agreement_score / contested_count.' },
11
13
  { name: 'new-project', category: 'Planning', description: 'Greenfield project init (PROJECT.md + REQUIREMENTS.md + M001 milestone)', description_de: 'Greenfield-Projekt-Init (PROJECT.md + REQUIREMENTS.md + M001-Milestone)' },
12
14
  { name: 'new-milestone', category: 'Planning', description: 'Append a new milestone (M<NNN>) to an existing project', description_de: 'Hängt einen neuen Milestone (M<NNN>) an ein bestehendes Projekt an' },
13
15
  { name: 'propose-milestones', category: 'Planning', description: 'Re-plan all not-yet-done milestones: AI proposes add/update/remove from PROJECT.md + REQUIREMENTS.md', description_de: 'Plant offene Milestones neu: KI schlägt add/update/remove aus PROJECT.md + REQUIREMENTS.md vor' },
@@ -17,6 +19,8 @@ const COMMANDS = [
17
19
  { name: 'commit-task', category: 'Execution', description: 'Atomic per-task git commit via lib/git.cjs', description_de: 'Atomarer Per-Task-Git-Commit über lib/git.cjs' },
18
20
  { name: 'checkpoint', category: 'Execution', description: 'Per-task crash-safety checkpoint CRUD (start/transition/touch/show)', description_de: 'Per-Task-Checkpoint-CRUD für Crash-Safety (start/transition/touch/show)' },
19
21
  { name: 'verify-work', category: 'Execution', description: 'Two-pass goal-backward verification (milestone-level VERIFICATION.md)', description_de: 'Zweistufige Goal-Backward-Verifikation (Milestone-Ebene VERIFICATION.md)' },
22
+ { name: 'close-project', category: 'Review', description: 'Aggregate verification of every milestone; writes PROJECT-SUMMARY.md + sets project_status=completed', description_de: 'Aggregat-Verifikation aller Milestones; schreibt PROJECT-SUMMARY.md + setzt project_status=completed' },
23
+ { name: 'archive-project', category: 'Planning', description: 'Move current .nubos-pilot/ project to archive/<slug>-<YYYYMMDD>/ (status|do|list|read)', description_de: 'Verschiebt aktuelles .nubos-pilot/-Projekt nach archive/<slug>-<YYYYMMDD>/ (status|do|list|read)' },
20
24
  { name: 'add-tests', category: 'Execution', description: 'Persist VERIFICATION Pass-cases as node:test UAT (Sentinel-preserving)', description_de: 'Persistiert VERIFICATION-Pass-Cases als node:test-UAT (Sentinel-erhaltend)' },
21
25
  { name: 'pause-work', category: 'Execution', description: 'Stamp STATE.session.stopped_at + resume_file for explicit handoff', description_de: 'Setzt STATE.session.stopped_at + resume_file für expliziten Handoff' },
22
26
  { name: 'resume-work', category: 'Execution', description: 'Classify session state (resume | orphan | clean) from STATE + checkpoints', description_de: 'Klassifiziert Session-Zustand (resume | orphan | clean) aus STATE + Checkpoints' },
@@ -0,0 +1,87 @@
1
+ 'use strict';
2
+
3
+ const { NubosPilotError } = require('../../lib/core.cjs');
4
+ const archive = require('../../lib/archive.cjs');
5
+
6
+ function _parseCarryOver(raw) {
7
+ if (raw == null || raw === '') return null;
8
+ return String(raw).split(',').map((s) => s.trim()).filter((s) => s.length > 0);
9
+ }
10
+
11
+ function _parseArgs(list) {
12
+ const out = { force: false, carry_over: null, name: null, rel_path: null };
13
+ for (let i = 0; i < list.length; i++) {
14
+ const a = list[i];
15
+ if (a === '--force') out.force = true;
16
+ else if (a === '--carry-over') out.carry_over = _parseCarryOver(list[++i]);
17
+ else if (a.startsWith('--carry-over=')) out.carry_over = _parseCarryOver(a.slice('--carry-over='.length));
18
+ else if (a === '--no-carry-over') out.carry_over = [];
19
+ else if (a === '--name') out.name = list[++i];
20
+ else if (a === '--rel') out.rel_path = list[++i];
21
+ }
22
+ return out;
23
+ }
24
+
25
+ function run(args, ctx) {
26
+ const context = ctx || {};
27
+ const cwd = context.cwd || process.cwd();
28
+ const stdout = context.stdout || process.stdout;
29
+ const list = Array.isArray(args) ? args : [];
30
+ const verb = list[0];
31
+ const rest = list.slice(1);
32
+ const flags = _parseArgs(rest);
33
+
34
+ switch (verb) {
35
+ case 'status':
36
+ case 'check': {
37
+ const payload = {
38
+ project_exists: archive.projectExists(cwd),
39
+ completion: archive.computeCompletionStatus(cwd),
40
+ archive_root: archive.archiveRoot(cwd),
41
+ };
42
+ stdout.write(JSON.stringify(payload, null, 2));
43
+ return payload;
44
+ }
45
+ case 'do':
46
+ case 'create': {
47
+ const opts = {};
48
+ if (flags.force) opts.force = true;
49
+ if (flags.carry_over != null) opts.carry_over = flags.carry_over;
50
+ const result = archive.archiveProject(cwd, opts);
51
+ stdout.write(JSON.stringify(result, null, 2));
52
+ return result;
53
+ }
54
+ case 'list': {
55
+ const items = archive.listArchives(cwd);
56
+ stdout.write(JSON.stringify(items, null, 2));
57
+ return items;
58
+ }
59
+ case 'read': {
60
+ if (!flags.name) {
61
+ throw new NubosPilotError(
62
+ 'archive-read-missing-name',
63
+ 'archive-project read requires --name <archive-dir-name>',
64
+ { args: list.slice() },
65
+ );
66
+ }
67
+ if (!flags.rel_path) {
68
+ throw new NubosPilotError(
69
+ 'archive-read-missing-rel',
70
+ 'archive-project read requires --rel <relative-path>',
71
+ { args: list.slice() },
72
+ );
73
+ }
74
+ const content = archive.readArchiveFile(cwd, flags.name, flags.rel_path);
75
+ stdout.write(content);
76
+ return { ok: true };
77
+ }
78
+ default:
79
+ throw new NubosPilotError(
80
+ 'archive-project-unknown-verb',
81
+ 'archive-project: unknown verb: ' + String(verb),
82
+ { verb, allowed: ['status', 'do', 'list', 'read'] },
83
+ );
84
+ }
85
+ }
86
+
87
+ module.exports = { run };
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ const { test, afterEach } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('node:fs');
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
+ const YAML = require('yaml');
9
+
10
+ const subcmd = require('./archive-project.cjs');
11
+ const layout = require('../../lib/layout.cjs');
12
+
13
+ const _sandboxes = [];
14
+
15
+ function _completeSandbox() {
16
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'np-arc-'));
17
+ _sandboxes.push(root);
18
+ const sd = path.join(root, '.nubos-pilot');
19
+ fs.mkdirSync(sd, { recursive: true });
20
+ fs.writeFileSync(path.join(sd, 'PROJECT.md'), '# Demo\n\nbody\n', 'utf-8');
21
+ fs.writeFileSync(
22
+ path.join(sd, 'roadmap.yaml'),
23
+ YAML.stringify({
24
+ schema_version: 2,
25
+ milestones: [{ id: 'M001', number: 1, name: 'a', status: 'done', success_criteria: ['x'], slices: [] }],
26
+ }),
27
+ 'utf-8',
28
+ );
29
+ const mDir = layout.milestoneDir(1, root);
30
+ fs.mkdirSync(mDir, { recursive: true });
31
+ fs.writeFileSync(path.join(mDir, 'M001-VERIFICATION.md'),
32
+ '**Milestone Status:** verified\n### SC-1: x\n- **Status:** Pass\n- **Classified by:** np-verifier\n- **Evidence:** abc\n', 'utf-8');
33
+ fs.writeFileSync(path.join(mDir, 'M001-VALIDATION.md'),
34
+ '- REQ-01: COVERED\n', 'utf-8');
35
+ return root;
36
+ }
37
+
38
+ afterEach(() => {
39
+ while (_sandboxes.length) {
40
+ try { fs.rmSync(_sandboxes.pop(), { recursive: true, force: true }); } catch {}
41
+ }
42
+ });
43
+
44
+ function _capture() {
45
+ let buf = '';
46
+ return { stub: { write: (s) => { buf += s; return true; } }, get: () => buf };
47
+ }
48
+
49
+ test('AP-1: status verb returns project_exists + completion', () => {
50
+ const sb = _completeSandbox();
51
+ const cap = _capture();
52
+ subcmd.run(['status'], { cwd: sb, stdout: cap.stub });
53
+ const payload = JSON.parse(cap.get().trim());
54
+ assert.equal(payload.project_exists, true);
55
+ assert.equal(payload.completion.complete, true);
56
+ });
57
+
58
+ test('AP-2: do verb archives a complete project', () => {
59
+ const sb = _completeSandbox();
60
+ const cap = _capture();
61
+ subcmd.run(['do'], { cwd: sb, stdout: cap.stub });
62
+ const payload = JSON.parse(cap.get().trim());
63
+ assert.ok(payload.archive_dir.includes('archive'));
64
+ assert.ok(fs.existsSync(path.join(payload.archive_dir, 'ARCHIVE.json')));
65
+ assert.equal(fs.existsSync(path.join(sb, '.nubos-pilot', 'PROJECT.md')), false);
66
+ });
67
+
68
+ test('AP-3: list verb returns archives in newest-first order', () => {
69
+ const sb = _completeSandbox();
70
+ subcmd.run(['do'], { cwd: sb, stdout: _capture().stub });
71
+ const cap = _capture();
72
+ subcmd.run(['list'], { cwd: sb, stdout: cap.stub });
73
+ const items = JSON.parse(cap.get().trim());
74
+ assert.equal(items.length, 1);
75
+ assert.equal(items[0].completion_status, 'complete');
76
+ });
77
+
78
+ test('AP-4: unknown verb throws', () => {
79
+ const sb = _completeSandbox();
80
+ assert.throws(
81
+ () => subcmd.run(['nope'], { cwd: sb, stdout: _capture().stub }),
82
+ (err) => err.code === 'archive-project-unknown-verb',
83
+ );
84
+ });
85
+
86
+ test('AP-5: read verb returns archived file content', () => {
87
+ const sb = _completeSandbox();
88
+ const cap1 = _capture();
89
+ subcmd.run(['do'], { cwd: sb, stdout: cap1.stub });
90
+ const archiveResult = JSON.parse(cap1.get().trim());
91
+ const archiveName = path.basename(archiveResult.archive_dir);
92
+ const cap2 = _capture();
93
+ subcmd.run(['read', '--name', archiveName, '--rel', 'PROJECT.md'], { cwd: sb, stdout: cap2.stub });
94
+ assert.match(cap2.get(), /# Demo/);
95
+ });
96
+
97
+ test('AP-6: read verb refuses missing flags', () => {
98
+ const sb = _completeSandbox();
99
+ subcmd.run(['do'], { cwd: sb, stdout: _capture().stub });
100
+ assert.throws(
101
+ () => subcmd.run(['read'], { cwd: sb, stdout: _capture().stub }),
102
+ (err) => err.code === 'archive-read-missing-name',
103
+ );
104
+ });
105
+
106
+ test('AP-7: --no-carry-over skips archive copy but leaves originals in place', () => {
107
+ const sb = _completeSandbox();
108
+ fs.mkdirSync(path.join(sb, '.nubos-pilot', 'learnings'), { recursive: true });
109
+ fs.writeFileSync(path.join(sb, '.nubos-pilot', 'learnings', 'x.md'), 'hi', 'utf-8');
110
+ const cap = _capture();
111
+ subcmd.run(['do', '--no-carry-over'], { cwd: sb, stdout: cap.stub });
112
+ const payload = JSON.parse(cap.get().trim());
113
+ assert.deepEqual(payload.carried_over, []);
114
+ assert.equal(fs.existsSync(path.join(sb, '.nubos-pilot', 'learnings', 'x.md')), true);
115
+ assert.equal(fs.existsSync(path.join(payload.archive_dir, 'learnings')), false);
116
+ });
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const os = require('node:os');
6
+ const crypto = require('node:crypto');
7
+
8
+ const {
9
+ NubosPilotError,
10
+ projectStateDir,
11
+ } = require('../../lib/core.cjs');
12
+ const archive = require('../../lib/archive.cjs');
13
+ const textMode = require('../../lib/text-mode.cjs');
14
+
15
+ const INLINE_THRESHOLD_BYTES = 16 * 1024;
16
+
17
+ function _emit(payload, stdout, cwd) {
18
+ const json = JSON.stringify(payload, null, 2);
19
+ if (Buffer.byteLength(json, 'utf-8') <= INLINE_THRESHOLD_BYTES) {
20
+ stdout.write(json);
21
+ return;
22
+ }
23
+ let tmpDir;
24
+ try {
25
+ tmpDir = path.join(projectStateDir(cwd), '.tmp');
26
+ fs.mkdirSync(tmpDir, { recursive: true });
27
+ } catch { tmpDir = os.tmpdir(); }
28
+ const suffix = process.pid + '-' + crypto.randomBytes(4).toString('hex');
29
+ const tmpPath = path.join(tmpDir, 'init-close-project-' + suffix + '.json');
30
+ fs.writeFileSync(tmpPath, json, 'utf-8');
31
+ stdout.write('@file:' + tmpPath);
32
+ }
33
+
34
+ function _initPayload(cwd) {
35
+ const completion = archive.computeCompletionStatus(cwd);
36
+ const tmDetail = textMode.resolveTextModeDetail(cwd);
37
+ return {
38
+ _workflow: 'close-project',
39
+ cwd,
40
+ project_exists: archive.projectExists(cwd),
41
+ completion,
42
+ summary_path: archive.projectSummaryPath(cwd),
43
+ text_mode: tmDetail.enabled,
44
+ text_mode_source: tmDetail.source,
45
+ };
46
+ }
47
+
48
+ function _emitSummary(cwd) {
49
+ return archive.writeProjectSummary(cwd);
50
+ }
51
+
52
+ function _mark(cwd) {
53
+ return archive.setProjectStatus(cwd, 'completed');
54
+ }
55
+
56
+ function _unmark(cwd) {
57
+ return archive.setProjectStatus(cwd, 'active');
58
+ }
59
+
60
+ function run(args, ctx) {
61
+ const context = ctx || {};
62
+ const cwd = context.cwd || process.cwd();
63
+ const stdout = context.stdout || process.stdout;
64
+ const list = Array.isArray(args) ? args : [];
65
+ const verb = list[0];
66
+
67
+ switch (verb) {
68
+ case 'init':
69
+ case undefined: {
70
+ const payload = _initPayload(cwd);
71
+ _emit(payload, stdout, cwd);
72
+ return payload;
73
+ }
74
+ case 'check': {
75
+ const payload = archive.computeCompletionStatus(cwd);
76
+ stdout.write(JSON.stringify(payload, null, 2));
77
+ return payload;
78
+ }
79
+ case 'write-summary': {
80
+ const result = _emitSummary(cwd);
81
+ stdout.write(JSON.stringify(result));
82
+ return result;
83
+ }
84
+ case 'mark-completed': {
85
+ const result = _mark(cwd);
86
+ stdout.write(JSON.stringify(result));
87
+ return result;
88
+ }
89
+ case 'unmark': {
90
+ const result = _unmark(cwd);
91
+ stdout.write(JSON.stringify(result));
92
+ return result;
93
+ }
94
+ default:
95
+ throw new NubosPilotError(
96
+ 'close-project-unknown-verb',
97
+ 'close-project: unknown verb: ' + String(verb),
98
+ { verb },
99
+ );
100
+ }
101
+ }
102
+
103
+ module.exports = { run };
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+
3
+ const { test, afterEach } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('node:fs');
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
+ const YAML = require('yaml');
9
+
10
+ const subcmd = require('./close-project.cjs');
11
+ const layout = require('../../lib/layout.cjs');
12
+
13
+ const _sandboxes = [];
14
+
15
+ function _sandbox(milestones, milestoneArtifacts) {
16
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'np-cp-'));
17
+ _sandboxes.push(root);
18
+ const sd = path.join(root, '.nubos-pilot');
19
+ fs.mkdirSync(sd, { recursive: true });
20
+ fs.writeFileSync(path.join(sd, 'PROJECT.md'), '# Demo Project\n\nbody\n', 'utf-8');
21
+ fs.writeFileSync(
22
+ path.join(sd, 'roadmap.yaml'),
23
+ YAML.stringify({ schema_version: 2, milestones }),
24
+ 'utf-8',
25
+ );
26
+ for (const m of (milestoneArtifacts || [])) {
27
+ const mDir = layout.milestoneDir(m.number, root);
28
+ fs.mkdirSync(mDir, { recursive: true });
29
+ if (m.verification) fs.writeFileSync(path.join(mDir, 'M' + String(m.number).padStart(3, '0') + '-VERIFICATION.md'), m.verification, 'utf-8');
30
+ if (m.validation) fs.writeFileSync(path.join(mDir, 'M' + String(m.number).padStart(3, '0') + '-VALIDATION.md'), m.validation, 'utf-8');
31
+ }
32
+ return root;
33
+ }
34
+
35
+ afterEach(() => {
36
+ while (_sandboxes.length) {
37
+ try { fs.rmSync(_sandboxes.pop(), { recursive: true, force: true }); } catch {}
38
+ }
39
+ });
40
+
41
+ function _capture() {
42
+ let buf = '';
43
+ return { stub: { write: (s) => { buf += s; return true; } }, get: () => buf };
44
+ }
45
+
46
+ function _verified() {
47
+ return '# M001\n\n**Verified:** 2026-05-11\n**Milestone Status:** verified\n\n## Success Criteria\n\n### SC-1: x\n- **Status:** Pass\n- **Classified by:** np-verifier\n- **Evidence:** abc\n';
48
+ }
49
+
50
+ function _validation() {
51
+ return '# M001 Validation\n- REQ-01: COVERED\n';
52
+ }
53
+
54
+ test('CP-1: init returns completion payload', () => {
55
+ const sb = _sandbox(
56
+ [{ id: 'M001', number: 1, name: 'a', status: 'done', success_criteria: ['x'], slices: [] }],
57
+ [{ number: 1, verification: _verified(), validation: _validation() }],
58
+ );
59
+ const cap = _capture();
60
+ subcmd.run(['init'], { cwd: sb, stdout: cap.stub });
61
+ const payload = JSON.parse(cap.get().trim());
62
+ assert.equal(payload._workflow, 'close-project');
63
+ assert.equal(payload.project_exists, true);
64
+ assert.equal(payload.completion.status, 'complete');
65
+ });
66
+
67
+ test('CP-2: write-summary writes PROJECT-SUMMARY.md', () => {
68
+ const sb = _sandbox(
69
+ [{ id: 'M001', number: 1, name: 'a', status: 'done', success_criteria: ['x'], slices: [] }],
70
+ [{ number: 1, verification: _verified(), validation: _validation() }],
71
+ );
72
+ const cap = _capture();
73
+ subcmd.run(['write-summary'], { cwd: sb, stdout: cap.stub });
74
+ const summaryPath = path.join(sb, '.nubos-pilot', 'PROJECT-SUMMARY.md');
75
+ assert.ok(fs.existsSync(summaryPath));
76
+ const md = fs.readFileSync(summaryPath, 'utf-8');
77
+ assert.match(md, /Project Summary/);
78
+ });
79
+
80
+ test('CP-3: mark-completed sets project_status in roadmap.yaml', () => {
81
+ const sb = _sandbox(
82
+ [{ id: 'M001', number: 1, name: 'a', status: 'done', success_criteria: ['x'], slices: [] }],
83
+ [{ number: 1, verification: _verified(), validation: _validation() }],
84
+ );
85
+ subcmd.run(['mark-completed'], { cwd: sb, stdout: _capture().stub });
86
+ const doc = YAML.parse(fs.readFileSync(path.join(sb, '.nubos-pilot', 'roadmap.yaml'), 'utf-8'));
87
+ assert.equal(doc.project_status, 'completed');
88
+ });
89
+
90
+ test('CP-4: unknown verb throws NubosPilotError', () => {
91
+ const sb = _sandbox(
92
+ [{ id: 'M001', number: 1, name: 'a', status: 'done', success_criteria: ['x'], slices: [] }],
93
+ [],
94
+ );
95
+ assert.throws(
96
+ () => subcmd.run(['frobnicate'], { cwd: sb, stdout: _capture().stub }),
97
+ (err) => err.code === 'close-project-unknown-verb',
98
+ );
99
+ });
100
+
101
+ test('CP-5: check verb prints completion JSON', () => {
102
+ const sb = _sandbox(
103
+ [{ id: 'M001', number: 1, name: 'a', status: 'pending', success_criteria: ['x'], slices: [] }],
104
+ [],
105
+ );
106
+ const cap = _capture();
107
+ subcmd.run(['check'], { cwd: sb, stdout: cap.stub });
108
+ const payload = JSON.parse(cap.get().trim());
109
+ assert.equal(payload.status, 'incomplete');
110
+ assert.ok(payload.blockers.length > 0);
111
+ });
@@ -11,6 +11,8 @@ const runtimeAssetsMod = require('../../lib/install/runtime-assets.cjs');
11
11
  const askuserMod = require('../../lib/askuser.cjs');
12
12
  const codebaseManifest = require('../../lib/codebase-manifest.cjs');
13
13
  const { scan: workspaceScan } = require('../../lib/workspace-scan.cjs');
14
+ const outputLint = require('../../lib/output-lint.cjs');
15
+ const { getSchema, inferSchemaForFile } = require('../../lib/schemas/index.cjs');
14
16
 
15
17
  const PAYLOAD_SUBPATH = path.join('.claude', 'nubos-pilot');
16
18
  const STATE_SUBPATH = '.nubos-pilot';
@@ -497,6 +499,52 @@ function _checkOrphanTmpFiles(projectRoot) {
497
499
  return issues;
498
500
  }
499
501
 
502
+ function _checkOutputSchemas(projectRoot) {
503
+ const issues = [];
504
+ const milestonesRoot = path.join(projectRoot, STATE_SUBPATH, 'milestones');
505
+ if (!fs.existsSync(milestonesRoot)) return issues;
506
+ let entries;
507
+ try { entries = fs.readdirSync(milestonesRoot, { withFileTypes: true }); }
508
+ catch { return issues; }
509
+ for (const ent of entries) {
510
+ if (!ent.isDirectory()) continue;
511
+ if (!/^M\d{3,}$/.test(ent.name)) continue;
512
+ const mDir = path.join(milestonesRoot, ent.name);
513
+ for (const suffix of ['-VERIFICATION.md', '-VALIDATION.md']) {
514
+ const file = path.join(mDir, ent.name + suffix);
515
+ if (!fs.existsSync(file)) continue;
516
+ const schemaName = inferSchemaForFile(file);
517
+ if (!schemaName) continue;
518
+ let result;
519
+ try {
520
+ result = outputLint.lintFile(file, getSchema(schemaName));
521
+ } catch (err) {
522
+ issues.push({
523
+ id: 'output-schema-lint-failed',
524
+ severity: 'error',
525
+ fixable: 'manual',
526
+ details: { file, schema: schemaName, cause: err && err.message },
527
+ });
528
+ continue;
529
+ }
530
+ if (!result.ok) {
531
+ issues.push({
532
+ id: 'output-schema-violation',
533
+ severity: 'error',
534
+ fixable: 'manual',
535
+ details: {
536
+ file,
537
+ schema: schemaName,
538
+ violation_count: result.violations.length,
539
+ violations: result.violations.slice(0, 10),
540
+ },
541
+ });
542
+ }
543
+ }
544
+ }
545
+ return issues;
546
+ }
547
+
500
548
  function _audit(projectRoot) {
501
549
  const scope = _readScope(projectRoot);
502
550
  const payloadDir = _payloadDirFor(projectRoot, scope);
@@ -514,6 +562,7 @@ function _audit(projectRoot) {
514
562
  issues.push(..._checkNubosloopKnowledgeStore(projectRoot));
515
563
  issues.push(..._checkNubosloopConfig(projectRoot));
516
564
  issues.push(..._checkOrphanTmpFiles(projectRoot));
565
+ issues.push(..._checkOutputSchemas(projectRoot));
517
566
  return { issues, _codexContent: codex.content };
518
567
  }
519
568