nubos-pilot 1.1.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +30 -1
  4. package/SECURITY.md +60 -0
  5. package/bin/install.js +104 -39
  6. package/bin/np-tools/_args.cjs +8 -2
  7. package/bin/np-tools/_memory-resolve.cjs +4 -4
  8. package/bin/np-tools/checkpoint.cjs +1 -1
  9. package/bin/np-tools/close-project.cjs +3 -29
  10. package/bin/np-tools/commit-task.cjs +31 -35
  11. package/bin/np-tools/commit.cjs +0 -3
  12. package/bin/np-tools/config.cjs +4 -13
  13. package/bin/np-tools/discuss-phase.cjs +4 -27
  14. package/bin/np-tools/doctor.cjs +76 -16
  15. package/bin/np-tools/doctor.test.cjs +14 -0
  16. package/bin/np-tools/execute-milestone.cjs +6 -27
  17. package/bin/np-tools/handoff-write.cjs +16 -2
  18. package/bin/np-tools/init-dispatch.test.cjs +21 -0
  19. package/bin/np-tools/knowledge-search.cjs +0 -3
  20. package/bin/np-tools/learning-list.cjs +0 -2
  21. package/bin/np-tools/learning-log.cjs +1 -7
  22. package/bin/np-tools/loop-audit-tool-use.cjs +1 -11
  23. package/bin/np-tools/loop-run-round.cjs +51 -148
  24. package/bin/np-tools/loop-state-read.cjs +1 -5
  25. package/bin/np-tools/loop-state-record.cjs +1 -27
  26. package/bin/np-tools/loop-stuck.cjs +1 -8
  27. package/bin/np-tools/messages-send.cjs +16 -2
  28. package/bin/np-tools/metrics.test.cjs +4 -4
  29. package/bin/np-tools/new-milestone.cjs +14 -3
  30. package/bin/np-tools/new-project.cjs +4 -2
  31. package/bin/np-tools/new-project.test.cjs +12 -0
  32. package/bin/np-tools/park.cjs +2 -1
  33. package/bin/np-tools/plan-lint.cjs +0 -19
  34. package/bin/np-tools/plan-milestone.cjs +8 -29
  35. package/bin/np-tools/propose-milestones.cjs +14 -3
  36. package/bin/np-tools/propose-milestones.test.cjs +27 -0
  37. package/bin/np-tools/research-phase.cjs +7 -37
  38. package/bin/np-tools/researcher-reconcile.cjs +3 -21
  39. package/bin/np-tools/reset-slice.cjs +10 -16
  40. package/bin/np-tools/resolve-model.cjs +21 -26
  41. package/bin/np-tools/resolve-model.test.cjs +15 -5
  42. package/bin/np-tools/resume-work.cjs +1 -5
  43. package/bin/np-tools/skip.cjs +2 -1
  44. package/bin/np-tools/spawn-headless.cjs +138 -19
  45. package/bin/np-tools/spawn-headless.test.cjs +310 -0
  46. package/bin/np-tools/state.cjs +0 -1
  47. package/bin/np-tools/undo-task.cjs +2 -1
  48. package/bin/np-tools/undo.cjs +5 -3
  49. package/bin/np-tools/unpark.cjs +2 -1
  50. package/bin/np-tools/verify-work.cjs +82 -25
  51. package/bin/np-tools/verify-work.test.cjs +211 -1
  52. package/bin/researcher-merge.cjs +2 -1
  53. package/bin/researcher-merge.test.cjs +14 -0
  54. package/lib/agents-registry.cjs +32 -0
  55. package/lib/agents.cjs +14 -6
  56. package/lib/agents.test.cjs +44 -0
  57. package/lib/archive.cjs +102 -36
  58. package/lib/archive.test.cjs +115 -5
  59. package/lib/checkpoint.cjs +43 -23
  60. package/lib/checkpoint.test.cjs +67 -6
  61. package/lib/commit-policy.cjs +3 -1
  62. package/lib/commit-policy.test.cjs +6 -0
  63. package/lib/config-defaults.cjs +5 -1
  64. package/lib/config-defaults.test.cjs +71 -0
  65. package/lib/config-schema.cjs +204 -0
  66. package/lib/config-schema.test.cjs +148 -0
  67. package/lib/config.cjs +168 -14
  68. package/lib/config.test.cjs +234 -0
  69. package/lib/core.cjs +226 -52
  70. package/lib/core.test.cjs +193 -10
  71. package/lib/dashboard.cjs +0 -12
  72. package/lib/frontmatter.cjs +5 -0
  73. package/lib/git.cjs +34 -27
  74. package/lib/git.test.cjs +11 -3
  75. package/lib/handoff.cjs +16 -14
  76. package/lib/handoff.test.cjs +24 -0
  77. package/lib/ids.cjs +6 -0
  78. package/lib/init-emit.cjs +33 -0
  79. package/lib/install/claude-hooks.cjs +46 -25
  80. package/lib/install/claude-hooks.test.cjs +64 -0
  81. package/lib/install/manifest.cjs +19 -0
  82. package/lib/install/manifest.test.cjs +107 -0
  83. package/lib/knowledge-adapter.cjs +3 -49
  84. package/lib/learnings.cjs +3 -108
  85. package/lib/logger.cjs +157 -0
  86. package/lib/logger.test.cjs +159 -0
  87. package/lib/memory-index-usearch.cjs +9 -12
  88. package/lib/memory-provider-local.cjs +8 -0
  89. package/lib/memory.cjs +86 -27
  90. package/lib/memory.test.cjs +135 -0
  91. package/lib/messaging.cjs +155 -83
  92. package/lib/metrics-aggregate.cjs +26 -27
  93. package/lib/metrics.cjs +7 -3
  94. package/lib/metrics.test.cjs +6 -5
  95. package/lib/migrations.cjs +89 -0
  96. package/lib/migrations.test.cjs +82 -0
  97. package/lib/milestone-meta.cjs +70 -0
  98. package/lib/nubosloop-audit.cjs +41 -141
  99. package/lib/nubosloop.cjs +45 -149
  100. package/lib/plan-lint.cjs +0 -67
  101. package/lib/researcher-swarm.cjs +1 -62
  102. package/lib/roadmap-render.cjs +107 -33
  103. package/lib/roadmap-schema.cjs +42 -0
  104. package/lib/roadmap.cjs +93 -20
  105. package/lib/roadmap.test.cjs +215 -0
  106. package/lib/run-context.cjs +54 -0
  107. package/lib/run-context.test.cjs +53 -0
  108. package/lib/runtime/index.cjs +5 -10
  109. package/lib/runtime/index.test.cjs +8 -1
  110. package/lib/safe-path.cjs +156 -0
  111. package/lib/safe-path.test.cjs +164 -0
  112. package/lib/state.cjs +28 -10
  113. package/lib/state.test.cjs +72 -22
  114. package/lib/tasks.cjs +92 -14
  115. package/lib/tasks.test.cjs +65 -0
  116. package/lib/todo.cjs +7 -5
  117. package/lib/verify.cjs +44 -3
  118. package/lib/worktree.cjs +2 -2
  119. package/lib/yaml.cjs +44 -0
  120. package/lib/yaml.test.cjs +65 -0
  121. package/np-tools.cjs +25 -23
  122. package/package.json +5 -2
  123. package/workflows/research-phase.md +1 -1
