nubos-pilot 1.0.3 → 1.0.5

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 (44) hide show
  1. package/agents/np-build-fixer.md +10 -0
  2. package/agents/np-critic.md +20 -0
  3. package/agents/np-executor.md +13 -0
  4. package/agents/np-planner.md +16 -0
  5. package/agents/np-researcher.md +17 -0
  6. package/bin/np-tools/_commands.cjs +11 -0
  7. package/bin/np-tools/_memory-resolve.cjs +41 -0
  8. package/bin/np-tools/archive-project.cjs +87 -0
  9. package/bin/np-tools/archive-project.test.cjs +116 -0
  10. package/bin/np-tools/close-project.cjs +103 -0
  11. package/bin/np-tools/close-project.test.cjs +111 -0
  12. package/bin/np-tools/loop-commands.test.cjs +126 -0
  13. package/bin/np-tools/loop-run-round.cjs +55 -0
  14. package/bin/np-tools/memory-add.cjs +57 -0
  15. package/bin/np-tools/memory-index.cjs +77 -0
  16. package/bin/np-tools/memory-query.cjs +39 -0
  17. package/bin/np-tools/memory-rebuild.cjs +16 -0
  18. package/bin/np-tools/memory-stats.cjs +15 -0
  19. package/bin/np-tools/memory.test.cjs +241 -0
  20. package/bin/np-tools/messages-archive.cjs +35 -0
  21. package/bin/np-tools/messages-inbox.cjs +34 -0
  22. package/bin/np-tools/messages-send.cjs +77 -0
  23. package/bin/np-tools/messages-thread.cjs +35 -0
  24. package/bin/np-tools/messages.test.cjs +183 -0
  25. package/bin/np-tools/new-project.cjs +24 -1
  26. package/bin/np-tools/new-project.test.cjs +31 -0
  27. package/docs/adr/0002-zero-runtime-dependencies.md +2 -1
  28. package/docs/adr/0014-vector-memory-layer.md +175 -0
  29. package/docs/adr/0015-named-agent-messaging.md +162 -0
  30. package/docs/adr/README.md +2 -0
  31. package/lib/archive.cjs +434 -0
  32. package/lib/archive.test.cjs +328 -0
  33. package/lib/knowledge-adapter.cjs +61 -4
  34. package/lib/knowledge-adapter.test.cjs +69 -0
  35. package/lib/memory-index-usearch.cjs +111 -0
  36. package/lib/memory-provider-local.cjs +60 -0
  37. package/lib/memory.cjs +312 -0
  38. package/lib/memory.test.cjs +323 -0
  39. package/lib/messaging.cjs +403 -0
  40. package/lib/messaging.test.cjs +309 -0
  41. package/np-tools.cjs +12 -0
  42. package/package.json +5 -1
  43. package/workflows/close-project.md +139 -0
  44. package/workflows/new-project.md +93 -13
@@ -40,6 +40,16 @@ The orchestrator provides these in your prompt context. Read every path it hands
40
40
 
41
41
  ## Workflow
42
42
 
43
+ 0. **Read inbox first (ADR-0015).** You are always running on round ≥ 2; the prior critic may have addressed clarifying questions to the executor that you must answer:
44
+ ```bash
45
+ node np-tools.cjs messages-inbox --agent np-executor --task <task-id>
46
+ ```
47
+ For each `kind=request` with `expects_reply=true`, your patch should resolve the question and you must send a response (otherwise commit-phase is blocked by Layer-B):
48
+ ```bash
49
+ node np-tools.cjs messages-send --from np-executor --to <orig-from> \
50
+ --phase <task-id> --round <round> --kind response \
51
+ --subject <same-subject> --body "<resolution>" --in-reply-to <request-id>
52
+ ```
43
53
  1. **Classify** the failure from the captured output:
44
54
  - `compile` (syntax error, missing import, type error)
45
55
  - `lint` (style/quality rule violation)
@@ -145,6 +145,26 @@ If `<report_path>` is missing from your prompt or you cannot write the file, do
145
145
  - Inline the full findings JSON in the final message. The Verdict-Only Contract exists because that response replays into the orchestrator's context every round and is the dominant token sink — defeating it silently re-introduces the cost ADR-0010 §L5 was designed to remove.
146
146
  </scope_guardrail>
