nubos-pilot 1.3.2 → 1.3.4

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 (38) hide show
  1. package/CHANGELOG.md +5 -2
  2. package/agents/np-critic-economy.md +103 -0
  3. package/agents/np-critic.md +11 -10
  4. package/agents/np-executor.md +14 -0
  5. package/agents/np-simplifier.md +83 -0
  6. package/agents/np-task-architect.md +95 -0
  7. package/agents/np-test-writer.md +89 -0
  8. package/bin/install.js +86 -0
  9. package/bin/np-tools/_commands.cjs +2 -0
  10. package/bin/np-tools/commit-task.cjs +80 -6
  11. package/bin/np-tools/commit-task.test.cjs +133 -0
  12. package/bin/np-tools/doctor.cjs +1 -0
  13. package/bin/np-tools/economy-mode.cjs +47 -0
  14. package/bin/np-tools/loop-commands.test.cjs +121 -2
  15. package/bin/np-tools/loop-run-round.cjs +122 -6
  16. package/bin/np-tools/resolve-model.cjs +1 -0
  17. package/bin/np-tools/simplify-debt.cjs +91 -0
  18. package/bin/np-tools/simplify-debt.test.cjs +99 -0
  19. package/lib/agents-registry.cjs +12 -1
  20. package/lib/agents.test.cjs +4 -0
  21. package/lib/config-defaults.cjs +22 -1
  22. package/lib/config-defaults.test.cjs +9 -0
  23. package/lib/config-schema.cjs +6 -0
  24. package/lib/economy-debt.cjs +235 -0
  25. package/lib/economy-debt.test.cjs +131 -0
  26. package/lib/economy-mode.cjs +66 -0
  27. package/lib/economy-mode.test.cjs +85 -0
  28. package/lib/git.cjs +6 -2
  29. package/lib/git.test.cjs +28 -0
  30. package/lib/nubosloop.cjs +4 -0
  31. package/lib/nubosloop.test.cjs +1 -0
  32. package/np-tools.cjs +2 -0
  33. package/package.json +1 -1
  34. package/templates/RULES.md +36 -1
  35. package/workflows/execute-phase.md +154 -1
  36. package/workflows/plan-phase.md +17 -2
  37. package/workflows/simplify-debt.md +93 -0
  38. package/workflows/simplify-review.md +103 -0
