nubos-pilot 1.0.4 → 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.
@@ -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' },
@@ -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,7 @@ const {
11
11
  const { writeState } = require('../../lib/state.cjs');
12
12
  const layout = require('../../lib/layout.cjs');
13
13
  const textMode = require('../../lib/text-mode.cjs');
14
+ const archive = require('../../lib/archive.cjs');
14
15
 
15
16
  const TEMPLATES_DIR = path.join(__dirname, '..', '..', 'templates');
16
17
  const PLACEHOLDER_RE = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g;
@@ -43,12 +44,29 @@ function _todayIso() {
43
44
  return new Date().toISOString().slice(0, 10);
44
45
  }
45
46
 
47
+ function _detectPayload(cwd) {
48
+ const exists = archive.projectExists(cwd);
49
+ if (!exists) {
50
+ return {
51
+ existing_project: false,
52
+ completion: null,
53
+ archives: archive.listArchives(cwd),
54
+ };
55
+ }
56
+ return {
57
+ existing_project: true,
58
+ completion: archive.computeCompletionStatus(cwd),
59
+ archives: archive.listArchives(cwd),
60
+ };
61
+ }
62
+
46
63
  function _interviewPayload(cwd) {
47
64
  const tmDetail = textMode.resolveTextModeDetail(cwd);
48
65
  return {
49
66
  mode: 'interview',
50
67
  text_mode: tmDetail.enabled,
51
68
  text_mode_source: tmDetail.source,
69
+ detection: _detectPayload(cwd),
52
70
  questions: [
53
71
  { key: 'project_name', type: 'input',
54
72
  question: 'Project name?' },
@@ -285,6 +303,11 @@ function run(args, ctx) {
285
303
  const stdout = context.stdout || process.stdout;
286
304
  const argv = args || [];
287
305
 
306
+ if (argv.includes('--detect')) {
307
+ _emit(stdout, { mode: 'detect', detection: _detectPayload(cwd) });
308
+ return;
309
+ }
310
+
288
311
  const applyIdx = argv.indexOf('--apply');
289
312
  if (applyIdx >= 0) {
290
313
  const answersPath = argv[applyIdx + 1];
@@ -302,4 +325,4 @@ function run(args, ctx) {
302
325
  _emit(stdout, _interviewPayload(cwd));
303
326
  }
304
327
 
305
- module.exports = { run, _interviewPayload, _slugify };
328
+ module.exports = { run, _interviewPayload, _detectPayload, _slugify };
@@ -171,3 +171,34 @@ test('NP-9: backwards compat — accepts legacy first_phase_name as goal fallbac
171
171
  const doc = YAML.parse(fs.readFileSync(path.join(sandbox, '.nubos-pilot', 'roadmap.yaml'), 'utf-8'));
172
172
  assert.equal(doc.milestones[0].goal, 'Legacy phase-name');
173
173
  });
174
+
175
+ test('NP-10: --detect on empty workspace returns existing_project=false', () => {
176
+ const sandbox = makeEmptySandbox();
177
+ const cap = _captureStdout();
178
+ subcmd.run(['--detect'], { cwd: sandbox, stdout: cap.stub });
179
+ const payload = JSON.parse(cap.get().trim());
180
+ assert.equal(payload.mode, 'detect');
181
+ assert.equal(payload.detection.existing_project, false);
182
+ });
183
+
184
+ test('NP-11: --detect after apply returns existing_project=true with completion payload', () => {
185
+ const sandbox = makeEmptySandbox();
186
+ const answersPath = _writeAnswers(sandbox, _baseAnswers());
187
+ subcmd.run(['--apply', answersPath], { cwd: sandbox, stdout: _captureStdout().stub });
188
+
189
+ const cap = _captureStdout();
190
+ subcmd.run(['--detect'], { cwd: sandbox, stdout: cap.stub });
191
+ const payload = JSON.parse(cap.get().trim());
192
+ assert.equal(payload.detection.existing_project, true);
193
+ assert.ok(payload.detection.completion);
194
+ assert.ok(['complete', 'incomplete'].includes(payload.detection.completion.status));
195
+ });
196
+
197
+ test('NP-12: run([]) interview payload embeds detection block', () => {
198
+ const sandbox = makeEmptySandbox();
199
+ const cap = _captureStdout();
200
+ subcmd.run([], { cwd: sandbox, stdout: cap.stub });
201
+ const payload = JSON.parse(cap.get().trim());
202
+ assert.ok(payload.detection);
203
+ assert.equal(payload.detection.existing_project, false);
204
+ });
@@ -4,6 +4,7 @@
4
4
  * Date: 2026-04-14
5
5
  * Supersedes: None
6
6
  * **Amendment:** [ADR-0006](0006-yaml-dependency-amendment.md) permits `yaml@^2.8` as a narrowly-scoped runtime dependency (2026-04-15).
7
+ * **Amendment:** [ADR-0014](0014-vector-memory-layer.md) permits `@huggingface/transformers` and `usearch` under `optionalDependencies` for the opt-in Vector-Memory layer (2026-05-08).
7
8
 
8
9
  ## Context and Problem Statement
9
10
 
@@ -45,7 +46,7 @@ Chosen: **"Zero runtime dependencies"**, because it is the only option that rein
45
46
  * Good, because the install-payload tree (see [ADR-0005](0005-three-orthogonal-file-trees.md)) contains only `.cjs` files and markdown — no `node_modules/` subtree ever appears there.
46
47
  * Bad, because we reimplement small utilities — YAML frontmatter via hand-rolled parser (`lib/frontmatter.cjs`), readline prompts instead of `inquirer`/`@clack/prompts`, raw ANSI escape constants instead of `chalk`. This is an accepted cost documented at length in CLAUDE.md §"Alternatives Considered".
47
48
  * Neutral, because `devDependencies` are permitted and do not ship to users — we can still adopt `c8` for coverage, `esbuild` for optional hook bundling, or `node:test` (builtin) for the test runner.
48
- * Neutral, because `optionalDependencies` for native prebuilt binaries is ALSO rejected by this ADR — see Rust N-API option below — so no accidental backdoor.
49
+ * Neutral, because `optionalDependencies` for native prebuilt binaries is rejected as a general escape hatch by this ADR — see Rust N-API option below — so no accidental backdoor. The narrow exemption permitted by [ADR-0014](0014-vector-memory-layer.md) is opt-in (config-gated, `memory.enabled=true`), lazy-loaded, and only resolves at install time when explicitly requested via `npm install --include=optional`.
49
50
 
50
51
  ## Pros and Cons of the Options
51
52
 
@@ -36,11 +36,11 @@ The layer is **opt-in** — disabled by default; enabled via `memory.enabled = t
36
36
  * **A — No vector memory.** Status quo. Reject: BM25-only Pre-flight has measurable false-negative rate on rephrased tickets; cache-hit ceiling is artificially low.
37
37
  * **B — External vector DB (AgentDB / Weaviate / Pinecone).** Reject: violates ADR-0001 (daemon shape) and ADR-0002 (heavy network dep). Inappropriate for a CLI shipping into arbitrary third-party projects.
38
38
  * **C — Pure-JS HNSW implementation, no native code, no WASM.** Reject: 200–300 LoC of subtle index code is a maintenance burden disproportionate to the benefit; performance ceiling at O(10K) records is fragile.
39
- * **D — `usearch` WASM + `@xenova/transformers` for local embedding, lazy-loaded as opt-in deps.** Chosen.
39
+ * **D — `usearch` + `@huggingface/transformers` for local embedding, lazy-loaded as opt-in deps.** Chosen.
40
40
 
41
41
  ## Decision Outcome
42
42
 
43
- Chosen: **Option D — local-first vector memory with `usearch` WASM and `@xenova/transformers`, lazy-loaded behind a config gate**, because it preserves ADR-0001's no-daemon stance, integrates with the existing `lib/learnings.cjs` Pre-flight without weakening ADR-0011's `[CACHED]` provenance semantics, and leaves a clean seam for future remote providers.
43
+ Chosen: **Option D — local-first vector memory with `usearch` and `@huggingface/transformers`, lazy-loaded behind a config gate**, because it preserves ADR-0001's no-daemon stance, integrates with the existing `lib/learnings.cjs` Pre-flight without weakening ADR-0011's `[CACHED]` provenance semantics, and leaves a clean seam for future remote providers.
44
44
 
45
45
  ### Layout
46
46
 
@@ -103,18 +103,19 @@ Where `Hit = { id, score, record }`. Provider selection happens once at module i
103
103
 
104
104
  ### Embedding Provider — Local Default
105
105
 
106
- `@xenova/transformers` running `Xenova/bge-small-en-v1.5` (or `Xenova/bge-multilingual-base` when project locale ≠ English):
106
+ `@huggingface/transformers` (the successor to `@xenova/transformers`, maintained by the original author at Hugging Face) running `Xenova/bge-small-en-v1.5` (or `Xenova/bge-multilingual-base` when project locale ≠ English):
107
107
 
108
108
  * **Model size:** ~70 MB downloaded on first run; cached under `~/.cache/nubos-pilot/models/`.
109
109
  * **Vector dim:** 384 (bge-small) or 768 (bge-multilingual).
110
110
  * **Provider interface:** `provider.embed(texts: string[]) → Promise<Float32Array[]>`.
111
111
  * **First-run UX:** `np:memory index` prints a one-time progress indicator while the model downloads. Subsequent runs load the cached model.
112
+ * **CJS-compatible:** v4 ships dual CJS+ESM (`main: ./dist/transformers.node.cjs`), so `lib/memory-provider-local.cjs` keeps using `require()`.
112
113
 
113
- ### Index Engine — `usearch` WASM
114
+ ### Index Engine — `usearch`
114
115
 
115
- `usearch` (WASM build) provides HNSW with cosine similarity:
116
+ `usearch` provides HNSW with cosine similarity:
116
117
 
117
- * **Why WASM:** ADR-0002 admits no native compile on the consumer machine. WASM ships precompiled in the package; no `node-gyp`, no Python, no build chain.
118
+ * **Why prebuilt binaries, not WASM:** Both `@huggingface/transformers` (via `onnxruntime-node`) and `usearch` ship platform-specific prebuilt binaries via `node-gyp-build` / `@img/sharp-*`-style platform packages. No `node-gyp` invocation, no Python, no build chain on the consumer machine — same UX as WASM, faster runtime. The `prebuild-install` package (deprecated) is not in the dependency tree of these pinned versions.
118
119
  * **Capacity:** O(100K) records mühelos. nubos-pilot's per-project memory expectation is O(1K–10K) records over the project lifetime.
119
120
  * **Persistence:** `index.usearch` written via `atomicWriteFileSync`; corruption recovery via `np:memory rebuild` from `records.jsonl`.
120
121
 
@@ -122,8 +123,8 @@ Where `Hit = { id, score, record }`. Provider selection happens once at module i
122
123
 
123
124
  This ADR introduces **two new runtime dependencies** in `package.json`:
124
125
 
125
- * `usearch` (~1 MB, WASM, no native build)
126
- * `@xenova/transformers` (~5 MB package; ~70 MB model downloaded on first use; no native build)
126
+ * `usearch@^2.25` (prebuilt platform binaries via `node-gyp-build`, no native compile on install)
127
+ * `@huggingface/transformers@^4` (~5 MB package; ~70 MB model downloaded on first use; prebuilt `onnxruntime-node` binaries, no native compile on install)
127
128
 
128
129
  Both are **lazy-loaded** — the `require()` lives inside `lib/memory.cjs` factory functions and is reached only when `memory.enabled=true` AND a memory operation is invoked. The package.json declares them under `optionalDependencies`, so a Free-tier install with `memory.enabled=false` never resolves them at install time either.
129
130
 
@@ -162,7 +163,7 @@ The local-default provider keeps everything on the consumer machine — no data
162
163
  * **Config schema:** `.nubos-pilot/config.json::memory = { enabled: false, provider: "local", alpha: 0.6, model: "Xenova/bge-small-en-v1.5" }`.
163
164
  * **Related ADRs:**
164
165
  * [ADR-0001](0001-no-daemon-invariant.md) — index is a file, not a process.
165
- * [ADR-0002](0002-zero-runtime-dependencies.md) — amended to admit `usearch` + `@xenova/transformers` under `optionalDependencies`.
166
+ * [ADR-0002](0002-zero-runtime-dependencies.md) — amended to admit `usearch` + `@huggingface/transformers` under `optionalDependencies`.
166
167
  * [ADR-0005](0005-three-orthogonal-file-trees.md) — `.nubos-pilot/memory/` is a strict Project-State sub-tree.
167
168
  * [ADR-0006](0006-yaml-dependency-amendment.md) — same shape of amendment, different scope.
168
169
  * [ADR-0010](0010-nubosloop.md) — Pre-flight integration point.