147
147
 
148
+ ## Inter-Agent Messaging (ADR-0015)
149
+
150
+ When a finding requires per-finding clarification from the executor that does NOT belong in the findings JSON itself (e.g. *"did you intend to delete `FoobarService`, or was that a side-effect?"*), you MAY emit an addressed `request` message via:
151
+
152
+ ```bash
153
+ node np-tools.cjs messages-send \
154
+ --from np-critic --to np-executor \
155
+ --phase <task-id> --round <current-round> \
156
+ --kind request --subject <finding-category> \
157
+ --body "<question>" --expects-reply
158
+ ```
159
+
160
+ Rules:
161
+ - Messages are **dialogue**, not findings. The findings JSON written to `<report_path>` is canonical for routing. Messages are for clarifications that bounce back to executor / build-fixer.
162
+ - Each `request` you send with `--expects-reply` blocks the next commit-phase via Layer-B (`pendingReplies(taskId) === 0` precondition). Use sparingly.
163
+ - `request` subjects should match a finding-category (`style`, `dead-code`, `missing-test`, `weak-assertion`, `unmet-criterion`, `scope-creep`).
164
+ - Inspect prior dialogue with `messages-thread <msg-id>` if a finding looks like it bounced last round.
165
+
166
+ If you have nothing to ask, send no message. Default = no dialogue.
167
+
148
168
  ## Stop Conditions
149
169
 
150
170
  Hard-stop (`Write` the full findings JSON to `<report_path>` if possible, then emit the envelope; do NOT attempt recovery):
@@ -96,6 +96,19 @@ into the `task(…)` commit. If `workflow.commit_docs=true`, the
96
96
  2. **Read codebase docs** — `.nubos-pilot/codebase/INDEX.md` plus every
97
97
  module doc owning a path in `files_modified`. Pre-edit step of the
98
98
  Codebase Docs Protocol.
99
+ 2a. **Read inbox (Round 2+ only)** — when round ≥ 2, check for addressed
100
+ requests from prior critics that need a response before commit:
101
+ ```bash
102
+ node np-tools.cjs messages-inbox --agent np-executor --task <task-id>
103
+ ```
104
+ For each `kind=request` with `expects_reply=true`, your edit should resolve it.
105
+ After the edit, send a response and let it auto-archive the request:
106
+ ```bash
107
+ node np-tools.cjs messages-send --from np-executor --to <orig-from> \
108
+ --phase <task-id> --round <round> --kind response \
109
+ --subject <same-subject> --body "<resolution>" --in-reply-to <request-id>
110
+ ```
111
+ Unanswered `expects_reply=true` requests block commit-phase via Layer-B (ADR-0015).
99
112
  3. **Transition to in-progress:** `node np-tools.cjs checkpoint transition <task-id> in-progress`.
100
113
  4. **Edit files** — only the paths listed in the task's `files_modified` frontmatter. Use `Read` + `Edit` / `Write`. No scope expansion.
101
114
  5. **Transition to verifying:** `node np-tools.cjs checkpoint transition <task-id> verifying`.
@@ -99,6 +99,22 @@ If the prompt contains a `<files_to_read>` block, you MUST use the `Read` tool t
99
99
  </role>
100
100
 
101
101
  <reality_check_protocol>
102
+ ## Vector-Memory Recall (ADR-0014)
103
+
104
+ Before scaffolding tasks, query the local vector memory for prior-phase decisions matching the current milestone. This is *advisory*: the locked-decisions file (`M<NNN>-CONTEXT.md` / `RESEARCH.md`) is canonical, memory-hits inform context-injection only.
105
+
106
+ ```bash
107
+ node np-tools.cjs memory-query --text "<milestone-summary>" --k 5 --type research
108
+ node np-tools.cjs memory-query --text "<milestone-summary>" --k 5 --type learning
109
+ ```
110
+
111
+ If `memory.enabled=false` you'll get `memory-disabled` — silently skip.
112
+
113
+ For `[VERIFIED]` / `[CITED]` hits whose subject overlaps the current milestone:
114
+ - Reference them in the slice plan's `<context>` block as prior-art.
115
+ - If a prior decision conflicts with a freshly-locked decision in `M<NNN>-CONTEXT.md`, the locked decision wins. Memory is never authoritative against locked-decisions.
116
+ - Do NOT auto-promote `[ASSUMED]` hits; they remain advisory.
117
+
102
118
  ## CRITICAL: Reality-Check Before Planning (MANDATORY)