@@ -0,0 +1,131 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const { test, afterEach } = require('node:test');
7
+ const assert = require('node:assert/strict');
8
+
9
+ const debt = require('./economy-debt.cjs');
10
+
11
+ const _sandboxes = [];
12
+
13
+ function makeSandbox() {
14
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'np-economy-debt-'));
15
+ fs.mkdirSync(path.join(root, '.nubos-pilot'), { recursive: true });
16
+ _sandboxes.push(root);
17
+ return root;
18
+ }
19
+
20
+ afterEach(() => {
21
+ while (_sandboxes.length) {
22
+ try { fs.rmSync(_sandboxes.pop(), { recursive: true, force: true }); } catch { /* best effort */ }
23
+ }
24
+ });
25
+
26
+ test('ED-1: addEntry writes an open entry and returns was_new=true', () => {
27
+ const cwd = makeSandbox();
28
+ const e = debt.addEntry(
29
+ { file: 'src/foo.ts', line: 42, category: 'over-engineering', note: 'Single-use factory — inline it.' },
30
+ cwd,
31
+ );
32
+ assert.equal(e.was_new, true);
33
+ assert.equal(e.status, 'open');
34
+ assert.equal(e.category, 'over-engineering');
35
+ assert.equal(e.file, 'src/foo.ts');
36
+ assert.equal(e.line, 42);
37
+ assert.match(e.id, /^[0-9a-f]{7}$/);
38
+ assert.ok(fs.existsSync(e.path));
39
+ });
40
+
41
+ test('ED-2: addEntry is idempotent — identical input does not duplicate', () => {
42
+ const cwd = makeSandbox();
43
+ const first = debt.addEntry(
44
+ { file: 'a.ts', line: 1, category: 'shrinkable', note: 'manual reduce -> Array.reduce' },
45
+ cwd,
46
+ );
47
+ const second = debt.addEntry(
48
+ { file: 'a.ts', line: 1, category: 'shrinkable', note: 'manual reduce -> Array.reduce' },
49
+ cwd,
50
+ );
51
+ assert.equal(first.id, second.id);
52
+ assert.equal(second.was_new, false);
53
+ assert.equal(debt.listEntries('open', cwd).length, 1);
54
+ });
55
+
56
+ test('ED-3: addEntry rejects a category outside the four economy routes', () => {
57
+ const cwd = makeSandbox();
58
+ assert.throws(
59
+ () => debt.addEntry({ file: 'a.ts', category: 'security', note: 'x' }, cwd),
60
+ (err) => err && err.name === 'NubosPilotError' && err.code === 'economy-debt-invalid-category',
61
+ );
62
+ });
63
+
64
+ test('ED-4: addEntry rejects an empty note', () => {
65
+ const cwd = makeSandbox();
66
+ assert.throws(
67
+ () => debt.addEntry({ file: 'a.ts', category: 'shrinkable', note: ' ' }, cwd),
68
+ (err) => err && err.name === 'NubosPilotError' && err.code === 'economy-debt-missing-note',
69
+ );
70
+ });
71
+
72
+ test('ED-5: line defaults to 0 (file-level) when omitted', () => {
73
+ const cwd = makeSandbox();
74
+ const e = debt.addEntry({ file: 'a.ts', category: 'native-duplication', note: 'reimplements framework helper' }, cwd);
75
+ assert.equal(e.line, 0);
76
+ const parsed = debt.listEntries('open', cwd)[0];
77
+ assert.equal(parsed.line, 0);
78
+ });
79
+
80
+ test('ED-6: listEntries sorts oldest-first and round-trips note + fields', () => {
81
+ const cwd = makeSandbox();
82
+ debt.addEntry({ file: 'a.ts', line: 5, category: 'shrinkable', note: 'first' }, cwd);
83
+ debt.addEntry({ file: 'b.ts', line: 9, category: 'over-engineering', note: 'second' }, cwd);
84
+ const list = debt.listEntries('open', cwd);
85
+ assert.equal(list.length, 2);
86
+ assert.equal(list[0].note, 'first');
87
+ assert.equal(list[1].note, 'second');
88
+ assert.equal(list[1].category, 'over-engineering');
89
+ assert.equal(list[1].line, 9);
90
+ });
91
+
92
+ test('ED-7: resolveEntry moves open -> resolved and stamps resolved time', () => {
93
+ const cwd = makeSandbox();
94
+ const e = debt.addEntry({ file: 'a.ts', line: 1, category: 'stdlib-reinvention', note: 'hand-rolled clamp' }, cwd);
95
+ const r = debt.resolveEntry(e.id, cwd);
96
+ assert.equal(r.status, 'resolved');
97
+ assert.match(r.resolved, /^\d{4}-\d{2}-\d{2}T/);
98
+ assert.equal(debt.listEntries('open', cwd).length, 0);
99
+ assert.equal(debt.listEntries('resolved', cwd).length, 1);
100
+ assert.equal(debt.listEntries('all', cwd).length, 1);
101
+ assert.ok(!fs.existsSync(e.path));
102
+ });
103
+
104
+ test('ED-8: resolveEntry throws economy-debt-not-found for an unknown id', () => {
105
+ const cwd = makeSandbox();
106
+ assert.throws(
107
+ () => debt.resolveEntry('deadbee', cwd),
108
+ (err) => err && err.name === 'NubosPilotError' && err.code === 'economy-debt-not-found',
109
+ );
110
+ });
111
+
112
+ test('ED-9: listEntries rejects an invalid status', () => {
113
+ const cwd = makeSandbox();
114
+ assert.throws(
115
+ () => debt.listEntries('bogus', cwd),
116
+ (err) => err && err.name === 'NubosPilotError' && err.code === 'economy-debt-invalid-status',
117
+ );
118
+ });
119
+
120
+ test('ED-10: empty ledger lists as []', () => {
121
+ const cwd = makeSandbox();
122
+ assert.deepEqual(debt.listEntries('open', cwd), []);
123
+ assert.deepEqual(debt.listEntries('all', cwd), []);
124
+ });
125
+
126
+ test('ED-11: ECONOMY_CATEGORIES matches the four canonical economy routes', () => {
127
+ assert.deepEqual(
128
+ debt.ECONOMY_CATEGORIES.slice().sort(),
129
+ ['native-duplication', 'over-engineering', 'shrinkable', 'stdlib-reinvention'],
130
+ );
131
+ });
@@ -0,0 +1,66 @@
1
+ 'use strict';
2
+
3
+ // Single source of truth for the Economy axis activation level (Ponytail-style
4
+ // graduated modes). The Economy schicht has two mechanisms — the prevention
5
+ // ladder in agents/np-executor.md (guidance BEFORE writing) and the in-loop
6
+ // Economy critic (agents/np-critic-economy.md, audits the diff AFTER). One
7
+ // enum dials both:
8
+ //
9
+ // off prevention OFF, critic OFF — no economy pressure at all
10
+ // lite prevention ON, critic OFF — prevention-first DEFAULT (advisory only)
11
+ // full prevention ON, critic ON — standard critic rubric
12
+ // ultra prevention ON, critic ON — aggressive critic (lowered shrinkable bar)
13
+ //
14
+ // Default is `lite`: the climb-the-ladder discipline is on, but nothing bounces
15
+ // work back. This makes prevention-first the documented default philosophy
16
+ // while keeping the costlier critic opt-in (full/ultra).
17
+ //
18
+ // Backward-compat: the legacy boolean `agents.economy_critic` is honoured when
19
+ // `agents.economy` is absent — true→full, false→lite — so a pre-existing
20
+ // gitignored config keeps its behaviour. The resolver is LOUD: an explicit but
21
+ // invalid `agents.economy` string throws rather than silently defaulting.
22
+
23
+ const { NubosPilotError } = require('./core.cjs');
24
+
25
+ const VALID_ECONOMY_MODES = Object.freeze(['off', 'lite', 'full', 'ultra']);
26
+ const DEFAULT_ECONOMY_MODE = 'lite';
27
+
28
+ function resolveEconomyMode(config) {
29
+ const agents = config && typeof config === 'object' ? config.agents : null;
30
+ if (agents && typeof agents === 'object') {
31
+ if (agents.economy !== undefined) {
32
+ const explicit = agents.economy;
33
+ if (typeof explicit !== 'string' || !VALID_ECONOMY_MODES.includes(explicit)) {
34
+ throw new NubosPilotError(
35
+ 'config-invalid-economy-mode',
36
+ 'agents.economy must be one of ' + VALID_ECONOMY_MODES.join('|') + ' (got: ' + JSON.stringify(explicit) + ')',
37
+ { value: explicit, valid: VALID_ECONOMY_MODES },
38
+ );
39
+ }
40
+ return explicit;
41
+ }
42
+ if (typeof agents.economy_critic === 'boolean') {
43
+ return agents.economy_critic ? 'full' : 'lite';
44
+ }
45
+ }
46
+ return DEFAULT_ECONOMY_MODE;
47
+ }
48
+
49
+ function preventionOn(mode) { return mode !== 'off'; }
50
+ function criticOn(mode) { return mode === 'full' || mode === 'ultra'; }
51
+ function isUltra(mode) { return mode === 'ultra'; }
52
+
53
+ function economyFlags(config) {
54
+ const mode = resolveEconomyMode(config);
55
+ return { mode, prevention: preventionOn(mode), critic: criticOn(mode), ultra: isUltra(mode) };
56
+ }
57
+
58
+ module.exports = {
59
+ VALID_ECONOMY_MODES,
60
+ DEFAULT_ECONOMY_MODE,
61
+ resolveEconomyMode,
62
+ preventionOn,
63
+ criticOn,
64
+ isUltra,
65
+ economyFlags,
66
+ };
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ const { test } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+
6
+ const {
7
+ VALID_ECONOMY_MODES,
8
+ DEFAULT_ECONOMY_MODE,
9
+ resolveEconomyMode,
10
+ preventionOn,
11
+ criticOn,
12
+ isUltra,
13
+ economyFlags,
14
+ } = require('./economy-mode.cjs');
15
+
16
+ test('default is lite (prevention-first) when nothing is set', () => {
17
+ assert.equal(DEFAULT_ECONOMY_MODE, 'lite');
18
+ assert.equal(resolveEconomyMode({}), 'lite');
19
+ assert.equal(resolveEconomyMode({ agents: {} }), 'lite');
20
+ assert.equal(resolveEconomyMode(null), 'lite');
21
+ assert.equal(resolveEconomyMode(undefined), 'lite');
22
+ });
23
+
24
+ test('explicit agents.economy wins for every valid mode', () => {
25
+ for (const mode of VALID_ECONOMY_MODES) {
26
+ assert.equal(resolveEconomyMode({ agents: { economy: mode } }), mode);
27
+ }
28
+ });
29
+
30
+ test('legacy agents.economy_critic maps true→full, false→lite', () => {
31
+ assert.equal(resolveEconomyMode({ agents: { economy_critic: true } }), 'full');
32
+ assert.equal(resolveEconomyMode({ agents: { economy_critic: false } }), 'lite');
33
+ });
34
+
35
+ test('non-boolean legacy economy_critic falls back to the lite default (schema warns separately)', () => {
36
+ assert.equal(resolveEconomyMode({ agents: { economy_critic: 'true' } }), 'lite');
37
+ assert.equal(resolveEconomyMode({ agents: { economy_critic: 1 } }), 'lite');
38
+ assert.equal(resolveEconomyMode({ agents: { economy_critic: null } }), 'lite');
39
+ });
40
+
41
+ test('explicit economy overrides the legacy bool', () => {
42
+ assert.equal(resolveEconomyMode({ agents: { economy: 'off', economy_critic: true } }), 'off');
43
+ assert.equal(resolveEconomyMode({ agents: { economy: 'ultra', economy_critic: false } }), 'ultra');
44
+ });
45
+
46
+ test('invalid explicit economy throws loud (no silent default)', () => {
47
+ assert.throws(
48
+ () => resolveEconomyMode({ agents: { economy: 'banana' } }),
49
+ (err) => err.code === 'config-invalid-economy-mode',
50
+ );
51
+ assert.throws(
52
+ () => resolveEconomyMode({ agents: { economy: 42 } }),
53
+ (err) => err.code === 'config-invalid-economy-mode',
54
+ );
55
+ });
56
+
57
+ test('flag helpers gate prevention/critic/ultra correctly', () => {
58
+ assert.equal(preventionOn('off'), false);
59
+ assert.equal(preventionOn('lite'), true);
60
+ assert.equal(preventionOn('full'), true);
61
+ assert.equal(preventionOn('ultra'), true);
62
+
63
+ assert.equal(criticOn('off'), false);
64
+ assert.equal(criticOn('lite'), false);
65
+ assert.equal(criticOn('full'), true);
66
+ assert.equal(criticOn('ultra'), true);
67
+
68
+ assert.equal(isUltra('ultra'), true);
69
+ assert.equal(isUltra('full'), false);
70
+ });
71
+
72
+ test('economyFlags bundles the resolved mode with its gates', () => {
73
+ assert.deepEqual(economyFlags({ agents: { economy: 'off' } }), {
74
+ mode: 'off', prevention: false, critic: false, ultra: false,
75
+ });
76
+ assert.deepEqual(economyFlags({}), {
77
+ mode: 'lite', prevention: true, critic: false, ultra: false,
78
+ });
79
+ assert.deepEqual(economyFlags({ agents: { economy: 'full' } }), {
80
+ mode: 'full', prevention: true, critic: true, ultra: false,
81
+ });
82
+ assert.deepEqual(economyFlags({ agents: { economy: 'ultra' } }), {
83
+ mode: 'ultra', prevention: true, critic: true, ultra: true,
84
+ });
85
+ });
package/lib/git.cjs CHANGED
@@ -58,7 +58,7 @@ function assertCommittablePaths(paths, opts) {
58
58
  return committable;
59
59
  }
