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.
- package/CHANGELOG.md +5 -2
- package/agents/np-critic-economy.md +103 -0
- package/agents/np-critic.md +11 -10
- package/agents/np-executor.md +14 -0
- package/agents/np-simplifier.md +83 -0
- package/agents/np-task-architect.md +95 -0
- package/agents/np-test-writer.md +89 -0
- package/bin/install.js +86 -0
- package/bin/np-tools/_commands.cjs +2 -0
- package/bin/np-tools/commit-task.cjs +80 -6
- package/bin/np-tools/commit-task.test.cjs +133 -0
- package/bin/np-tools/doctor.cjs +1 -0
- package/bin/np-tools/economy-mode.cjs +47 -0
- package/bin/np-tools/loop-commands.test.cjs +121 -2
- package/bin/np-tools/loop-run-round.cjs +122 -6
- package/bin/np-tools/resolve-model.cjs +1 -0
- package/bin/np-tools/simplify-debt.cjs +91 -0
- package/bin/np-tools/simplify-debt.test.cjs +99 -0
- package/lib/agents-registry.cjs +12 -1
- package/lib/agents.test.cjs +4 -0
- package/lib/config-defaults.cjs +22 -1
- package/lib/config-defaults.test.cjs +9 -0
- package/lib/config-schema.cjs +6 -0
- package/lib/economy-debt.cjs +235 -0
- package/lib/economy-debt.test.cjs +131 -0
- package/lib/economy-mode.cjs +66 -0
- package/lib/economy-mode.test.cjs +85 -0
- package/lib/git.cjs +6 -2
- package/lib/git.test.cjs +28 -0
- package/lib/nubosloop.cjs +4 -0
- package/lib/nubosloop.test.cjs +1 -0
- package/np-tools.cjs +2 -0
- package/package.json +1 -1
- package/templates/RULES.md +36 -1
- package/workflows/execute-phase.md +154 -1
- package/workflows/plan-phase.md +17 -2
- package/workflows/simplify-debt.md +93 -0
- package/workflows/simplify-review.md +103 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { NubosPilotError } = require('../../lib/core.cjs');
|
|
4
|
+
const debt = require('../../lib/economy-debt.cjs');
|
|
5
|
+
|
|
6
|
+
function _parseFlags(list) {
|
|
7
|
+
const out = { _: [], file: null, line: null, category: null, note: null, status: null, json: false };
|
|
8
|
+
for (let i = 0; i < list.length; i++) {
|
|
9
|
+
const a = list[i];
|
|
10
|
+
if (a === '--json') out.json = true;
|
|
11
|
+
else if (a === '--file') out.file = list[++i];
|
|
12
|
+
else if (a.startsWith('--file=')) out.file = a.slice('--file='.length);
|
|
13
|
+
else if (a === '--line') out.line = list[++i];
|
|
14
|
+
else if (a.startsWith('--line=')) out.line = a.slice('--line='.length);
|
|
15
|
+
else if (a === '--category') out.category = list[++i];
|
|
16
|
+
else if (a.startsWith('--category=')) out.category = a.slice('--category='.length);
|
|
17
|
+
else if (a === '--note') out.note = list[++i];
|
|
18
|
+
else if (a.startsWith('--note=')) out.note = a.slice('--note='.length);
|
|
19
|
+
else if (a === '--status') out.status = list[++i];
|
|
20
|
+
else if (a.startsWith('--status=')) out.status = a.slice('--status='.length);
|
|
21
|
+
else out._.push(a);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function _renderList(entries, status) {
|
|
27
|
+
if (entries.length === 0) {
|
|
28
|
+
return status === 'resolved'
|
|
29
|
+
? 'No resolved economy-debt entries.'
|
|
30
|
+
: 'Economy-debt ledger is empty. Lean already.';
|
|
31
|
+
}
|
|
32
|
+
const lines = entries.map((e) => {
|
|
33
|
+
const where = e.line > 0 ? e.file + ':' + e.line : (e.file || '(no file)');
|
|
34
|
+
const mark = e.status === 'resolved' ? '[x]' : '[ ]';
|
|
35
|
+
return mark + ' ' + e.id + ' ' + e.category.padEnd(18) + ' ' + where + '\n ' + e.note;
|
|
36
|
+
});
|
|
37
|
+
const open = entries.filter((e) => e.status === 'open').length;
|
|
38
|
+
const resolved = entries.length - open;
|
|
39
|
+
lines.push('');
|
|
40
|
+
lines.push('total: ' + entries.length + ' (' + open + ' open, ' + resolved + ' resolved)');
|
|
41
|
+
return lines.join('\n');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function run(args, ctx) {
|
|
45
|
+
const context = ctx || {};
|
|
46
|
+
const cwd = context.cwd || process.cwd();
|
|
47
|
+
const stdout = context.stdout || process.stdout;
|
|
48
|
+
const list = Array.isArray(args) ? args : [];
|
|
49
|
+
const verb = (list[0] || 'list').trim();
|
|
50
|
+
const flags = _parseFlags(list.slice(1));
|
|
51
|
+
|
|
52
|
+
if (verb === 'add') {
|
|
53
|
+
const note = flags.note != null ? flags.note : flags._.join(' ');
|
|
54
|
+
const entry = debt.addEntry(
|
|
55
|
+
{ file: flags.file, line: flags.line, category: flags.category, note },
|
|
56
|
+
cwd,
|
|
57
|
+
);
|
|
58
|
+
stdout.write(JSON.stringify({ ok: true, action: 'add', was_new: entry.was_new, entry }) + '\n');
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (verb === 'resolve') {
|
|
63
|
+
const id = flags._[0];
|
|
64
|
+
const entry = debt.resolveEntry(id, cwd);
|
|
65
|
+
stdout.write(JSON.stringify({ ok: true, action: 'resolve', entry }) + '\n');
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (verb === 'list') {
|
|
70
|
+
const status = flags.status || 'open';
|
|
71
|
+
const entries = debt.listEntries(status, cwd);
|
|
72
|
+
if (flags.json) {
|
|
73
|
+
stdout.write(JSON.stringify({ ok: true, action: 'list', status, count: entries.length, entries }, null, 2) + '\n');
|
|
74
|
+
} else {
|
|
75
|
+
stdout.write(_renderList(entries, status) + '\n');
|
|
76
|
+
}
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
throw new NubosPilotError(
|
|
81
|
+
'simplify-debt-unknown-verb',
|
|
82
|
+
"simplify-debt verb must be 'add', 'list', or 'resolve' (got: " + verb + ')',
|
|
83
|
+
{ verb },
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { run, _parseFlags, _renderList };
|
|
88
|
+
|
|
89
|
+
if (require.main === module) {
|
|
90
|
+
process.exit(run(process.argv.slice(2)));
|
|
91
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
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 subcmd = require('./simplify-debt.cjs');
|
|
10
|
+
|
|
11
|
+
const _sandboxes = [];
|
|
12
|
+
|
|
13
|
+
function makeSandbox() {
|
|
14
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'np-simplify-debt-'));
|
|
15
|
+
fs.mkdirSync(path.join(root, '.nubos-pilot'), { recursive: true });
|
|
16
|
+
_sandboxes.push(root);
|
|
17
|
+
return root;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function capture(fn) {
|
|
21
|
+
const out = [];
|
|
22
|
+
const orig = process.stdout.write.bind(process.stdout);
|
|
23
|
+
process.stdout.write = (c) => { out.push(String(c)); return true; };
|
|
24
|
+
let rc;
|
|
25
|
+
try { rc = fn(); } finally { process.stdout.write = orig; }
|
|
26
|
+
return { stdout: out.join(''), rc };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
while (_sandboxes.length) {
|
|
31
|
+
try { fs.rmSync(_sandboxes.pop(), { recursive: true, force: true }); } catch { /* best effort */ }
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('SD-1: add records an entry and reports was_new', () => {
|
|
36
|
+
const cwd = makeSandbox();
|
|
37
|
+
const { stdout, rc } = capture(() =>
|
|
38
|
+
subcmd.run(['add', '--file', 'src/foo.ts', '--line', '42', '--category', 'over-engineering', '--note', 'inline the factory'], { cwd }),
|
|
39
|
+
);
|
|
40
|
+
assert.equal(rc, 0);
|
|
41
|
+
const res = JSON.parse(stdout);
|
|
42
|
+
assert.equal(res.ok, true);
|
|
43
|
+
assert.equal(res.action, 'add');
|
|
44
|
+
assert.equal(res.was_new, true);
|
|
45
|
+
assert.equal(res.entry.category, 'over-engineering');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('SD-2: add accepts the note as positional args', () => {
|
|
49
|
+
const cwd = makeSandbox();
|
|
50
|
+
const { stdout, rc } = capture(() =>
|
|
51
|
+
subcmd.run(['add', '--category', 'shrinkable', 'collapse', 'this', 'loop'], { cwd }),
|
|
52
|
+
);
|
|
53
|
+
assert.equal(rc, 0);
|
|
54
|
+
assert.equal(JSON.parse(stdout).entry.note, 'collapse this loop');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('SD-3: list (default open) renders the ledger', () => {
|
|
58
|
+
const cwd = makeSandbox();
|
|
59
|
+
capture(() => subcmd.run(['add', '--file', 'a.ts', '--category', 'shrinkable', '--note', 'use reduce'], { cwd }));
|
|
60
|
+
const { stdout, rc } = capture(() => subcmd.run(['list'], { cwd }));
|
|
61
|
+
assert.equal(rc, 0);
|
|
62
|
+
assert.match(stdout, /shrinkable/);
|
|
63
|
+
assert.match(stdout, /1 open/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('SD-4: list --json emits structured entries', () => {
|
|
67
|
+
const cwd = makeSandbox();
|
|
68
|
+
capture(() => subcmd.run(['add', '--file', 'a.ts', '--category', 'shrinkable', '--note', 'x'], { cwd }));
|
|
69
|
+
const { stdout } = capture(() => subcmd.run(['list', '--json'], { cwd }));
|
|
70
|
+
const res = JSON.parse(stdout);
|
|
71
|
+
assert.equal(res.count, 1);
|
|
72
|
+
assert.equal(res.entries[0].category, 'shrinkable');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('SD-5: no-arg defaults to list', () => {
|
|
76
|
+
const cwd = makeSandbox();
|
|
77
|
+
const { stdout, rc } = capture(() => subcmd.run([], { cwd }));
|
|
78
|
+
assert.equal(rc, 0);
|
|
79
|
+
assert.match(stdout, /empty|Lean already/i);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('SD-6: resolve moves an entry to resolved', () => {
|
|
83
|
+
const cwd = makeSandbox();
|
|
84
|
+
const added = capture(() => subcmd.run(['add', '--file', 'a.ts', '--category', 'native-duplication', '--note', 'reuse helper'], { cwd }));
|
|
85
|
+
const id = JSON.parse(added.stdout).entry.id;
|
|
86
|
+
const { stdout, rc } = capture(() => subcmd.run(['resolve', id], { cwd }));
|
|
87
|
+
assert.equal(rc, 0);
|
|
88
|
+
assert.equal(JSON.parse(stdout).entry.status, 'resolved');
|
|
89
|
+
const open = capture(() => subcmd.run(['list', '--json'], { cwd }));
|
|
90
|
+
assert.equal(JSON.parse(open.stdout).count, 0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('SD-7: unknown verb throws', () => {
|
|
94
|
+
const cwd = makeSandbox();
|
|
95
|
+
assert.throws(
|
|
96
|
+
() => subcmd.run(['frobnicate'], { cwd }),
|
|
97
|
+
(err) => err && err.name === 'NubosPilotError' && err.code === 'simplify-debt-unknown-verb',
|
|
98
|
+
);
|
|
99
|
+
});
|
package/lib/agents-registry.cjs
CHANGED
|
@@ -6,14 +6,23 @@ const LEGACY_CRITIC_AXIS_AGENTS = Object.freeze([
|
|
|
6
6
|
'np-critic-style',
|
|
7
7
|
'np-critic-tests',
|
|
8
8
|
'np-critic-acceptance',
|
|
9
|
+
'np-critic-economy',
|
|
9
10
|
]);
|
|
10
|
-
const SUPPORTED_CRITIC_AXES = Object.freeze(['critic', 'style', 'tests', 'acceptance']);
|
|
11
|
+
const SUPPORTED_CRITIC_AXES = Object.freeze(['critic', 'style', 'tests', 'acceptance', 'economy']);
|
|
11
12
|
|
|
12
13
|
const EXECUTOR_AGENT = 'np-executor';
|
|
13
14
|
const BUILD_FIXER_AGENT = 'np-build-fixer';
|
|
14
15
|
|
|
15
16
|
const RESEARCHER_AGENT = 'np-researcher';
|
|
16
17
|
|
|
18
|
+
// Round-1 preparation agents that run between the researcher swarm and the
|
|
19
|
+
// executor. Config-gated (agents.architect / agents.test_writer). They get
|
|
20
|
+
// Layer-C spawn-evidence stamps but are NOT Rule-9 audited (not in
|
|
21
|
+
// AUDITED_AGENTS) — the architect is advisory/read-only and the test-writer's
|
|
22
|
+
// quality is enforced downstream by the np-critic-tests axis.
|
|
23
|
+
const TASK_ARCHITECT_AGENT = 'np-task-architect';
|
|
24
|
+
const TEST_WRITER_AGENT = 'np-test-writer';
|
|
25
|
+
|
|
17
26
|
const AUDITED_AGENTS = Object.freeze([
|
|
18
27
|
EXECUTOR_AGENT,
|
|
19
28
|
BUILD_FIXER_AGENT,
|
|
@@ -28,5 +37,7 @@ module.exports = {
|
|
|
28
37
|
EXECUTOR_AGENT,
|
|
29
38
|
BUILD_FIXER_AGENT,
|
|
30
39
|
RESEARCHER_AGENT,
|
|
40
|
+
TASK_ARCHITECT_AGENT,
|
|
41
|
+
TEST_WRITER_AGENT,
|
|
31
42
|
AUDITED_AGENTS,
|
|
32
43
|
};
|
package/lib/agents.test.cjs
CHANGED
|
@@ -242,12 +242,15 @@ const NP_AGENTS = [
|
|
|
242
242
|
{ file: 'np-researcher-reconciler', expected_tier: 'sonnet' },
|
|
243
243
|
{ file: 'np-codebase-documenter', expected_tier: 'sonnet' },
|
|
244
244
|
{ file: 'np-architect', expected_tier: 'sonnet' },
|
|
245
|
+
{ file: 'np-task-architect', expected_tier: 'sonnet' },
|
|
246
|
+
{ file: 'np-test-writer', expected_tier: 'sonnet' },
|
|
245
247
|
{ file: 'np-build-fixer', expected_tier: 'sonnet' },
|
|
246
248
|
{ file: 'np-security-reviewer', expected_tier: 'sonnet' },
|
|
247
249
|
{ file: 'np-nyquist-auditor', expected_tier: 'haiku' },
|
|
248
250
|
{ file: 'np-sc-extractor', expected_tier: 'haiku' },
|
|
249
251
|
{ file: 'np-critic', expected_tier: 'sonnet' },
|
|
250
252
|
{ file: 'np-learnings-extractor', expected_tier: 'haiku' },
|
|
253
|
+
{ file: 'np-simplifier', expected_tier: 'sonnet' },
|
|
251
254
|
];
|
|
252
255
|
|
|
253
256
|
// Audit-surface modules — files in agents/ that carry agent-shaped frontmatter
|
|
@@ -257,6 +260,7 @@ const NP_AGENT_MODULES = [
|
|
|
257
260
|
{ file: 'np-critic-style', parent: 'np-critic' },
|
|
258
261
|
{ file: 'np-critic-tests', parent: 'np-critic' },
|
|
259
262
|
{ file: 'np-critic-acceptance', parent: 'np-critic' },
|
|
263
|
+
{ file: 'np-critic-economy', parent: 'np-critic' },
|
|
260
264
|
];
|
|
261
265
|
|
|
262
266
|
for (let i = 0; i < NP_AGENTS.length; i += 1) {
|
package/lib/config-defaults.cjs
CHANGED
|
@@ -18,6 +18,18 @@ const DEFAULT_AGENTS = Object.freeze({
|
|
|
18
18
|
research: true,
|
|
19
19
|
plan_checker: true,
|
|
20
20
|
verifier: true,
|
|
21
|
+
// Per-task architecture step in the Nubosloop (np-task-architect). Runs in
|
|
22
|
+
// round 1 after the researcher swarm, before the test-writer and executor.
|
|
23
|
+
// Backfilled to true on install/update when absent (see bin/install.js).
|
|
24
|
+
architect: true,
|
|
25
|
+
// Per-task TDD step (np-test-writer). Runs in round 1 after the architect,
|
|
26
|
+
// before the executor — writes the tests the executor must make green.
|
|
27
|
+
// Backfilled to true on install/update when absent (see bin/install.js).
|
|
28
|
+
test_writer: true,
|
|
29
|
+
// Economy axis level (off|lite|full|ultra). Default `lite` = prevention-first:
|
|
30
|
+
// the climb-the-ladder discipline is on, the in-loop critic is opt-in (full/ultra).
|
|
31
|
+
// Resolved via lib/economy-mode.cjs; legacy `economy_critic` bool still honoured.
|
|
32
|
+
economy: 'lite',
|
|
21
33
|
});
|
|
22
34
|
|
|
23
35
|
const DEFAULT_LOOP = Object.freeze({
|
|
@@ -35,6 +47,7 @@ const DEFAULT_SWARM_CRITIC = Object.freeze({
|
|
|
35
47
|
style_tier: 'haiku',
|
|
36
48
|
tests_tier: 'sonnet',
|
|
37
49
|
acceptance_tier: 'sonnet',
|
|
50
|
+
economy_tier: 'haiku',
|
|
38
51
|
});
|
|
39
52
|
|
|
40
53
|
const DEFAULT_SWARM = Object.freeze({
|
|
@@ -118,6 +131,13 @@ const DEFAULT_MODEL_PROFILE = 'frontier';
|
|
|
118
131
|
const DEFAULT_SCOPE = 'local';
|
|
119
132
|
const DEFAULT_RESPONSE_LANGUAGE = 'en';
|
|
120
133
|
|
|
134
|
+
// Install/update ships the most aggressive Economy level by default. This is the
|
|
135
|
+
// value written into a fresh config.json (and backfilled into keyless configs on
|
|
136
|
+
// update — see bin/install.js). It deliberately differs from the *resolved*
|
|
137
|
+
// fallback in DEFAULT_AGENTS.economy (`lite`): a config with the key absent
|
|
138
|
+
// entirely stays conservative, but anything nubos-pilot writes opts into ultra.
|
|
139
|
+
const INSTALL_ECONOMY_MODE = 'ultra';
|
|
140
|
+
|
|
121
141
|
const DEFAULT_CONFIG_TREE = Object.freeze({
|
|
122
142
|
scope: DEFAULT_SCOPE,
|
|
123
143
|
model_profile: DEFAULT_MODEL_PROFILE,
|
|
@@ -147,7 +167,7 @@ function buildInstallConfig(answers) {
|
|
|
147
167
|
model_profile: a.model_profile || DEFAULT_MODEL_PROFILE,
|
|
148
168
|
response_language: a.response_language || DEFAULT_RESPONSE_LANGUAGE,
|
|
149
169
|
workflow: workflowOverride,
|
|
150
|
-
agents: { ...DEFAULT_AGENTS },
|
|
170
|
+
agents: { ...DEFAULT_AGENTS, economy: INSTALL_ECONOMY_MODE },
|
|
151
171
|
loop: { ...DEFAULT_LOOP },
|
|
152
172
|
swarm: {
|
|
153
173
|
research: { ...DEFAULT_SWARM_RESEARCH },
|
|
@@ -191,6 +211,7 @@ module.exports = {
|
|
|
191
211
|
DEFAULT_MODEL_PROFILE,
|
|
192
212
|
DEFAULT_SCOPE,
|
|
193
213
|
DEFAULT_RESPONSE_LANGUAGE,
|
|
214
|
+
INSTALL_ECONOMY_MODE,
|
|
194
215
|
DEFAULT_CONFIG_TREE,
|
|
195
216
|
buildInstallConfig,
|
|
196
217
|
};
|
|
@@ -6,8 +6,17 @@ const {
|
|
|
6
6
|
DEFAULT_WORKFLOW,
|
|
7
7
|
DEFAULT_MODEL_PROFILE,
|
|
8
8
|
DEFAULT_SCOPE,
|
|
9
|
+
DEFAULT_CONFIG_TREE,
|
|
10
|
+
INSTALL_ECONOMY_MODE,
|
|
9
11
|
} = require('./config-defaults.cjs');
|
|
10
12
|
|
|
13
|
+
test('CFD-economy: install writes economy=ultra, but the resolved fallback stays lite', () => {
|
|
14
|
+
assert.equal(INSTALL_ECONOMY_MODE, 'ultra');
|
|
15
|
+
assert.equal(buildInstallConfig({ runtime: 'claude' }).agents.economy, 'ultra');
|
|
16
|
+
// The keyless resolved fallback is intentionally conservative.
|
|
17
|
+
assert.equal(DEFAULT_CONFIG_TREE.agents.economy, 'lite');
|
|
18
|
+
});
|
|
19
|
+
|
|
11
20
|
test('CFD-1: buildInstallConfig defaults preserve commit_artifacts:true (back-compat)', () => {
|
|
12
21
|
const cfg = buildInstallConfig({ runtime: 'claude' });
|
|
13
22
|
assert.equal(cfg.workflow.commit_artifacts, true);
|
package/lib/config-schema.cjs
CHANGED
|
@@ -5,6 +5,7 @@ const VALID_SCOPES = Object.freeze(['local', 'global']);
|
|
|
5
5
|
const VALID_MODEL_PROFILES = Object.freeze(['frontier', 'quality', 'balanced', 'budget', 'inherit']);
|
|
6
6
|
const VALID_KNOWLEDGE_ADAPTERS = Object.freeze(['local']);
|
|
7
7
|
const VALID_TIERS = Object.freeze(['haiku', 'sonnet', 'opus']);
|
|
8
|
+
const { VALID_ECONOMY_MODES } = require('./economy-mode.cjs');
|
|
8
9
|
|
|
9
10
|
const SCHEMA = Object.freeze({
|
|
10
11
|
scope: { type: 'enum', values: VALID_SCOPES, optional: true },
|
|
@@ -32,6 +33,10 @@ const SCHEMA = Object.freeze({
|
|
|
32
33
|
research: { type: 'boolean', optional: true },
|
|
33
34
|
plan_checker: { type: 'boolean', optional: true },
|
|
34
35
|
verifier: { type: 'boolean', optional: true },
|
|
36
|
+
architect: { type: 'boolean', optional: true },
|
|
37
|
+
test_writer: { type: 'boolean', optional: true },
|
|
38
|
+
economy: { type: 'enum', values: VALID_ECONOMY_MODES, optional: true },
|
|
39
|
+
economy_critic: { type: 'boolean', optional: true },
|
|
35
40
|
},
|
|
36
41
|
},
|
|
37
42
|
loop: {
|
|
@@ -54,6 +59,7 @@ const SCHEMA = Object.freeze({
|
|
|
54
59
|
style_tier: { type: 'enum', values: VALID_TIERS, optional: true },
|
|
55
60
|
tests_tier: { type: 'enum', values: VALID_TIERS, optional: true },
|
|
56
61
|
acceptance_tier: { type: 'enum', values: VALID_TIERS, optional: true },
|
|
62
|
+
economy_tier: { type: 'enum', values: VALID_TIERS, optional: true },
|
|
57
63
|
},
|
|
58
64
|
},
|
|
59
65
|
knowledge_adapter: { type: 'enum', values: VALID_KNOWLEDGE_ADAPTERS, optional: true },
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
|
|
7
|
+
const { projectStateDir, NubosPilotError } = require('./core.cjs');
|
|
8
|
+
const { slugify } = require('./layout.cjs');
|
|
9
|
+
const { extractFrontmatter } = require('./frontmatter.cjs');
|
|
10
|
+
|
|
11
|
+
// The economy-debt ledger records simplifications a reviewer chose to DEFER
|
|
12
|
+
// rather than fix now — the manual twin of the in-loop Economy critic
|
|
13
|
+
// (agents.economy ∈ {full,ultra}). It exists so "later" does not become "never":
|
|
14
|
+
// /np:simplify-review surfaces over-build, and what is not fixed this round is
|
|
15
|
+
// harvested here. Categories mirror the canonical four economy routes in
|
|
16
|
+
// lib/nubosloop.cjs::ROUTE_TABLE and agents/np-critic-economy.md — keep in sync.
|
|
17
|
+
const ECONOMY_CATEGORIES = Object.freeze([
|
|
18
|
+
'over-engineering',
|
|
19
|
+
'stdlib-reinvention',
|
|
20
|
+
'native-duplication',
|
|
21
|
+
'shrinkable',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const STATUSES = Object.freeze(['open', 'resolved']);
|
|
25
|
+
const MAX_NOTE_LENGTH = 500;
|
|
26
|
+
const ID_LENGTH = 7;
|
|
27
|
+
|
|
28
|
+
function debtRoot(cwd) {
|
|
29
|
+
return path.join(projectStateDir(cwd || process.cwd()), 'economy-debt');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function statusDir(status, cwd) {
|
|
33
|
+
return path.join(debtRoot(cwd), status);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function _entryId(file, line, category, note) {
|
|
37
|
+
const key = String(file) + ':' + String(line) + ':' + String(category) + ':' + String(note);
|
|
38
|
+
return crypto.createHash('sha1').update(key, 'utf-8').digest('hex').slice(0, ID_LENGTH);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function _composeMd(entry) {
|
|
42
|
+
const fm = [
|
|
43
|
+
'---',
|
|
44
|
+
'id: ' + entry.id,
|
|
45
|
+
'category: ' + entry.category,
|
|
46
|
+
'file: ' + entry.file,
|
|
47
|
+
'line: ' + entry.line,
|
|
48
|
+
'created: ' + entry.created,
|
|
49
|
+
'status: ' + entry.status,
|
|
50
|
+
];
|
|
51
|
+
if (entry.resolved) fm.push('resolved: ' + entry.resolved);
|
|
52
|
+
fm.push('---');
|
|
53
|
+
return fm.join('\n') + '\n' + entry.note + '\n';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function _parseEntry(filePath) {
|
|
57
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
58
|
+
const { frontmatter, body } = extractFrontmatter(raw);
|
|
59
|
+
const lineRaw = frontmatter.line;
|
|
60
|
+
const lineNum = Number.parseInt(lineRaw, 10);
|
|
61
|
+
return {
|
|
62
|
+
id: frontmatter.id != null ? String(frontmatter.id) : '',
|
|
63
|
+
category: frontmatter.category != null ? String(frontmatter.category) : '',
|
|
64
|
+
file: frontmatter.file != null ? String(frontmatter.file) : '',
|
|
65
|
+
line: Number.isFinite(lineNum) ? lineNum : 0,
|
|
66
|
+
created: frontmatter.created != null ? String(frontmatter.created) : '',
|
|
67
|
+
resolved: frontmatter.resolved != null ? String(frontmatter.resolved) : '',
|
|
68
|
+
status: frontmatter.status != null ? String(frontmatter.status) : '',
|
|
69
|
+
note: String(body || '').trim(),
|
|
70
|
+
path: filePath,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function _validateInput(input) {
|
|
75
|
+
const file = input && input.file != null ? String(input.file).trim() : '';
|
|
76
|
+
const note = input && input.note != null ? String(input.note).trim() : '';
|
|
77
|
+
const category = input && input.category != null ? String(input.category).trim() : '';
|
|
78
|
+
if (!note) {
|
|
79
|
+
throw new NubosPilotError(
|
|
80
|
+
'economy-debt-missing-note',
|
|
81
|
+
'economy-debt entry requires a non-empty note',
|
|
82
|
+
{},
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (note.length > MAX_NOTE_LENGTH) {
|
|
86
|
+
throw new NubosPilotError(
|
|
87
|
+
'economy-debt-note-too-long',
|
|
88
|
+
'economy-debt note must be <= ' + MAX_NOTE_LENGTH + ' chars',
|
|
89
|
+
{ length: note.length },
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
if (!ECONOMY_CATEGORIES.includes(category)) {
|
|
93
|
+
throw new NubosPilotError(
|
|
94
|
+
'economy-debt-invalid-category',
|
|
95
|
+
'economy-debt category must be one of: ' + ECONOMY_CATEGORIES.join(', '),
|
|
96
|
+
{ category, valid: ECONOMY_CATEGORIES.slice() },
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
let line = 0;
|
|
100
|
+
if (input && input.line != null && String(input.line).trim() !== '') {
|
|
101
|
+
line = Number.parseInt(input.line, 10);
|
|
102
|
+
if (!Number.isFinite(line) || line < 0) {
|
|
103
|
+
throw new NubosPilotError(
|
|
104
|
+
'economy-debt-invalid-line',
|
|
105
|
+
'economy-debt line must be a non-negative integer',
|
|
106
|
+
{ line: input.line },
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { file, note, category, line };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Append a deferred-simplification entry to the open ledger. Idempotent: an
|
|
115
|
+
* identical (file, line, category, note) maps to the same id and is not
|
|
116
|
+
* duplicated — a re-harvest of the same finding is a no-op, returning the
|
|
117
|
+
* existing entry with `created: false`.
|
|
118
|
+
* @returns {{id, category, file, line, created, status, path, was_new: boolean}}
|
|
119
|
+
*/
|
|
120
|
+
function addEntry(input, cwd) {
|
|
121
|
+
const { file, note, category, line } = _validateInput(input);
|
|
122
|
+
const id = _entryId(file, line, category, note);
|
|
123
|
+
const openDir = statusDir('open', cwd);
|
|
124
|
+
const resolvedDir = statusDir('resolved', cwd);
|
|
125
|
+
const slug = slugify(note).slice(0, 48) || 'entry';
|
|
126
|
+
const fileName = id + '-' + slug + '.md';
|
|
127
|
+
|
|
128
|
+
// already open, or already resolved — either way this finding is on record
|
|
129
|
+
const existingOpen = _findById(id, 'open', cwd);
|
|
130
|
+
if (existingOpen) return Object.assign({}, _parseEntry(existingOpen), { was_new: false });
|
|
131
|
+
const existingResolved = _findById(id, 'resolved', cwd);
|
|
132
|
+
if (existingResolved) return Object.assign({}, _parseEntry(existingResolved), { was_new: false });
|
|
133
|
+
|
|
134
|
+
fs.mkdirSync(openDir, { recursive: true });
|
|
135
|
+
const entry = {
|
|
136
|
+
id,
|
|
137
|
+
category,
|
|
138
|
+
file,
|
|
139
|
+
line,
|
|
140
|
+
created: new Date().toISOString(),
|
|
141
|
+
status: 'open',
|
|
142
|
+
note,
|
|
143
|
+
};
|
|
144
|
+
const target = path.join(openDir, fileName);
|
|
145
|
+
fs.writeFileSync(target, _composeMd(entry), 'utf-8');
|
|
146
|
+
void resolvedDir;
|
|
147
|
+
return Object.assign({}, entry, { path: target, was_new: true });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function _findById(id, status, cwd) {
|
|
151
|
+
const dir = statusDir(status, cwd);
|
|
152
|
+
let names;
|
|
153
|
+
try {
|
|
154
|
+
names = fs.readdirSync(dir);
|
|
155
|
+
} catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
const hit = names.find((n) => n.endsWith('.md') && n.slice(0, ID_LENGTH) === id);
|
|
159
|
+
return hit ? path.join(dir, hit) : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* List ledger entries. status: 'open' | 'resolved' | 'all'. Sorted by created
|
|
164
|
+
* ascending (oldest debt first — the longest-deferred is the most urgent).
|
|
165
|
+
*/
|
|
166
|
+
function listEntries(status, cwd) {
|
|
167
|
+
const want = status || 'open';
|
|
168
|
+
const dirs = want === 'all' ? STATUSES.slice() : [want];
|
|
169
|
+
if (want !== 'all' && !STATUSES.includes(want)) {
|
|
170
|
+
throw new NubosPilotError(
|
|
171
|
+
'economy-debt-invalid-status',
|
|
172
|
+
"economy-debt status must be 'open', 'resolved', or 'all'",
|
|
173
|
+
{ status: want },
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
const out = [];
|
|
177
|
+
for (const d of dirs) {
|
|
178
|
+
const dir = statusDir(d, cwd);
|
|
179
|
+
let names;
|
|
180
|
+
try {
|
|
181
|
+
names = fs.readdirSync(dir);
|
|
182
|
+
} catch {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
for (const n of names) {
|
|
186
|
+
if (!n.endsWith('.md')) continue;
|
|
187
|
+
out.push(_parseEntry(path.join(dir, n)));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
out.sort((a, b) => (a.created < b.created ? -1 : a.created > b.created ? 1 : 0));
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Mark an open entry resolved: stamp `resolved` + flip status, then move the
|
|
196
|
+
* file from open/ to resolved/. Throws economy-debt-not-found if no open entry
|
|
197
|
+
* carries the id.
|
|
198
|
+
*/
|
|
199
|
+
function resolveEntry(id, cwd) {
|
|
200
|
+
const wanted = String(id || '').trim();
|
|
201
|
+
if (!wanted) {
|
|
202
|
+
throw new NubosPilotError('economy-debt-missing-id', 'resolve requires an entry id', {});
|
|
203
|
+
}
|
|
204
|
+
const src = _findById(wanted, 'open', cwd);
|
|
205
|
+
if (!src) {
|
|
206
|
+
throw new NubosPilotError(
|
|
207
|
+
'economy-debt-not-found',
|
|
208
|
+
'no open economy-debt entry with id: ' + wanted,
|
|
209
|
+
{ id: wanted },
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
const entry = _parseEntry(src);
|
|
213
|
+
entry.resolved = new Date().toISOString();
|
|
214
|
+
entry.status = 'resolved';
|
|
215
|
+
const resolvedDir = statusDir('resolved', cwd);
|
|
216
|
+
fs.mkdirSync(resolvedDir, { recursive: true });
|
|
217
|
+
const target = path.join(resolvedDir, path.basename(src));
|
|
218
|
+
fs.writeFileSync(target, _composeMd(entry), 'utf-8');
|
|
219
|
+
fs.rmSync(src);
|
|
220
|
+
return Object.assign({}, entry, { path: target });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
module.exports = {
|
|
224
|
+
ECONOMY_CATEGORIES,
|
|
225
|
+
STATUSES,
|
|
226
|
+
MAX_NOTE_LENGTH,
|
|
227
|
+
debtRoot,
|
|
228
|
+
statusDir,
|
|
229
|
+
addEntry,
|
|
230
|
+
listEntries,
|
|
231
|
+
resolveEntry,
|
|
232
|
+
_entryId,
|
|
233
|
+
_parseEntry,
|
|
234
|
+
_composeMd,
|
|
235
|
+
};
|