103
119
 
104
120
  Plans fail at execute-time when they encode assumptions the planner never verified against the actual repo. To stop the replan-after-execute loop, BEFORE writing any `S<NNN>-PLAN.md` you MUST empirically verify every load-bearing assumption and record the evidence inside the slice plan.
@@ -36,6 +36,23 @@ anchor points for your research — do not propose replacements without
36
36
  explicit justification. If `INDEX.md` is absent, report and stop —
37
37
  `np:scan-codebase` must run first.
38
38
 
39
+ ## Vector-Memory Pre-recall (ADR-0014)
40
+
41
+ **Before issuing external research**, query the local vector memory for prior decisions matching the current ticket. The hybrid pre-flight (`lib/knowledge-adapter.cjs`) already runs at swarm-entry; this step is the *agent-side* recall for context-injection into your `RESEARCH.md`.
42
+
43
+ ```bash
44
+ node np-tools.cjs memory-query --text "<ticket-summary>" --k 5 --type research
45
+ node np-tools.cjs memory-query --text "<ticket-summary>" --k 3 --type learning
46
+ ```
47
+
48
+ If `memory.enabled=false` you'll get `memory-disabled` — silently skip; this section is opt-in and additive.
49
+
50
+ For each hit, surface the underlying decision in your `RESEARCH.md` with the original `provenance` preserved:
51
+ - `[VERIFIED]` / `[CITED]` hits become `[CACHED:VERIFIED]` / `[CACHED:CITED]` in your output — the merged `<consensus_meta>` block carries `cache_hit: true`.
52
+ - `[ASSUMED]` hits stay `[CACHED:ASSUMED]` — flagged for plan-checker scrutiny, never auto-promoted.
53
+
54
+ A high-confidence cache match should *replace* external research for that decision, not duplicate it. Save the swarm tokens.
55
+
39
56
  ## Handoff Protocol
40
57
 
41
58
  Agent handoffs are persistent notes between phase invocations. Before researching, check handoffs addressed to `np-researcher`:
@@ -17,6 +17,8 @@ const COMMANDS = [
17
17
  { 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
18
  { 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
19
  { 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)' },
20
+ { 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' },
21
+ { 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
22
  { 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
23
  { 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
24
  { 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' },
@@ -59,6 +61,15 @@ const COMMANDS = [
59
61
  { name: 'handoff-read', category: 'Capture', description: 'Read a single handoff by id (returns frontmatter + body as JSON)', description_de: 'Liest einzelnen Handoff per ID (gibt Frontmatter + Body als JSON zurück)' },
60
62
  { name: 'handoff-list', category: 'Capture', description: 'List handoffs (JSON array); filter with --for AGENT, --milestone M<NNN>, --status STATUS, --global', description_de: 'Listet Handoffs (JSON-Array); filtert mit --for AGENT, --milestone M<NNN>, --status STATUS, --global' },
61
63
  { name: 'handoff-status', category: 'Capture', description: 'Update a handoff status (open|read|acted|archived)', description_de: 'Aktualisiert Handoff-Status (open|read|acted|archived)' },
64
+ { name: 'messages-send', category: 'Capture', description: 'Send addressed inter-agent message (request|response|notify) to .nubos-pilot/messages/inbox/<to>/. ADR-0015', description_de: 'Sendet adressierte Inter-Agent-Nachricht (request|response|notify) an .nubos-pilot/messages/inbox/<to>/. ADR-0015' },
65
+ { name: 'messages-inbox', category: 'Capture', description: 'List ungelesen messages addressed to an agent (filterable by --kind, --since, --task)', description_de: 'Listet ungelesen Messages für einen Agent (filterbar via --kind, --since, --task)' },
66
+ { name: 'messages-archive', category: 'Capture', description: 'Move an inbox message to archive/; refuses request+expects_reply without prior response', description_de: 'Verschiebt Inbox-Message nach archive/; weigert sich bei request+expects_reply ohne Reply' },
67
+ { name: 'messages-thread', category: 'Capture', description: 'Print full reply-chain for a message id (causal order)', description_de: 'Gibt vollständige Reply-Chain für eine Message-ID aus (kausale Reihenfolge)' },
68
+ { name: 'memory-index', category: 'Capture', description: 'Bulk-index records into vector memory (--records JSON or --records-file JSONL). Opt-in via memory.enabled=true. ADR-0014', description_de: 'Bulk-Index für Records ins Vector-Memory (--records JSON oder --records-file JSONL). Opt-in via memory.enabled=true. ADR-0014' },
69
+ { name: 'memory-query', category: 'Utility', description: 'Query vector memory by text; returns top-k JSON hits with score + record. Filter by --type, --phase, --tags', description_de: 'Fragt Vector-Memory by Text; liefert Top-k JSON-Hits mit Score + Record. Filter via --type, --phase, --tags' },
70
+ { name: 'memory-add', category: 'Capture', description: 'Add a single record to vector memory (--type, --title, --body, optional --tags / --provenance / --phase / --id)', description_de: 'Fügt einzelnen Record in Vector-Memory ein (--type, --title, --body, optional --tags / --provenance / --phase / --id)' },
71
+ { name: 'memory-rebuild', category: 'Utility', description: 'Force full re-embed from records.jsonl; required after embedding-model change. ADR-0014', description_de: 'Erzwingt komplettes Re-Embed aus records.jsonl; erforderlich nach Embedding-Model-Wechsel. ADR-0014' },
72
+ { name: 'memory-stats', category: 'Utility', description: 'Print vector-memory stats (count, dim, model, schema_version, created_at)', description_de: 'Gibt Vector-Memory-Stats aus (count, dim, model, schema_version, created_at)' },
62
73
  { name: 'worktree-create', category: 'Execution', description: 'Create an isolated git worktree for a slice (branch np/<mid>-<sid> off current HEAD) under .nubos-pilot/worktrees/', description_de: 'Erstellt isoliertes Git-Worktree für eine Slice (Branch np/<mid>-<sid> vom aktuellen HEAD) unter .nubos-pilot/worktrees/' },
63
74
  { name: 'worktree-remove', category: 'Execution', description: 'Remove a slice worktree + delete its branch (--force / --keep-branch)', description_de: 'Entfernt Slice-Worktree + löscht zugehörigen Branch (--force / --keep-branch)' },
64
75
  { name: 'worktree-list', category: 'Execution', description: 'List all nubos-pilot-managed slice worktrees (np/<mid>-<sid> only) as JSON', description_de: 'Listet alle nubos-pilot-verwalteten Slice-Worktrees (nur np/<mid>-<sid>) als JSON' },
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ const { NubosPilotError } = require('../../lib/core.cjs');
4
+ const { readConfigPath } = require('../../lib/config.cjs');
5
+ const { createMemory } = require('../../lib/memory.cjs');
6
+
7
+ function resolveMemory(opts) {
8
+ const o = opts || {};
9
+ const cwd = o.cwd || process.cwd();
10
+
11
+ if (o.provider && o.indexEngine) {
12
+ return createMemory({
13
+ provider: o.provider,
14
+ indexEngine: o.indexEngine,
15
+ cwd,
16
+ alpha: o.alpha,
17
+ });
18
+ }
19
+
20
+ const enabled = readConfigPath(cwd, 'memory.enabled', false);
21
+ if (!enabled) {
22
+ throw new NubosPilotError(
23
+ 'memory-disabled',
24
+ 'memory layer is disabled. Set "memory": { "enabled": true } in .nubos-pilot/config.json (ADR-0014).',
25
+ {},
26
+ );
27
+ }
28
+
29
+ const model = readConfigPath(cwd, 'memory.model', 'Xenova/bge-small-en-v1.5');
30
+ const alpha = readConfigPath(cwd, 'memory.alpha', 0.6);
31
+
32
+ const { createLocalProvider } = require('../../lib/memory-provider-local.cjs');
33
+ const { createUsearchIndex } = require('../../lib/memory-index-usearch.cjs');
34
+
35
+ const provider = createLocalProvider({ model });
36
+ const indexEngine = createUsearchIndex({ dim: provider.dim });
37
+
38
+ return createMemory({ provider, indexEngine, cwd, alpha });
39
+ }
40
+
41
+ module.exports = { resolveMemory };
@@ -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
+ });