package/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ All notable changes to nubos-pilot are documented in this file. Format
4
+ follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning
5
+ follows [SemVer](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [1.1.4] — 2026-05-25
8
+
9
+ Public release.
10
+
11
+ - Plan, execute, and verify code changes through a researcher + critic
12
+ agent loop.
13
+ - Wave-based milestone execution; one atomic git commit per task.
14
+ - Multi-runtime install for 14 host CLIs (Claude Code, Codex, Gemini,
15
+ OpenCode, Cursor, and more) via `npx nubos-pilot`.
16
+ - Local vector memory for cross-task learnings.
17
+ - Inter-agent messages, handoffs, and project archive with crash-safe
18
+ resume.
19
+ - Hardened filesystem operations: symlink-rejecting locks, restricted
20
+ permissions on audit logs, path containment for file-input flags,
21
+ frontmatter sanitisation, and a memory-model allow-list.
22
+
23
+ Full documentation at <https://pilot.nubos.cloud>.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Nubos AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -146,6 +146,35 @@ npm test # all unit tests via node:test
146
146
  node bin/check-workflows.cjs # workflow linter
147
147
  ```
148
148
 
149
+ See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for setup, code conventions, ADR
150
+ map and commit format.
151
+
152
+ ## Architecture Decisions
153
+
154
+ ADRs live in the VitePress at
155
+ [`pilot.nubos.cloud/v1/adr/`](https://pilot.nubos.cloud/v1/adr/). The
156
+ load-bearing ones for users and contributors:
157
+
158
+ | ADR | What it pins |
159
+ |---|---|
160
+ | 0004 | `workflow.commit_artifacts` controls whether `.nubos-pilot/` is committed |
161
+ | 0010 | Nubosloop — researcher → executor → critic-schwarm is mandatory in `/np:execute-phase` |
162
+ | 0012 | Completeness doctrine (12 rules in `templates/COMPLETENESS.md`) |
163
+ | 0013 | Learnings-store schema evolution |
164
+ | 0017 | Strict output-schema enforcement |
165
+ | 0019 | Plan-side trust layer (`lib/plan-lint.cjs`) |
166
+
167
+ ## Security
168
+
169
+ See [`SECURITY.md`](./SECURITY.md) for the vulnerability disclosure policy
170
+ and threat model.
171
+
172
+ ## Support
173
+
174
+ - Bugs / features: [GitHub issues](https://github.com/Nubos-AI/nubos-pilot/issues)
175
+ - Security: `security@nubos.ai` (see [`SECURITY.md`](./SECURITY.md))
176
+ - Docs: <https://pilot.nubos.cloud>
177
+
149
178
  ## License
150
179
 
151
- MIT
180
+ MIT — see [`LICENSE`](./LICENSE).
package/SECURITY.md ADDED
@@ -0,0 +1,60 @@
1
+ # Security Policy
2
+
3
+ ## Reporting a Vulnerability
4
+
5
+ If you discover a security issue in nubos-pilot, **do not open a public issue**.
6
+ Email **security@nubos.ai** with:
7
+
8
+ - A description of the issue and its impact.
9
+ - Steps to reproduce (PoC if possible).
10
+ - The affected version (`npx nubos-pilot --version` or check `package.json`).
11
+ - Your preferred contact channel for follow-up.
12
+
13
+ We will acknowledge receipt within **3 business days** and provide a
14
+ resolution plan within **14 business days**. Fixes are released as patch
15
+ versions and announced in `CHANGELOG.md`.
16
+
17
+ ## Supported Versions
18
+
19
+ | Version | Supported |
20
+ |---------|-----------|
21
+ | 0.2.x | ✅ active |
22
+ | < 0.2 | ❌ end of life |
23
+
24
+ Only the latest minor on the current major receives security patches until
25
+ 1.0 is reached.
26
+
27
+ ## Threat Model
28
+
29
+ nubos-pilot is a **local CLI** distributed via npm to developer workstations
30
+ and CI. It is **not** a hosted service. The threat surface and assumptions:
31
+
32
+ | What nubos-pilot reads | What it writes | What it executes |
33
+ |---|---|---|
34
+ | `.nubos-pilot/`, project source for context | `.nubos-pilot/` state, `~/.codex/`, `~/.claude/` config (install only) | `git`, `claude`/`codex` headless via `child_process.spawn` |
35
+
36
+ **Trust boundaries:**
37
+
38
+ - **Project source code** — untrusted in the sense that agent-authored
39
+ files (`PLAN.md`, `RESEARCH.md` etc.) may contain hostile YAML. nubos-pilot
40
+ rejects prototype-pollution keys, refuses symlink-escape via `safe-path`,
41
+ caps message bodies, and whitelists ML model identifiers.
42
+ - **`.nubos-pilot/messages/`** — multi-agent inbox; entries are written
43
+ atomically with `O_CREAT|O_EXCL|O_NOFOLLOW` (POSIX) so a pre-planted
44
+ symlink cannot redirect writes.
45
+ - **Subprocess spawn** — `claude`/`codex` are invoked via `spawnSync` (no
46
+ shell). The binary path is overridable via `NUBOS_PILOT_CLAUDE_BIN` /
47
+ `NUBOS_PILOT_CODEX_BIN`; treat operators who can set those env vars as
48
+ trusted.
49
+ - **`workflow.commit_artifacts`** flag controls whether `.nubos-pilot/`
50
+ artifacts are committed to git. Default is `true`; downstream projects
51
+ that consider artifacts sensitive should set it to `false`.
52
+
53
+ ## What is Out of Scope
54
+
55
+ - Vulnerabilities in `@huggingface/transformers`, `usearch`, or the
56
+ `yaml` package — report those upstream.
57
+ - Operator-controlled config (`config.json`) that the operator themselves
58
+ wrote — config is trusted input from the project owner.
59
+ - DoS from running nubos-pilot in obviously bad conditions
60
+ (no disk space, no Node 22+, broken `git`).
package/bin/install.js CHANGED
@@ -5,7 +5,7 @@ const fs = require('node:fs');
5
5
  const path = require('node:path');
6
6
  const os = require('node:os');
7
7
 
8
- const { atomicWriteFileSync, withFileLock, NubosPilotError } = require('../lib/core.cjs');
8
+ const { atomicWriteFileSync, withFileLock, installSignalCleanup, NubosPilotError } = require('../lib/core.cjs');
9
9
  const { askUser: defaultAskUser } = require('../lib/askuser.cjs');
10
10
  const manifestMod = require('../lib/install/manifest.cjs');
11
11
  const stagingMod = require('../lib/install/staging.cjs');
@@ -159,6 +159,10 @@ function _renderShim(target, mode) {
159
159
  return '#!/usr/bin/env node\n'
160
160
  + "'use strict';\n"
161
161
  + 'const fs = require(\'node:fs\');\n'
162
+ + 'if (Number(process.versions.node.split(\'.\')[0]) < 22) {\n'
163
+ + ' process.stderr.write("nubos-pilot: requires Node >= 22 (running " + process.versions.node + ")\\n");\n'
164
+ + ' process.exit(1);\n'
165
+ + '}\n'
162
166
  + 'const TARGET = ' + JSON.stringify(target) + ';\n'
163
167
  + 'if (!fs.existsSync(TARGET)) {\n'
164
168
  + ' process.stderr.write("nubos-pilot: tool binary fehlt unter " + TARGET + "\\nFix: npx nubos-pilot@latest update\\n");\n'
@@ -170,12 +174,18 @@ function _renderShim(target, mode) {
170
174
  + "'use strict';\n"
171
175
  + 'const fs = require(\'node:fs\');\n'
172
176
  + 'const { spawn } = require(\'node:child_process\');\n'
177
+ + 'if (Number(process.versions.node.split(\'.\')[0]) < 22) {\n'
178
+ + ' process.stderr.write("nubos-pilot: requires Node >= 22 (running " + process.versions.node + ")\\n");\n'
179
+ + ' process.exit(1);\n'
180
+ + '}\n'
173
181
  + 'const TARGET = ' + JSON.stringify(target) + ';\n'
174
182
  + 'if (!fs.existsSync(TARGET)) {\n'
175
183
  + ' process.stderr.write("nubos-pilot: tool binary fehlt unter " + TARGET + "\\nFix: npx nubos-pilot@latest update\\n");\n'
176
184
  + ' process.exit(1);\n'
177
185
  + '}\n'
178
186
  + 'const child = spawn(process.execPath, [TARGET, ...process.argv.slice(2)], { stdio: \'inherit\' });\n'
187
+ + 'child.on(\'error\', (err) => { process.stderr.write("nubos-pilot shim: " + (err && err.message ? err.message : String(err)) + "\\n"); process.exit(1); });\n'
188
+ + 'for (const s of [\'SIGINT\', \'SIGTERM\', \'SIGHUP\']) { process.on(s, () => { try { child.kill(s); } catch {} }); }\n'
179
189
  + 'child.on(\'exit\', (code, sig) => { if (sig) process.kill(process.pid, sig); else process.exit(code == null ? 1 : code); });\n';
180
190
  }
181
191
 
@@ -197,24 +207,38 @@ function _stateDirFor(projectRoot) {
197
207
  return path.join(projectRoot, STATE_SUBPATH);
198
208
  }
199
209
 
200
- function _readExistingScope(projectRoot) {
210
+ function _readInstallConfig(projectRoot) {
201
211
  const cfgPath = path.join(_stateDirFor(projectRoot), 'config.json');
202
212
  if (!fs.existsSync(cfgPath)) return null;
213
+ const { _CONFIG_PARSE_CODES, readConfig } = require('../lib/config.cjs');
214
+ const { NubosPilotError } = require('../lib/core.cjs');
203
215
  try {
204
- const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
205
- return cfg && cfg.scope ? cfg.scope : null;
206
- } catch { return null; }
216
+ return readConfig(projectRoot);
217
+ } catch (err) {
218
+ if (err && err.code === 'not-in-project') return null;
219
+ if (err && _CONFIG_PARSE_CODES.has(err.code)) {
220
+ throw new NubosPilotError(
221
+ 'install-config-unusable',
222
+ 'install refused — .nubos-pilot/config.json is unusable (' + err.code
223
+ + '). Repair or delete the file and re-run.',
224
+ { cause: err.code },
225
+ );
226
+ }
227
+ throw err;
228
+ }
229
+ }
230
+
231
+ function _readExistingScope(projectRoot) {
232
+ const cfg = _readInstallConfig(projectRoot);
233
+ return cfg && cfg.scope ? cfg.scope : null;
207
234
  }
208
235
 
209
236
  function _readExistingRuntimes(projectRoot) {
210
- const cfgPath = path.join(_stateDirFor(projectRoot), 'config.json');
211
- if (!fs.existsSync(cfgPath)) return null;
212
- try {
213
- const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
214
- if (Array.isArray(cfg.runtimes) && cfg.runtimes.length) return cfg.runtimes.slice();
215
- if (cfg.runtime) return [cfg.runtime];
216
- return null;
217
- } catch { return null; }
237
+ const cfg = _readInstallConfig(projectRoot);
238
+ if (!cfg) return null;
239
+ if (Array.isArray(cfg.runtimes) && cfg.runtimes.length) return cfg.runtimes.slice();
240
+ if (cfg.runtime) return [cfg.runtime];
241
+ return null;
218
242
  }
219
243
 
220
244
  function detectMode(projectRoot, scope) {
@@ -277,10 +301,19 @@ async function _runInitQuestions(detectedRuntime, askUser, flags) {
277
301
  const model_profile = (await askUser({ type: 'select', question: 'Model-Profile?',
278
302
  options: ['frontier', 'quality', 'balanced', 'budget', 'inherit'], default: 'frontier' })).value;
279
303
  const response_language = (await askUser({ type: 'input', question: 'Response language (ISO-639 code)?', default: 'en' })).value;
304
+ // Wizard / --yes default is intentionally `false` (safer-by-default per
305
+ // FIX-B2) even though the implicit code default lives at `true` in
306
+ // DEFAULT_WORKFLOW (ADR-0004). The two are NOT in drift: explicit answer
307
+ // overrides default; absent key falls back to ADR-0004 true. This is
308
+ // covered by tests/install/install-flags.test.cjs:85.
309
+ const commit_artifacts = (await askUser({ type: 'confirm',
310
+ question: 'Auto-commit nubos-pilot planning artefacts (.nubos-pilot/ — milestones, roadmap, learnings) into your git repo?',
311
+ default: false })).value;
280
312
  return configDefaults.buildInstallConfig({
281
313
  runtime, runtimes, scope,
282
314
  model_profile,
283
315
  response_language,
316
+ commit_artifacts,
284
317
  });
285
318
  }
286
319
 
@@ -476,8 +509,19 @@ async function _runInstallLocked(ctx) {
476
509
  }
477
510
 
478
511
  stagingMod.finalizeSwap(payloadBase);
512
+ const resolvedPayloadDir = path.resolve(payloadDir);
479
513
  for (const rel of diff.stale) {
480
- try { fs.unlinkSync(path.join(payloadDir, rel)); } catch {}
514
+ manifestMod.assertSafeManifestKey(rel, 'install-stale-cleanup');
515
+ const abs = path.join(payloadDir, rel);
516
+ const resolvedAbs = path.resolve(abs);
517
+ if (!(resolvedAbs === resolvedPayloadDir || resolvedAbs.startsWith(resolvedPayloadDir + path.sep))) {
518
+ throw new NubosPilotError(
519
+ 'manifest-unlink-outside-base',
520
+ 'Refusing unlink that escapes payloadDir',
521
+ { rel, base: path.basename(payloadDir) },
522
+ );
523
+ }
524
+ try { fs.unlinkSync(abs); } catch {}
481
525
  }
482
526
 
483
527
  if (opencodeManifest) {
@@ -503,9 +547,20 @@ async function _runInstallLocked(ctx) {
503
547
  const opencodeBase = resolvedScope === 'global' ? os.homedir() : projectRoot;
504
548
  for (const rel of diff.stale) {
505
549
  if (rel.startsWith(opencodeManifestPrefix)) {
550
+ manifestMod.assertSafeManifestKey(rel, 'install-opencode-stale');
506
551
  const relFs = rel.startsWith('~/')
507
552
  ? path.join(os.homedir(), rel.slice(2))
508
553
  : path.join(opencodeBase, rel);
554
+ const expectedBase = rel.startsWith('~/') ? os.homedir() : opencodeBase;
555
+ const resolvedRelFs = path.resolve(relFs);
556
+ const resolvedExpected = path.resolve(expectedBase);
557
+ if (!(resolvedRelFs === resolvedExpected || resolvedRelFs.startsWith(resolvedExpected + path.sep))) {
558
+ throw new NubosPilotError(
559
+ 'manifest-unlink-outside-base',
560
+ 'Refusing opencode unlink that escapes its base',
561
+ { rel, base: path.basename(expectedBase) },
562
+ );
563
+ }
509
564
  try { fs.unlinkSync(relFs); } catch {}
510
565
  }
511
566
  }
@@ -594,14 +649,11 @@ function _runUninstallLocked(projectRoot) {
594
649
  return { uninstalled: false };
595
650
  }
596
651
 
652
+ // Reuse the SAME validator as readManifest so a legitimate key like
653
+ // `..bar` (no traversal segment) isn't false-rejected here while passing
654
+ // validation upstream. Single source of truth lives in manifest.cjs.
597
655
  for (const rel of Object.keys(manifest.files)) {
598
- if (rel.includes('..') || path.isAbsolute(rel)) {
599
- throw new NubosPilotError(
600
- 'manifest-path-traversal',
601
- 'Manifest contains suspicious path',
602
- { rel },
603
- );
604
- }
656
+ manifestMod.assertSafeManifestKey(rel, 'uninstall');
605
657
  }
606
658
 
607
659
  const payloadBase = scope === 'global' ? os.homedir() : projectRoot;
@@ -612,6 +664,20 @@ function _runUninstallLocked(projectRoot) {
612
664
  const abs = rel.startsWith('~/')
613
665
  ? path.join(os.homedir(), rel.slice(2))
614
666
  : isAsset ? path.join(payloadBase, rel) : path.join(payloadDir, rel);
667
+ // Defense-in-depth: even with the validator above, ensure the resolved
668
+ // path lives inside its expected base. A symlink or future-validator
669
+ // regression cannot escape this prefix check.
670
+ const expectedBase = rel.startsWith('~/') ? os.homedir()
671
+ : isAsset ? payloadBase : payloadDir;
672
+ const resolvedAbs = path.resolve(abs);
673
+ const resolvedBase = path.resolve(expectedBase);
674
+ if (!(resolvedAbs === resolvedBase || resolvedAbs.startsWith(resolvedBase + path.sep))) {
675
+ throw new NubosPilotError(
676
+ 'manifest-unlink-outside-base',
677
+ 'Refusing unlink that escapes its payload base',
678
+ { rel, base: path.basename(expectedBase) },
679
+ );
680
+ }
615
681
  try {
616
682
  fs.unlinkSync(abs);
617
683
  removed++;
@@ -639,12 +705,11 @@ function _runUninstallLocked(projectRoot) {
639
705
 
640
706
  try { fs.rmdirSync(payloadDir); } catch {}
641
707
 
642
- const cfgPath = path.join(_stateDirFor(projectRoot), 'config.json');
643
708
  let installedRuntimes = [];
644
- try {
645
- const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
709
+ const cfg = _readInstallConfig(projectRoot);
710
+ if (cfg) {
646
711
  installedRuntimes = cfg.runtimes || (cfg.runtime ? [cfg.runtime] : []);
647
- } catch {}
712
+ }
648
713
 
649
714
  const legacyFiles = ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md'];
650
715
  const extraFiles = [];
@@ -793,21 +858,21 @@ async function runUninstallHooks(opts) {
793
858
  }
794
859
 
795
860
  if (require.main === module) {
796
- main().catch((err) => {
797
- if (err && err.code) {
798
- process.stderr.write(
799
- JSON.stringify({
800
- error: {
801
- code: err.code,
802
- message: err.message,
803
- details: err.details || null,
804
- },
805
- }) + '\n',
806
- );
807
- } else {
808
- process.stderr.write(((err && err.stack) || String(err)) + '\n');
809
- }
861
+ if (Number(process.versions.node.split('.')[0]) < 22) {
862
+ process.stderr.write('nubos-pilot: requires Node >= 22 (running ' + process.versions.node + ')\n');
810
863
  process.exit(1);
864
+ }
865
+ installSignalCleanup();
866
+ main().catch((err) => {
867
+ const payload = (err && err.code)
868
+ ? JSON.stringify({ error: { code: err.code, message: err.message, details: err.details || null } }) + '\n'
869
+ : ((err && err.stack) || String(err)) + '\n';
870
+ // Drain stderr before exit. process.exit() can otherwise tear down the
871
+ // pipe mid-flush on busy CI, truncating the envelope. Set exitCode and
872
+ // let Node drain naturally; force-exit only as a last-resort fallback.
873
+ try { process.stderr.write(payload); } catch {}
874
+ process.exitCode = 1;
875
+ setTimeout(() => process.exit(1), 1000).unref();
811
876
  });
812
877
  }
813
878
 
@@ -2,9 +2,15 @@
2
2
 
3
3
  const { NubosPilotError } = require('../../lib/core.cjs');
4
4
 
5
- function getFlag(rest, name) {
5
+ function getFlag(rest, name, opts) {
6
6
  const idx = rest.indexOf(name);
7
- return idx !== -1 ? rest[idx + 1] : undefined;
7
+ if (idx === -1) return undefined;
8
+ const next = rest[idx + 1];
9
+ const allowDash = opts && opts.allowDashValues === true;
10
+ if (!allowDash && typeof next === 'string' && next.startsWith('--')) {
11
+ return undefined;
12
+ }
13
+ return next;
8
14
  }
9
15
 
10
16
  function getJsonFlag(rest, name, missingCode, hint) {
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { NubosPilotError } = require('../../lib/core.cjs');
4
- const { readConfigPath } = require('../../lib/config.cjs');
4
+ const { tryReadConfigPath } = require('../../lib/config.cjs');
5
5
  const { createMemory } = require('../../lib/memory.cjs');
6
6
 
7
7
  function resolveMemory(opts) {
@@ -17,7 +17,7 @@ function resolveMemory(opts) {
17
17
  });
18
18
  }
19
19
 
20
- const enabled = readConfigPath(cwd, 'memory.enabled', false);
20
+ const enabled = tryReadConfigPath(cwd, 'memory.enabled', false);
21
21
  if (!enabled) {
22
22
  throw new NubosPilotError(
23
23
  'memory-disabled',
@@ -26,8 +26,8 @@ function resolveMemory(opts) {
26
26
  );
27
27
  }
28
28
 
29
- const model = readConfigPath(cwd, 'memory.model', 'Xenova/bge-small-en-v1.5');
30
- const alpha = readConfigPath(cwd, 'memory.alpha', 0.6);
29
+ const model = tryReadConfigPath(cwd, 'memory.model', 'Xenova/bge-small-en-v1.5');
30
+ const alpha = tryReadConfigPath(cwd, 'memory.alpha', 0.6);
31
31
 
32
32
  const { createLocalProvider } = require('../../lib/memory-provider-local.cjs');
33
33
  const { createUsearchIndex } = require('../../lib/memory-index-usearch.cjs');
@@ -1,5 +1,5 @@
1
1
  const { NubosPilotError } = require('../../lib/core.cjs');
2
- const { TASK_ID_RE } = require('../../lib/tasks.cjs');
2
+ const { TASK_ID_RE } = require('../../lib/ids.cjs');
3
3
  const {
4
4
  startTask,
5
5
  writeCheckpoint,
@@ -1,36 +1,10 @@
1
1
  'use strict';
2
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');
3
+ const { NubosPilotError } = require('../../lib/core.cjs');
4
+ const { emitInitPayload } = require('../../lib/init-emit.cjs');
12
5
  const archive = require('../../lib/archive.cjs');
13
6
  const textMode = require('../../lib/text-mode.cjs');
14
7
 
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
8
  function _initPayload(cwd) {
35
9
  const completion = archive.computeCompletionStatus(cwd);
36
10
  const tmDetail = textMode.resolveTextModeDetail(cwd);
@@ -68,7 +42,7 @@ function run(args, ctx) {
68
42
  case 'init':
69
43
  case undefined: {
70
44
  const payload = _initPayload(cwd);
71
- _emit(payload, stdout, cwd);
45
+ emitInitPayload(payload, stdout, cwd, 'close-project');
72
46
  return payload;
73
47
  }
74
48
  case 'check': {
@@ -1,28 +1,18 @@
1
1
  const fs = require('node:fs');
2
2
  const path = require('node:path');
3
+ const safePath = require('../../lib/safe-path.cjs');
3
4
 
4
5
  const { NubosPilotError, findProjectRoot } = require('../../lib/core.cjs');
5
6
  const { extractFrontmatter } = require('../../lib/frontmatter.cjs');
6
- const { TASK_ID_RE, setTaskStatus } = require('../../lib/tasks.cjs');
7
+ const { setTaskStatus } = require('../../lib/tasks.cjs');
8
+ const { TASK_ID_RE } = require('../../lib/ids.cjs');
7
9
  const layout = require('../../lib/layout.cjs');
8
10
  const git = require('../../lib/git.cjs');
9
11
  const { commitTask, findCommitByTaskId } = git;
10
- const { deleteCheckpoint, readCheckpoint, mergeCheckpoint } = require('../../lib/checkpoint.cjs');
12
+ const { finishTask, readCheckpoint, mergeCheckpoint } = require('../../lib/checkpoint.cjs');
11
13
 
12
14
  const BYPASS_FLAG = '--bypass-nubosloop';
13
15
 
14
- // Evidence-based gate: a complete Nubosloop run accumulates fields on the
15
- // checkpoint envelope (cache_hit from preflight, verify_exit_code from
16
- // post-executor, findings from post-critics, committed_at from commit). A
17
- // gamed run that only invokes `loop-run-round --phase commit` directly leaves
18
- // verify_exit_code and findings undefined. Checking last_phase alone is not
19
- // enough — we require the cumulative signature.
20
- //
21
- // `evaluateLoop` only routes `next_action='commit'` when `findings.length === 0`
22
- // (see lib/nubosloop.cjs). The previous gate accepted `Array.isArray(findings)`
23
- // alone — a critic that returned actual findings still satisfied the shape
24
- // check, letting the commit slip through. Mirror the evaluator's invariant
25
- // here so a non-empty findings array is a hard refuse, not an accident.
26
16
  function _assertLoopGate(taskId, cwd, bypass, stderr) {
27
17
  const cp = readCheckpoint(taskId, cwd);
28
18
  const np = (cp && cp.nubosloop) || null;
@@ -85,24 +75,33 @@ function _resolveTaskFile(taskId, cwd) {
85
75
  }
86
76
 
87
77
  function _resolveSafe(root, p) {
88
- const abs = path.resolve(root, p);
89
- const rel = path.relative(root, abs);
90
- if (rel.startsWith('..') || path.isAbsolute(rel)) {
91
- throw new NubosPilotError(
92
- 'path-not-in-project',
93
- 'files_modified entry escapes project root: ' + p,
94
- { path: p, root },
95
- );
78
+ try {
79
+ safePath.assertInsideBase(root, path.resolve(root, p), 'commit-files');
80
+ } catch (err) {
81
+ if (err && (err.code === 'safe-path-outside-base' || err.code === 'safe-path-invalid-input' || err.code === 'safe-path-base-missing')) {
82
+ throw new NubosPilotError(
83
+ 'path-not-in-project',
84
+ 'files_modified entry escapes project root: ' + p,
85
+ { path: p, root, cause: err.code },
86
+ );
87
+ }
88
+ throw err;
96
89
  }
97
90
  return p;
98
91
  }
99
92
 
100
- function _extractName(frontmatter, body) {
101
- if (typeof frontmatter.name === 'string' && frontmatter.name.length > 0) return frontmatter.name;
93
+ const _COMMIT_NAME_MAX = 200;
94
+ function _sanitizeCommitName(s) {
95
+ return String(s == null ? '' : s).replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, _COMMIT_NAME_MAX);
96
+ }
102
97
 
98
+ function _extractName(frontmatter, body) {
99
+ if (typeof frontmatter.name === 'string' && frontmatter.name.length > 0) {
100
+ return _sanitizeCommitName(frontmatter.name);
101
+ }
103
102
  const m = String(body || '').match(/^#\s+(?:Task:\s*)?(.+?)\s*$/m);
104
- if (m) return m[1].trim();
105
- return frontmatter.id || 'task';
103
+ if (m) return _sanitizeCommitName(m[1]);
104
+ return _sanitizeCommitName(frontmatter.id || 'task');
106
105
  }
107
106
 
108
107
  function run(args, ctx) {
@@ -163,13 +162,6 @@ function run(args, ctx) {
163
162
  const result = commitTask(taskId, safeFiles, message);
164
163
 
165
164
  if (result.committed === false && result.reason === 'artifacts-gitignored') {
166
- // Soft-skip: every files_modified entry is gitignored. The task ran the
167
- // full Nubosloop (preflight → executor → critic), edits landed locally,
168
- // and the workflow already stamped `committed_at` via loop-run-round.
169
- // We mark the task done WITHOUT a git commit, record the skip reason on
170
- // the checkpoint for audit, and let the wave continue. Symmetric to
171
- // commit_artifacts=false (commit.cjs:102) and to feedback_no_container_blocker:
172
- // gitignore is a routing signal, never a hard stop.
173
165
  try {
174
166
  mergeCheckpoint(taskId, (cur) => ({
175
167
  nubosloop: Object.assign({}, (cur && cur.nubosloop) || {}, {
@@ -180,7 +172,9 @@ function run(args, ctx) {
180
172
  } catch (err) {
181
173
  process.stderr.write('[nubos-pilot warn] checkpoint stamp failed for ' + taskId + ': ' + (err && err.message) + '\n');
182
174
  }
183
- try { deleteCheckpoint(taskId, cwd); } catch {}
175
+ try { finishTask(taskId, cwd); } catch (err) {
176
+ process.stderr.write('[nubos-pilot warn] finishTask failed for ' + taskId + ': ' + (err && err.message) + '\n');
177
+ }
184
178
  try { setTaskStatus(taskId, 'done', cwd); } catch (err) {
185
179
  process.stderr.write('[nubos-pilot warn] setTaskStatus failed for ' + taskId + ': ' + (err && err.message) + '\n');
186
180
  }
@@ -201,7 +195,9 @@ function run(args, ctx) {
201
195
 
202
196
  const sha = findCommitByTaskId(taskId);
203
197
 
204
- try { deleteCheckpoint(taskId, cwd); } catch {}
198
+ try { finishTask(taskId, cwd); } catch (err) {
199
+ process.stderr.write('[nubos-pilot warn] finishTask failed for ' + taskId + ': ' + (err && err.message) + '\n');
200
+ }
205
201
  try { setTaskStatus(taskId, 'done', cwd); } catch (err) {
206
202
  process.stderr.write('[nubos-pilot warn] setTaskStatus failed for ' + taskId + ': ' + (err && err.message) + '\n');
207
203
  }
@@ -101,9 +101,6 @@ function run(argv, ctx) {
101
101
  const normalized = _normalizeFiles(files, cwd, root);
102
102
  const committable = assertCommittablePaths(normalized, { cwd: root });
103
103
  if (committable.length === 0) {
104
- // All paths gitignored → soft-skip with structured payload (symmetric to
105
- // commit_artifacts=false above). The earlier `commit-no-paths` throw
106
- // turned a routing signal into a hard error.
107
104
  stdout.write(JSON.stringify({
108
105
  committed: false,
109
106
  reason: 'artifacts-gitignored',
@@ -1,6 +1,4 @@
1
- const fs = require('node:fs');
2
- const path = require('node:path');
3
- const { findProjectRoot, NubosPilotError } = require('../../lib/core.cjs');
1
+ const { NubosPilotError } = require('../../lib/core.cjs');
4
2
  const { DEFAULT_CONFIG_TREE } = require('../../lib/config-defaults.cjs');
5
3
  const { emitErrorEnvelope } = require('./_args.cjs');
6
4
 
@@ -12,21 +10,14 @@ function _usage() {
12
10
  }
13
11
 
14
12
  function _readConfig(cwd) {
15
- let root;
13
+ const { readConfig } = require('../../lib/config.cjs');
16
14
  try {
17
- root = findProjectRoot(cwd);
15
+ const cfg = readConfig(cwd);
16
+ return cfg && Object.keys(cfg).length === 0 ? null : cfg;
18
17
  } catch (err) {
19
18
  if (err && err.code === 'not-in-project') return null;
20
19
  throw err;
21
20
  }
22
- const p = path.join(root, '.nubos-pilot', 'config.json');
23
- if (!fs.existsSync(p)) return null;
24
- try {
25
- const parsed = JSON.parse(fs.readFileSync(p, 'utf-8'));
26
- return parsed && typeof parsed === 'object' ? parsed : null;
27
- } catch (err) {
28
- throw new NubosPilotError('config-parse-error', 'config.json invalid JSON', { cause: err && err.message });
29
- }
30
21
  }
31
22
 
32
23
  function _walkPath(obj, segments) {