60
60
 
61
- function commitTask(taskId, files, message) {
61
+ function commitTask(taskId, files, message, body) {
62
62
  const { committable, ignored } = classifyCommittablePaths(files);
63
63
  if (committable.length === 0) {
64
64
  if (ignored.length > 0) {
@@ -84,7 +84,11 @@ function commitTask(taskId, files, message) {
84
84
  });
85
85
  }
86
86
  execFileSync('git', ['add', '--', ...committable], { stdio: 'pipe', timeout: GIT_TIMEOUT_MS });
87
- execFileSync('git', ['commit', '-m', message, '--', ...committable], { stdio: 'pipe', timeout: GIT_TIMEOUT_MS });
87
+ const commitArgs = ['commit', '-m', message];
88
+ if (typeof body === 'string' && body.trim().length > 0) {
89
+ commitArgs.push('-m', body);
90
+ }
91
+ execFileSync('git', [...commitArgs, '--', ...committable], { stdio: 'pipe', timeout: GIT_TIMEOUT_MS });
88
92
  return {
89
93
  committed: true,
90
94
  files_committed: committable.slice(),
package/lib/git.test.cjs CHANGED
@@ -199,6 +199,34 @@ test('GIT-5: commitTask creates a single commit containing exactly the supplied
199
199
  });
200
200
  });
201
201
 
202
+ test('GIT-5b: commitTask attaches a multi-line body via a second -m when body is supplied', () => {
203
+ const root = makeRepo();
204
+ inRepo(root, () => {
205
+ writeFile(root, 'lib/git.cjs', '// stub');
206
+ git.commitTask(
207
+ 'M006-S001-T0001',
208
+ ['lib/git.cjs'],
209
+ 'task(M006-S001-T0001): add git helper',
210
+ 'Implements the git helper.\n\nTask: M006-S001-T0001',
211
+ );
212
+ const subject = execFileSync('git', ['log', '-n', '1', '--format=%s'], { encoding: 'utf-8' }).trim();
213
+ const fullBody = execFileSync('git', ['log', '-n', '1', '--format=%b'], { encoding: 'utf-8' });
214
+ assert.equal(subject, 'task(M006-S001-T0001): add git helper');
215
+ assert.match(fullBody, /Implements the git helper\./);
216
+ assert.match(fullBody, /Task: M006-S001-T0001/);
217
+ });
218
+ });
219
+
220
+ test('GIT-5c: commitTask omits the body -m when body is empty/whitespace (backward-compatible)', () => {
221
+ const root = makeRepo();
222
+ inRepo(root, () => {
223
+ writeFile(root, 'lib/git.cjs', '// stub');
224
+ git.commitTask('M006-S001-T0001', ['lib/git.cjs'], 'task(M006-S001-T0001): add git helper', ' ');
225
+ const fullBody = execFileSync('git', ['log', '-n', '1', '--format=%b'], { encoding: 'utf-8' }).trim();
226
+ assert.equal(fullBody, '');
227
+ });
228
+ });
229
+
202
230
  test('GIT-6: findCommitByTaskId returns 40-char SHA for known task commit', () => {
203
231
  const root = makeRepo();
204
232
  inRepo(root, () => {
package/lib/nubosloop.cjs CHANGED
@@ -34,6 +34,10 @@ const ROUTE_TABLE = {
34
34
  'verify-mismatch': 'executor',
35
35
  'unmet-criterion': 'executor',
36
36
  'scope-creep': 'executor',
37
+ 'over-engineering': 'executor',
38
+ 'stdlib-reinvention': 'executor',
39
+ 'native-duplication': 'executor',
40
+ 'shrinkable': 'executor',
37
41
  'information-missing': 'researcher',
38
42
  'question-to-user': 'askuser',
39
43
  'locked-decision-violation': 'plan-checker',
@@ -191,6 +191,7 @@ test('NL-17: ROUTE_TABLE covers every documented finding category', () => {
191
191
  'missing-test', 'edge-case-gap',
192
192
  'weak-assertion', 'silenced-failure', 'test-naming', 'non-deterministic',
193
193
  'verify-mismatch', 'unmet-criterion', 'scope-creep', 'information-missing',
194
+ 'over-engineering', 'stdlib-reinvention', 'native-duplication', 'shrinkable',
194
195
  'infrastructure-mismatch',
195
196
  'question-to-user', 'locked-decision-violation', 'stuck-detected',
196
197
  ];
package/np-tools.cjs CHANGED
@@ -44,6 +44,7 @@ const topLevelCommands = {
44
44
  'askuser': require('./bin/np-tools/askuser.cjs'),
45
45
  'commit': require('./bin/np-tools/commit.cjs'),
46
46
  'config-get': require('./bin/np-tools/config.cjs'),
47
+ 'economy-mode': require('./bin/np-tools/economy-mode.cjs'),
47
48
  'scan-codebase': require('./bin/np-tools/scan-codebase.cjs'),
48
49
  'update-docs': require('./bin/np-tools/update-docs.cjs'),
49
50
  'graph-impact': require('./bin/np-tools/graph-impact.cjs'),
@@ -79,6 +80,7 @@ const topLevelCommands = {
79
80
  'worktree-list': require('./bin/np-tools/worktree-list.cjs'),
80
81
  'worktree-ff-merge': require('./bin/np-tools/worktree-ff-merge.cjs'),
81
82
  'dashboard': require('./bin/np-tools/dashboard.cjs'),
83
+ 'simplify-debt': require('./bin/np-tools/simplify-debt.cjs'),
82
84
  'archive-project': require('./bin/np-tools/archive-project.cjs'),
83
85
 
84
86
  ...initWorkflows,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nubos-pilot",
3
- "version": "1.3.2",
3
+ "version": "1.3.4",
4
4
  "description": "Self-hosted AI pilot for any codebase. Researcher and critic agents plan, execute and verify each change.",
5
5
  "homepage": "https://pilot.nubos.cloud",
6
6
  "repository": {
@@ -79,7 +79,33 @@ Examples:
79
79
  -->
80
80
  - _TBD — fill with logging policy._
81
81
 
82
- ## Code Style
82
+ ## Conventions
83
+
84
+ > **How your code must be built.** These rules bind the architect (`np-task-architect`),
85
+ > the test-writer (`np-test-writer`), the executor, and the style critic
86
+ > (`np-critic-style`). They are read on every task. Each subsection is **MUST FILL** —
87
+ > use `- _none — <reason>_` only when a subsection genuinely does not apply.
88
+
89
+ ### Class / Module Structure
90
+
91
+ <!-- How classes, modules, and units are shaped. Examples:
92
+ - One public class per file; file name matches the class name.
93
+ - Constructor injection only — no service-locator / static singletons.
94
+ - Business logic lives in Services/Actions; controllers stay thin (no DB access).
95
+ - Public surface ≤ 5 methods; split when it grows past that.
96
+ -->
97
+ - _TBD — fill with class/module construction rules._
98
+
99
+ ### Naming
100
+
101
+ <!-- Identifier conventions. Examples:
102
+ - Classes PascalCase, methods camelCase, constants UPPER_SNAKE.
103
+ - Booleans read as predicates (`isActive`, `hasAccess`), never `flag`/`status`.
104
+ - Table names follow the framework's pluralization — never override.
105
+ -->
106
+ - _TBD — fill with naming conventions._
107
+
108
+ ### Code Style
83
109
 
84
110
  <!-- Format/lint/comment policy. Examples:
85
111
  - No comments inside source — names + tests carry intent.
@@ -88,6 +114,15 @@ Examples:
88
114
  -->
89
115
  - _TBD — fill with style policy._
90
116
 
117
+ ### Patterns & Paradigms
118
+
119
+ <!-- Architectural patterns that are required or banned. Examples:
120
+ - Required: Repository pattern for all persistence; Result objects over exceptions for control flow.
121
+ - Banned: anemic domain models; inheritance for code reuse (prefer composition).
122
+ - Errors are typed and surfaced — never swallowed or stringly-typed.
123
+ -->
124
+ - _TBD — fill with required/banned patterns._
125
+
91
126
  ## Out-of-Scope (Forever)
92
127
 
93
128
  <!-- Things this project explicitly will never do. Distinct from deferred ideas.
@@ -35,8 +35,14 @@ RUNTIME=$(node .nubos-pilot/bin/np-tools.cjs detect-runtime)
35
35
  WORKTREE_ISOLATION=$(node .nubos-pilot/bin/np-tools.cjs config-get workflow.worktree_isolation 2>/dev/null || echo "false")
36
36
  TIER_ROUTING=$(node .nubos-pilot/bin/np-tools.cjs config-get workflow.tier_routing 2>/dev/null || echo "false")
37
37
  VERIFY_RUNS=$(node .nubos-pilot/bin/np-tools.cjs config-get loop.verify_runs 2>/dev/null || echo "1")
38
+ ECONOMY=$(node .nubos-pilot/bin/np-tools.cjs economy-mode --json 2>/dev/null || echo '{"mode":"lite","prevention":true,"critic":false,"ultra":false}')
39
+ ECONOMY_MODE=$(echo "$ECONOMY" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{console.log(JSON.parse(s).mode)}catch{console.log("lite")}})')
40
+ ECONOMY_PREVENTION=$(echo "$ECONOMY" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{console.log(JSON.parse(s).prevention)}catch{console.log("true")}})')
41
+ ECONOMY_CRITIC=$(echo "$ECONOMY" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{console.log(JSON.parse(s).critic)}catch{console.log("false")}})')
38
42
  ```
39
43
 
44
+ **Economy axis (Ponytail-style graduated modes, SSOT = `economy-mode`).** `$ECONOMY_MODE` is one of `off|lite|full|ultra` (default `lite` = prevention-first). It dials two mechanisms: `$ECONOMY_PREVENTION` (`true` for `lite`/`full`/`ultra`) gates the climb-the-ladder directive injected into the Executor (Step 3); `$ECONOMY_CRITIC` (`true` for `full`/`ultra`) gates the `np-critic-economy.md` audit module injected into np-critic (Step 5). `ultra` additionally tells the critic to lower its `shrinkable` bar. Resolve this ONCE here — never re-read the raw config toggle downstream.
45
+
40
46
  When `--verify-work` is passed, the init payload's `auto_verify: true` flag tells this workflow to chain into `/np:verify-work $PHASE` after every slice committed and `finalize-milestone` ran. Without the flag the workflow stops after finalize as before — verify-work then remains a separate manual step.
41
47
 
42
48
  **Language (SSOT = `.nubos-pilot/config.json` → `response_language`).**
@@ -213,6 +219,10 @@ SPAWN_HEADLESS_ENABLED=$(node .nubos-pilot/bin/np-tools.cjs config-get spawn.hea
213
219
  SPAWN_HEADLESS_AGENTS=$(node .nubos-pilot/bin/np-tools.cjs config-get spawn.headless.agents 2>/dev/null || echo '["np-critic","np-researcher"]')
214
220
  SPAWN_HEADLESS_FALLBACK=$(node .nubos-pilot/bin/np-tools.cjs config-get spawn.headless.fallback_on_error 2>/dev/null || echo true)
215
221
  CONF_INJECT_CRITERIA=$(node .nubos-pilot/bin/np-tools.cjs config-get conformance.inject_criteria 2>/dev/null || echo true)
222
+ # Round-1 prep agents (default on; backfilled on install/update). When a toggle
223
+ # is false the matching ACTION CONTRACT (Step 2b / Step 2c) is skipped wholesale.
224
+ ARCHITECT_ENABLED=$(node .nubos-pilot/bin/np-tools.cjs config-get agents.architect 2>/dev/null || echo true)
225
+ TEST_WRITER_ENABLED=$(node .nubos-pilot/bin/np-tools.cjs config-get agents.test_writer 2>/dev/null || echo true)
216
226
  # Milestone success_criteria as the executor's acceptance target (rendered once from the INIT payload).
217
227
  # Intent-level only (ADR-0019): these describe what "done right" means, NOT how to build it.
218
228
  SUCCESS_CRITERIA_BLOCK=$(echo "$INIT" | node -e 'process.stdin.on("data",d=>{try{const c=JSON.parse(d).success_criteria||[];console.log(c.map(x=>"- "+(x.id?x.id+": ":"")+(x.text||x)).join("\n"))}catch(e){console.log("")}})')
@@ -408,6 +418,131 @@ for WAVE_INDEX in 0 1 2 ...; do
408
418
  CONSENSUS_PATTERN=""
409
419
  fi
410
420
 
421
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
422
+ # ACTION CONTRACT — Step 2b: Per-Task Architect (Round 1, config-gated)
423
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
424
+ # WHEN: $ROUND -eq 1 AND $ARCHITECT_ENABLED = true. Skip wholesale otherwise
425
+ # (agents.architect=false → no architect this run; R≥2 build-fixer rounds
426
+ # never run it).
427
+ # SKIP-GUARD: loop-post-architect-missing-spawn-audit (needs 1 architect audit).
428
+ #
429
+ # Execute EXACTLY these three groups, in order:
430
+ #
431
+ # (1) ONE Agent tool-call (real, not bash):
432
+ # Agent(subagent_type="np-task-architect", prompt=<…>)
433
+ # Prompt fields:
434
+ # <files_to_read>: task plan, slice plan, CONTEXT.md, RULES.md,
435
+ # M<NNN>-ARCHITECTURE.md (if present), .nubos-pilot/codebase/INDEX.md
436
+ # <consensus_pattern>: $CONSENSUS_PATTERN (researcher output; may be empty)
437
+ # <lang_directive>: $LANG_DIRECTIVE
438
+ # Curated skills (quality bar) — instruct the agent to Read each that
439
+ # applies from .claude/skills/<skill>/SKILL.md: np-system-design,
440
+ # np-service-boundary, np-api-design, np-composition-patterns,
441
+ # np-error-handling, np-adr (only for a costly-to-reverse choice).
442
+ # The agent is READ-ONLY: it emits its Task-Architecture spec as its FINAL
443
+ # MESSAGE (markdown per its Output Contract). Write that message verbatim
444
+ # to "$ARCH_CONSTRAINTS_PATH".
445
+ #
446
+ # (2) ONE Bash audit-stamp (same round) — architect is NOT Rule-9 audited,
447
+ # so an empty tool-use log is correct:
448
+ # node .nubos-pilot/bin/np-tools.cjs loop-audit-tool-use "$TASK_ID" \
449
+ # --agent np-task-architect --tool-use-log '[]'
450
+ #
451
+ # (3) ONE Bash advance:
452
+ # node .nubos-pilot/bin/np-tools.cjs loop-run-round "$TASK_ID" \
453
+ # --phase post-architect
454
+ #
455
+ # $ARCH_CONSTRAINTS is injected as <architecture_constraints> into the
456
+ # test-writer (Step 2c) AND executor (Step 3) prompts.
457
+ #
458
+ # Rationale: ADR-0023 — a per-task structural pass before tests/code so the
459
+ # test-writer and executor build against a decided shape, honouring RULES.md
460
+ # Conventions. Ephemeral ($TMPDIR, never committed) → plan-lint untouched.
461
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
462
+ ARCH_CONSTRAINTS=""
463
+ ARCH_CONSTRAINTS_PATH="${TMPDIR:-/tmp}/np-arch-${TASK_ID}.md"
464
+ if [ "$ROUND" -eq 1 ] && [ "$ARCHITECT_ENABLED" = "true" ]; then
465
+ # Off-host (ADR-0021): np-task-architect is read-only (Read/Grep/Glob), not
466
+ # Rule-9 audited, writes no files — run via spawn-offhost with default cwd
467
+ # when it routes to an openai-compat provider; its spec returns as the
468
+ # spawn's final message (content).
469
+ ARCHITECT_KIND=$(node .nubos-pilot/bin/np-tools.cjs resolve-model np-task-architect --json 2>/dev/null \
470
+ | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{console.log(JSON.parse(s).kind||"native")}catch{console.log("native")}})')
471
+ if [ "$ARCHITECT_KIND" = "openai-compat" ]; then
472
+ A_PROMPT="${TMPDIR:-/tmp}/np-offhost-task-architect-${TASK_ID}.md"
473
+ # … render the files_to_read block + consensus + skills + $LANG_DIRECTIVE into "$A_PROMPT" …
474
+ A_OUT=$(node .nubos-pilot/bin/np-tools.cjs spawn-offhost \
475
+ --agent np-task-architect --task-file "$A_PROMPT" --task-id "$TASK_ID" \
476
+ --read-only --no-audit ${SLICE_CWD:+--cwd "$SLICE_CWD"})
477
+ echo "$A_OUT" | ARCH_PATH="$ARCH_CONSTRAINTS_PATH" node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let c="";try{c=JSON.parse(s).content||""}catch{}require("fs").writeFileSync(process.env.ARCH_PATH,c)})'
478
+ else
479
+ true # → execute group (1): native Agent spawn; write its final message to "$ARCH_CONSTRAINTS_PATH".
480
+ fi
481
+ node .nubos-pilot/bin/np-tools.cjs loop-audit-tool-use "$TASK_ID" --agent np-task-architect --tool-use-log '[]'
482
+ node .nubos-pilot/bin/np-tools.cjs loop-run-round "$TASK_ID" --phase post-architect
483
+ [ -f "$ARCH_CONSTRAINTS_PATH" ] && ARCH_CONSTRAINTS=$(cat "$ARCH_CONSTRAINTS_PATH")
484
+ fi
485
+
486
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
487
+ # ACTION CONTRACT — Step 2c: Test-Writer / TDD (Round 1, config-gated)
488
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
489
+ # WHEN: $ROUND -eq 1 AND $TEST_WRITER_ENABLED = true. Runs AFTER the architect,
490
+ # BEFORE the executor. Skip wholesale otherwise.
491
+ # SKIP-GUARD: loop-post-test-writer-missing-spawn-audit (needs 1 test-writer audit).
492
+ #
493
+ # Execute EXACTLY these three groups, in order:
494
+ #
495
+ # (1) ONE Agent tool-call (real, not bash):
496
+ # Agent(subagent_type="np-test-writer", prompt=<…>)
497
+ # Prompt fields:
498
+ # <files_to_read>: task plan, slice plan, RULES.md, neighbouring tests
499
+ # <architecture_constraints>: $ARCH_CONSTRAINTS (the architect's required
500
+ # test surfaces; empty when the architect is disabled)
501
+ # <success_criteria>: $SUCCESS_CRITERIA_BLOCK + slice UAT path (intent-level)
502
+ # <lang_directive>: $LANG_DIRECTIVE
503
+ # Curated skill (quality bar) — instruct the agent to Read
504
+ # .claude/skills/np-test-strategy/SKILL.md and satisfy its Verification bar.
505
+ # RULES — the agent writes REAL, VALID test files for every required surface;
506
+ # it MUST NOT skip/stub/weaken assertions (Rule 10). Tests MAY be red now;
507
+ # the executor makes them green. The agent emits a JSON envelope whose
508
+ # tests_written paths you collect into $TDD_TESTS.
509
+ #
510
+ # (2) ONE Bash audit-stamp (same round) — test-writer is NOT Rule-9 audited:
511
+ # node .nubos-pilot/bin/np-tools.cjs loop-audit-tool-use "$TASK_ID" \
512
+ # --agent np-test-writer --tool-use-log '[]'
513
+ #
514
+ # (3) ONE Bash advance — pass the written test paths so they are recorded in
515
+ # the checkpoint (nubosloop.tdd_tests) and commit-task folds them into the
516
+ # commit even when files_modified did not enumerate them:
517
+ # node .nubos-pilot/bin/np-tools.cjs loop-run-round "$TASK_ID" \
518
+ # --phase post-test-writer --tests "$TDD_TESTS"
519
+ #
520
+ # Rationale: ADR-0023 — TDD inside the loop. The mechanical verify gate
521
+ # (Step 4) runs only AFTER the executor, so red-until-executor is expected
522
+ # and not a failure. The np-critic-tests axis (Step 5) re-audits for any
523
+ # skipped/vacuous assertions that slipped through.
524
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
525
+ TDD_TESTS=""
526
+ if [ "$ROUND" -eq 1 ] && [ "$TEST_WRITER_ENABLED" = "true" ]; then
527
+ # Off-host (ADR-0021): np-test-writer writes test files, so off-host needs
528
+ # worktree isolation exactly like the executor (model-driven Write confined
529
+ # + ff-merged back). When worktree isolation is off, it runs native.
530
+ TEST_WRITER_KIND=$(node .nubos-pilot/bin/np-tools.cjs resolve-model np-test-writer --json 2>/dev/null \
531
+ | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{console.log(JSON.parse(s).kind||"native")}catch{console.log("native")}})')
532
+ if [ "$TEST_WRITER_KIND" = "openai-compat" ] && [ "$WORKTREE_ISOLATION" = "true" ] && [ -n "$SLICE_CWD" ] && [ "$SLICE_CWD" != "." ]; then
533
+ TW_PROMPT="${TMPDIR:-/tmp}/np-offhost-test-writer-${TASK_ID}.md"
534
+ # … render files_to_read + architecture_constraints + success_criteria + skill + $LANG_DIRECTIVE into "$TW_PROMPT" …
535
+ TW_OUT=$(node .nubos-pilot/bin/np-tools.cjs spawn-offhost \
536
+ --agent np-test-writer --task-file "$TW_PROMPT" --task-id "$TASK_ID" \
537
+ --cwd "$SLICE_CWD" --allow-bash --no-audit)
538
+ TDD_TESTS=$(echo "$TW_OUT" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const j=JSON.parse(JSON.parse(s).content||"{}");console.log((j.tests_written||[]).join(", "))}catch{console.log("")}})')
539
+ else
540
+ true # → execute group (1): native Agent spawn; collect tests_written from the envelope into $TDD_TESTS.
541
+ fi
542
+ node .nubos-pilot/bin/np-tools.cjs loop-audit-tool-use "$TASK_ID" --agent np-test-writer --tool-use-log '[]'
543
+ node .nubos-pilot/bin/np-tools.cjs loop-run-round "$TASK_ID" --phase post-test-writer --tests "$TDD_TESTS"
544
+ fi
545
+
411
546
  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
412
547
  # ACTION CONTRACT — Step 3: Executor (R1) / Build-Fixer (R≥2)
413
548
  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -418,6 +553,13 @@ for WAVE_INDEX in 0 1 2 ...; do
418
553
  # Prompt fields:
419
554
  # <files_to_read>: task plan, slice plan, prior slice SUMMARYs, CONTEXT.md
420
555
  # <consensus_pattern>: $CONSENSUS_PATTERN (with [VERIFIED]/[PROVISIONAL]/[CACHED])
556
+ # <architecture_constraints>: $ARCH_CONSTRAINTS — the per-task architect's
557
+ # decided structure + constraints (empty when agents.architect is off).
558
+ # The executor builds against this shape; it is intent-level, not a code spec.
559
+ # <tdd_tests>: $TDD_TESTS — test files np-test-writer wrote (R1, empty when off).
560
+ # The executor MUST make them green WITHOUT deleting, skipping, or weakening
561
+ # any assertion. They are in scope alongside files_modified (recorded in the
562
+ # checkpoint at post-test-writer) and commit-task commits them with the diff.
421
563
  # <success_criteria>: when $CONF_INJECT_CRITERIA = true, include the milestone
422
564
  # acceptance target — $SUCCESS_CRITERIA_BLOCK plus the slice UAT path
423
565
  # (.nubos-pilot/milestones/M<NNN>/slices/S<NNN>/S<NNN>-UAT.md). Frame it as
@@ -427,7 +569,12 @@ for WAVE_INDEX in 0 1 2 ...; do
427
569
  # <verify_excerpt>: tail of $VERIFY_LOG (R≥2 only)
428
570
  # <lang_directive>: $LANG_DIRECTIVE
429
571
  # <skills>: $AGENT_SKILLS_EXECUTOR
430
- # RULESAgent MUST: edit ONLY paths in files_modified (D-04 scope guard) —
572
+ # <economy_mode>: $ECONOMY_MODE when $ECONOMY_PREVENTION = true (lite/full/
573
+ # ultra) instruct the agent to APPLY the np-executor "Climb the ladder"
574
+ # discipline before writing (prevention-first). When $ECONOMY_MODE = off,
575
+ # instruct it to SKIP the ladder (no economy pressure this run).
576
+ # RULES — Agent MUST: edit ONLY paths in files_modified plus the <tdd_tests>
577
+ # paths (D-04 scope guard; the TDD tests are the sole sanctioned addition) —
431
578
  # success_criteria are the acceptance target, NEVER a licence to touch other files,
432
579
  # run `node np-tools.cjs knowledge-search "<q>" --task $TASK_ID` via Bash
433
580
  # ≥1× (Rule 9 — the --task flag writes the audit evidence ledger),
@@ -566,6 +713,12 @@ for WAVE_INDEX in 0 1 2 ...; do
566
713
  # - agents/np-critic-style.md
567
714
  # - agents/np-critic-tests.md
568
715
  # - agents/np-critic-acceptance.md
716
+ # - agents/np-critic-economy.md ← ONLY when $ECONOMY_CRITIC = true (mode full/ultra)
717
+ # (resolved once in the init block via `economy-mode --json`; omit this line
718
+ # entirely when $ECONOMY_CRITIC = false — default mode lite has prevention
719
+ # on but the critic off). When $ECONOMY_MODE = ultra, ALSO append to the
720
+ # prompt: "Economy mode: ultra — lower the shrinkable bar per the Ultra
721
+ # section of np-critic-economy.md." Never inject the module at off/lite.
569
722
  # <report_path>$CRITIC_REPORT_PATH</report_path>
570
723
  # Agent MUST: Write the full findings JSON to $CRITIC_REPORT_PATH,
571
724
  # emit ONLY the verdict-envelope as final message (~150 bytes):