nubos-pilot 0.1.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.
- package/agents/np-ai-researcher.md +140 -0
- package/agents/np-code-fixer.md +363 -0
- package/agents/np-code-reviewer.md +351 -0
- package/agents/np-domain-researcher.md +136 -0
- package/agents/np-eval-auditor.md +167 -0
- package/agents/np-eval-planner.md +153 -0
- package/agents/np-executor.md +72 -0
- package/agents/np-framework-selector.md +171 -0
- package/agents/np-nyquist-auditor.md +185 -0
- package/agents/np-plan-checker.md +165 -0
- package/agents/np-planner.md +199 -0
- package/agents/np-researcher.md +150 -0
- package/agents/np-security-auditor.md +206 -0
- package/agents/np-ui-auditor.md +369 -0
- package/agents/np-ui-checker.md +192 -0
- package/agents/np-ui-researcher.md +324 -0
- package/agents/np-verifier.md +79 -0
- package/bin/check-coverage.cjs +40 -0
- package/bin/check-workflows.cjs +171 -0
- package/bin/check-workflows.test.cjs +208 -0
- package/bin/install.js +500 -0
- package/bin/np-tools/_commands.cjs +70 -0
- package/bin/np-tools/add-tests.cjs +171 -0
- package/bin/np-tools/add-tests.test.cjs +122 -0
- package/bin/np-tools/add-todo.cjs +108 -0
- package/bin/np-tools/add-todo.test.cjs +112 -0
- package/bin/np-tools/agent-skills.cjs +14 -0
- package/bin/np-tools/agent-skills.test.cjs +42 -0
- package/bin/np-tools/ai-integration-phase.cjs +109 -0
- package/bin/np-tools/ai-integration-phase.test.cjs +123 -0
- package/bin/np-tools/askuser.cjs +53 -0
- package/bin/np-tools/askuser.test.cjs +49 -0
- package/bin/np-tools/autonomous.cjs +69 -0
- package/bin/np-tools/autonomous.test.cjs +74 -0
- package/bin/np-tools/checkpoint.cjs +101 -0
- package/bin/np-tools/checkpoint.test.cjs +119 -0
- package/bin/np-tools/code-review.cjs +133 -0
- package/bin/np-tools/code-review.test.cjs +96 -0
- package/bin/np-tools/commit-task.cjs +120 -0
- package/bin/np-tools/commit-task.test.cjs +160 -0
- package/bin/np-tools/commit.cjs +103 -0
- package/bin/np-tools/commit.test.cjs +93 -0
- package/bin/np-tools/config.cjs +101 -0
- package/bin/np-tools/config.test.cjs +71 -0
- package/bin/np-tools/discuss-phase-power.cjs +265 -0
- package/bin/np-tools/discuss-phase-power.test.cjs +242 -0
- package/bin/np-tools/discuss-phase.cjs +132 -0
- package/bin/np-tools/discuss-phase.test.cjs +148 -0
- package/bin/np-tools/dispatch.cjs +116 -0
- package/bin/np-tools/doctor.cjs +242 -0
- package/bin/np-tools/eval-review.cjs +116 -0
- package/bin/np-tools/eval-review.test.cjs +123 -0
- package/bin/np-tools/execute-phase.cjs +182 -0
- package/bin/np-tools/execute-phase.test.cjs +116 -0
- package/bin/np-tools/execute-plan.cjs +124 -0
- package/bin/np-tools/execute-plan.test.cjs +82 -0
- package/bin/np-tools/help.cjs +28 -0
- package/bin/np-tools/help.test.cjs +29 -0
- package/bin/np-tools/init-dispatch.test.cjs +91 -0
- package/bin/np-tools/metrics.cjs +97 -0
- package/bin/np-tools/metrics.test.cjs +188 -0
- package/bin/np-tools/new-milestone.cjs +288 -0
- package/bin/np-tools/new-milestone.test.cjs +166 -0
- package/bin/np-tools/new-project.cjs +284 -0
- package/bin/np-tools/new-project.test.cjs +165 -0
- package/bin/np-tools/next.cjs +7 -0
- package/bin/np-tools/next.test.cjs +30 -0
- package/bin/np-tools/park.cjs +48 -0
- package/bin/np-tools/park.test.cjs +50 -0
- package/bin/np-tools/pause-work.cjs +24 -0
- package/bin/np-tools/pause-work.test.cjs +74 -0
- package/bin/np-tools/phase.cjs +71 -0
- package/bin/np-tools/phase.test.cjs +81 -0
- package/bin/np-tools/plan-diff.cjs +57 -0
- package/bin/np-tools/plan-diff.test.cjs +134 -0
- package/bin/np-tools/plan-milestone-gaps.cjs +115 -0
- package/bin/np-tools/plan-milestone-gaps.test.cjs +122 -0
- package/bin/np-tools/plan-phase.cjs +350 -0
- package/bin/np-tools/plan-phase.test.cjs +263 -0
- package/bin/np-tools/progress.cjs +7 -0
- package/bin/np-tools/progress.test.cjs +44 -0
- package/bin/np-tools/queue.cjs +213 -0
- package/bin/np-tools/research-phase.cjs +144 -0
- package/bin/np-tools/research-phase.test.cjs +154 -0
- package/bin/np-tools/reset-slice.cjs +17 -0
- package/bin/np-tools/reset-slice.test.cjs +96 -0
- package/bin/np-tools/resolve-model.cjs +110 -0
- package/bin/np-tools/resolve-model.test.cjs +200 -0
- package/bin/np-tools/resume-work.cjs +76 -0
- package/bin/np-tools/resume-work.test.cjs +91 -0
- package/bin/np-tools/skip.cjs +48 -0
- package/bin/np-tools/skip.test.cjs +66 -0
- package/bin/np-tools/slug.cjs +34 -0
- package/bin/np-tools/slug.test.cjs +46 -0
- package/bin/np-tools/state.cjs +16 -0
- package/bin/np-tools/state.test.cjs +40 -0
- package/bin/np-tools/stats.cjs +151 -0
- package/bin/np-tools/stats.test.cjs +118 -0
- package/bin/np-tools/triage.cjs +128 -0
- package/bin/np-tools/ui-phase.cjs +108 -0
- package/bin/np-tools/ui-phase.test.cjs +121 -0
- package/bin/np-tools/ui-review.cjs +108 -0
- package/bin/np-tools/ui-review.test.cjs +120 -0
- package/bin/np-tools/undo-task.cjs +31 -0
- package/bin/np-tools/undo-task.test.cjs +117 -0
- package/bin/np-tools/undo.cjs +43 -0
- package/bin/np-tools/undo.test.cjs +120 -0
- package/bin/np-tools/unpark.cjs +48 -0
- package/bin/np-tools/unpark.test.cjs +50 -0
- package/bin/np-tools/verify-work.cjs +186 -0
- package/bin/np-tools/verify-work.test.cjs +97 -0
- package/docs/adr/0001-no-daemon-invariant.md +82 -0
- package/docs/adr/0002-zero-runtime-dependencies.md +90 -0
- package/docs/adr/0003-max-six-unit-types.md +85 -0
- package/docs/adr/0004-atomic-commit-per-unit.md +102 -0
- package/docs/adr/0005-three-orthogonal-file-trees.md +98 -0
- package/docs/adr/0006-yaml-dependency-amendment.md +60 -0
- package/docs/adr/README.md +27 -0
- package/docs/agent-frontmatter-schema.md +84 -0
- package/docs/phase-artifact-schemas.md +292 -0
- package/docs/phase-directory-layout.md +82 -0
- package/lib/__tests__/README.md +1 -0
- package/lib/agents.cjs +98 -0
- package/lib/agents.test.cjs +286 -0
- package/lib/askuser.cjs +36 -0
- package/lib/askuser.test.cjs +310 -0
- package/lib/checkpoint.cjs +135 -0
- package/lib/checkpoint.test.cjs +184 -0
- package/lib/core.cjs +165 -0
- package/lib/core.test.cjs +405 -0
- package/lib/fixtures/README.md +1 -0
- package/lib/fixtures/phase-tree/README.md +1 -0
- package/lib/fixtures/plans/cycle/PLAN.md +16 -0
- package/lib/fixtures/plans/cycle/tasks/T-01.md +20 -0
- package/lib/fixtures/plans/cycle/tasks/T-02.md +20 -0
- package/lib/fixtures/plans/cycle/tasks/T-03.md +20 -0
- package/lib/fixtures/plans/linear/PLAN.md +16 -0
- package/lib/fixtures/plans/linear/tasks/T-01.md +20 -0
- package/lib/fixtures/plans/linear/tasks/T-02.md +20 -0
- package/lib/fixtures/plans/linear/tasks/T-03.md +20 -0
- package/lib/fixtures/plans/parallel/PLAN.md +16 -0
- package/lib/fixtures/plans/parallel/tasks/T-01.md +20 -0
- package/lib/fixtures/plans/parallel/tasks/T-02.md +20 -0
- package/lib/fixtures/plans/parallel/tasks/T-03.md +20 -0
- package/lib/fixtures/plans/wave-conflict/PLAN.md +16 -0
- package/lib/fixtures/plans/wave-conflict/tasks/T-01.md +20 -0
- package/lib/fixtures/plans/wave-conflict/tasks/T-02.md +20 -0
- package/lib/fixtures/roadmap/ROADMAP-malformed.md +3 -0
- package/lib/fixtures/roadmap/ROADMAP-minimal.md +51 -0
- package/lib/fixtures/roadmap/roadmap-malformed.yaml +7 -0
- package/lib/fixtures/roadmap/roadmap-minimal.yaml +40 -0
- package/lib/fixtures/roadmap/roadmap-ten-phases.yaml +101 -0
- package/lib/fixtures/templates/phase-context.md +6 -0
- package/lib/fixtures/templates/plan-skeleton.md +6 -0
- package/lib/frontmatter.cjs +251 -0
- package/lib/frontmatter.test.cjs +177 -0
- package/lib/gaps.cjs +197 -0
- package/lib/gaps.test.cjs +200 -0
- package/lib/git.cjs +207 -0
- package/lib/git.test.cjs +305 -0
- package/lib/install/agents-md.cjs +77 -0
- package/lib/install/backup.cjs +70 -0
- package/lib/install/codex-toml.cjs +440 -0
- package/lib/install/managed-block.cjs +30 -0
- package/lib/install/manifest.cjs +148 -0
- package/lib/install/mcp-writer.cjs +127 -0
- package/lib/install/runtime-detect.cjs +44 -0
- package/lib/install/staging.cjs +149 -0
- package/lib/metrics-aggregate.cjs +229 -0
- package/lib/metrics-aggregate.test.cjs +192 -0
- package/lib/metrics.cjs +120 -0
- package/lib/metrics.test.cjs +182 -0
- package/lib/model-aliases.regression.test.cjs +16 -0
- package/lib/model-profiles.cjs +42 -0
- package/lib/model-profiles.test.cjs +61 -0
- package/lib/next.cjs +236 -0
- package/lib/next.test.cjs +194 -0
- package/lib/phase.cjs +95 -0
- package/lib/phase.test.cjs +189 -0
- package/lib/plan-checker-contract.test.cjs +72 -0
- package/lib/plan-diff.cjs +173 -0
- package/lib/plan-diff.test.cjs +217 -0
- package/lib/plan.cjs +85 -0
- package/lib/plan.test.cjs +263 -0
- package/lib/progress.cjs +95 -0
- package/lib/progress.test.cjs +116 -0
- package/lib/researcher-contract.test.cjs +61 -0
- package/lib/roadmap-render.cjs +206 -0
- package/lib/roadmap-render.test.cjs +121 -0
- package/lib/roadmap.cjs +416 -0
- package/lib/roadmap.test.cjs +371 -0
- package/lib/runtime/_contract.test.cjs +61 -0
- package/lib/runtime/_readline.cjs +119 -0
- package/lib/runtime/_readline.test.cjs +126 -0
- package/lib/runtime/claude.cjs +48 -0
- package/lib/runtime/claude.test.cjs +101 -0
- package/lib/runtime/codex.cjs +35 -0
- package/lib/runtime/codex.test.cjs +114 -0
- package/lib/runtime/gemini.cjs +35 -0
- package/lib/runtime/gemini.test.cjs +109 -0
- package/lib/runtime/index.cjs +49 -0
- package/lib/runtime/index.test.cjs +181 -0
- package/lib/runtime/opencode.cjs +35 -0
- package/lib/runtime/opencode.test.cjs +124 -0
- package/lib/state.cjs +205 -0
- package/lib/state.test.cjs +264 -0
- package/lib/surface-audit.test.cjs +46 -0
- package/lib/tasks.cjs +327 -0
- package/lib/tasks.test.cjs +389 -0
- package/lib/template.cjs +66 -0
- package/lib/template.test.cjs +159 -0
- package/lib/undo.cjs +179 -0
- package/lib/undo.test.cjs +261 -0
- package/lib/verify.cjs +116 -0
- package/lib/verify.test.cjs +187 -0
- package/np-tools.cjs +303 -0
- package/package.json +39 -0
- package/templates/AI-SPEC.md +90 -0
- package/templates/CONTEXT.md +32 -0
- package/templates/PLAN.md +69 -0
- package/templates/PROJECT.md +60 -0
- package/templates/REQUIREMENTS.md +38 -0
- package/templates/SECURITY.md +61 -0
- package/templates/UI-SPEC.md +64 -0
- package/templates/VALIDATION.md +76 -0
- package/templates/claude/payload/README.md +11 -0
- package/templates/opencode/opencode.json +6 -0
- package/templates/opencode/payload/AGENTS.md +9 -0
- package/workflows/add-backlog.md +212 -0
- package/workflows/add-tests.md +69 -0
- package/workflows/add-todo.md +222 -0
- package/workflows/ai-integration-phase.md +230 -0
- package/workflows/autonomous.md +94 -0
- package/workflows/cleanup.md +325 -0
- package/workflows/code-review-fix.md +435 -0
- package/workflows/code-review.md +447 -0
- package/workflows/discuss-phase-assumptions.md +269 -0
- package/workflows/discuss-phase-power.md +139 -0
- package/workflows/discuss-phase.md +386 -0
- package/workflows/dispatch.md +9 -0
- package/workflows/doctor.md +10 -0
- package/workflows/eval-review.md +243 -0
- package/workflows/execute-phase.md +142 -0
- package/workflows/execute-plan.md +82 -0
- package/workflows/help.md +8 -0
- package/workflows/new-milestone.md +166 -0
- package/workflows/new-project.md +213 -0
- package/workflows/next.md +8 -0
- package/workflows/note.md +244 -0
- package/workflows/park.md +29 -0
- package/workflows/pause-work.md +34 -0
- package/workflows/plan-milestone-gaps.md +233 -0
- package/workflows/plan-phase.md +351 -0
- package/workflows/progress.md +8 -0
- package/workflows/queue.md +9 -0
- package/workflows/research-phase.md +327 -0
- package/workflows/reset-slice.md +39 -0
- package/workflows/resume-work.md +79 -0
- package/workflows/review.md +489 -0
- package/workflows/secure-phase.md +209 -0
- package/workflows/session-report.md +243 -0
- package/workflows/skip.md +29 -0
- package/workflows/state.md +7 -0
- package/workflows/stats.md +170 -0
- package/workflows/thread.md +214 -0
- package/workflows/triage.md +9 -0
- package/workflows/ui-phase.md +246 -0
- package/workflows/ui-review.md +222 -0
- package/workflows/undo-task.md +42 -0
- package/workflows/undo.md +55 -0
- package/workflows/unpark.md +29 -0
- package/workflows/validate-phase.md +231 -0
- package/workflows/verify-work.md +83 -0
package/lib/tasks.cjs
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const { extractFrontmatter } = require('./frontmatter.cjs');
|
|
4
|
+
const { NubosPilotError, withFileLock, atomicWriteFileSync } = require('./core.cjs');
|
|
5
|
+
|
|
6
|
+
const TASK_REQUIRED_FIELDS = [
|
|
7
|
+
'id',
|
|
8
|
+
'phase',
|
|
9
|
+
'plan',
|
|
10
|
+
'type',
|
|
11
|
+
'status',
|
|
12
|
+
'tier',
|
|
13
|
+
'owner',
|
|
14
|
+
'wave',
|
|
15
|
+
'depends_on',
|
|
16
|
+
'files_modified',
|
|
17
|
+
'autonomous',
|
|
18
|
+
'must_haves',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const TASK_STATUS_ENUM = new Set(['pending', 'in-progress', 'done', 'skipped', 'parked']);
|
|
22
|
+
const TASK_TIER_ENUM = new Set(['haiku', 'sonnet', 'opus']);
|
|
23
|
+
const TASK_ID_RE = /^\d{2}-\d{2}-T\d{2}$/;
|
|
24
|
+
|
|
25
|
+
function _isPlainObject(v) {
|
|
26
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function validateTaskFrontmatter(fm, taskId) {
|
|
30
|
+
if (!_isPlainObject(fm)) {
|
|
31
|
+
throw new NubosPilotError(
|
|
32
|
+
'tasks-invalid-frontmatter',
|
|
33
|
+
`Task ${taskId} frontmatter must be an object`,
|
|
34
|
+
{ task: taskId, missing: TASK_REQUIRED_FIELDS.slice(), wrong_type: [] },
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
const missing = [];
|
|
38
|
+
const wrongType = [];
|
|
39
|
+
for (const field of TASK_REQUIRED_FIELDS) {
|
|
40
|
+
if (!(field in fm)) {
|
|
41
|
+
missing.push(field);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if ('depends_on' in fm && !Array.isArray(fm.depends_on)) wrongType.push('depends_on');
|
|
45
|
+
if ('files_modified' in fm && !Array.isArray(fm.files_modified)) wrongType.push('files_modified');
|
|
46
|
+
if ('autonomous' in fm && typeof fm.autonomous !== 'boolean') wrongType.push('autonomous');
|
|
47
|
+
if ('wave' in fm && fm.wave !== null && typeof fm.wave !== 'number') wrongType.push('wave');
|
|
48
|
+
if ('must_haves' in fm && !_isPlainObject(fm.must_haves)) wrongType.push('must_haves');
|
|
49
|
+
|
|
50
|
+
if (missing.length > 0 || wrongType.length > 0) {
|
|
51
|
+
throw new NubosPilotError(
|
|
52
|
+
'tasks-invalid-frontmatter',
|
|
53
|
+
`Task ${taskId} frontmatter invalid (missing: [${missing.join(', ')}], wrong_type: [${wrongType.join(', ')}])`,
|
|
54
|
+
{ task: taskId, missing, wrong_type: wrongType },
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if ('id' in fm && !TASK_ID_RE.test(String(fm.id))) {
|
|
59
|
+
throw new NubosPilotError(
|
|
60
|
+
'tasks-invalid-frontmatter',
|
|
61
|
+
`Task ${taskId} has invalid id format '${fm.id}' (expected <plan-id>-T<NN>, e.g. 04-01-T03)`,
|
|
62
|
+
{ task: taskId, field: 'id', got: fm.id, expected: '<plan-id>-T<NN>' },
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if ('status' in fm && !TASK_STATUS_ENUM.has(fm.status)) {
|
|
66
|
+
throw new NubosPilotError(
|
|
67
|
+
'tasks-invalid-status',
|
|
68
|
+
`Task ${taskId} has invalid status '${fm.status}'`,
|
|
69
|
+
{ task: taskId, got: fm.status, allowed: [...TASK_STATUS_ENUM] },
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if ('tier' in fm && !TASK_TIER_ENUM.has(fm.tier)) {
|
|
73
|
+
throw new NubosPilotError(
|
|
74
|
+
'tasks-invalid-tier',
|
|
75
|
+
`Task ${taskId} has invalid tier '${fm.tier}'`,
|
|
76
|
+
{ task: taskId, got: fm.tier, allowed: [...TASK_TIER_ENUM] },
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if ('owner' in fm && (typeof fm.owner !== 'string' || fm.owner.length === 0)) {
|
|
80
|
+
throw new NubosPilotError(
|
|
81
|
+
'tasks-invalid-owner',
|
|
82
|
+
`Task ${taskId} has invalid owner (must be non-empty string)`,
|
|
83
|
+
{ task: taskId, got: fm.owner },
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function _extractCycle(remaining, children) {
|
|
89
|
+
const remainingSet = remaining instanceof Set ? remaining : new Set(remaining);
|
|
90
|
+
if (remainingSet.size === 0) return [];
|
|
91
|
+
const start = [...remainingSet].sort()[0];
|
|
92
|
+
const path = [];
|
|
93
|
+
const onPath = new Set();
|
|
94
|
+
|
|
95
|
+
function visit(node) {
|
|
96
|
+
path.push(node);
|
|
97
|
+
onPath.add(node);
|
|
98
|
+
const kids = children.get(node) || [];
|
|
99
|
+
const sortedKids = [...kids].sort();
|
|
100
|
+
for (const k of sortedKids) {
|
|
101
|
+
if (!remainingSet.has(k)) continue;
|
|
102
|
+
if (onPath.has(k)) {
|
|
103
|
+
const idx = path.indexOf(k);
|
|
104
|
+
return path.slice(idx).concat(k);
|
|
105
|
+
}
|
|
106
|
+
const result = visit(k);
|
|
107
|
+
if (result) return result;
|
|
108
|
+
}
|
|
109
|
+
path.pop();
|
|
110
|
+
onPath.delete(node);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const result = visit(start);
|
|
115
|
+
if (result) return result;
|
|
116
|
+
return path.length > 0 ? path.slice() : [start];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function computeWaves(tasks) {
|
|
120
|
+
const idSet = new Set(tasks.map((t) => t.id));
|
|
121
|
+
|
|
122
|
+
for (const t of tasks) {
|
|
123
|
+
const deps = t.depends_on || [];
|
|
124
|
+
for (const dep of deps) {
|
|
125
|
+
if (!idSet.has(dep)) {
|
|
126
|
+
throw new NubosPilotError(
|
|
127
|
+
'tasks-unknown-dep',
|
|
128
|
+
`Task ${t.id} depends on unknown task ${dep}`,
|
|
129
|
+
{ task: t.id, missing_dep: dep },
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const indeg = new Map();
|
|
136
|
+
const children = new Map();
|
|
137
|
+
for (const t of tasks) {
|
|
138
|
+
indeg.set(t.id, (t.depends_on || []).length);
|
|
139
|
+
children.set(t.id, []);
|
|
140
|
+
}
|
|
141
|
+
for (const t of tasks) {
|
|
142
|
+
for (const dep of t.depends_on || []) {
|
|
143
|
+
children.get(dep).push(t.id);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const remaining = new Set(tasks.map((t) => t.id));
|
|
148
|
+
const wavesById = new Map();
|
|
149
|
+
const waves = [];
|
|
150
|
+
let waveNum = 1;
|
|
151
|
+
|
|
152
|
+
while (remaining.size > 0) {
|
|
153
|
+
const layer = [...remaining].filter((id) => indeg.get(id) === 0).sort();
|
|
154
|
+
if (layer.length === 0) {
|
|
155
|
+
const cycle = _extractCycle(remaining, children);
|
|
156
|
+
throw new NubosPilotError(
|
|
157
|
+
'tasks-cyclic',
|
|
158
|
+
`Cycle detected in task graph: ${cycle.join(' -> ')}`,
|
|
159
|
+
{ cycle },
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
for (const id of layer) {
|
|
163
|
+
wavesById.set(id, waveNum);
|
|
164
|
+
for (const child of children.get(id) || []) {
|
|
165
|
+
indeg.set(child, indeg.get(child) - 1);
|
|
166
|
+
}
|
|
167
|
+
remaining.delete(id);
|
|
168
|
+
}
|
|
169
|
+
waves.push(layer);
|
|
170
|
+
waveNum += 1;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const warnings = [];
|
|
174
|
+
for (const t of tasks) {
|
|
175
|
+
if (t.wave != null && typeof t.wave === 'number') {
|
|
176
|
+
const computed = wavesById.get(t.id);
|
|
177
|
+
if (t.wave !== computed) {
|
|
178
|
+
warnings.push({
|
|
179
|
+
code: 'wave-override-conflict',
|
|
180
|
+
task: t.id,
|
|
181
|
+
user_wave: t.wave,
|
|
182
|
+
computed_wave: computed,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { waves, wavesById, warnings };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function loadTaskGraph(planDir) {
|
|
192
|
+
const tasksDir = path.join(planDir, 'tasks');
|
|
193
|
+
let entries;
|
|
194
|
+
try {
|
|
195
|
+
entries = fs.readdirSync(tasksDir);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
if (err && err.code === 'ENOENT') {
|
|
198
|
+
return {
|
|
199
|
+
tasks: [],
|
|
200
|
+
graph: new Map(),
|
|
201
|
+
waves: [],
|
|
202
|
+
wavesById: new Map(),
|
|
203
|
+
warnings: [],
|
|
204
|
+
errors: [],
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const mdFiles = entries.filter((n) => n.endsWith('.md'));
|
|
211
|
+
mdFiles.sort();
|
|
212
|
+
|
|
213
|
+
const taskRecords = [];
|
|
214
|
+
for (const name of mdFiles) {
|
|
215
|
+
const absPath = path.join(tasksDir, name);
|
|
216
|
+
const id = name.slice(0, -3);
|
|
217
|
+
const raw = fs.readFileSync(absPath, 'utf-8');
|
|
218
|
+
const { frontmatter } = extractFrontmatter(raw);
|
|
219
|
+
validateTaskFrontmatter(frontmatter, id);
|
|
220
|
+
taskRecords.push({ id, frontmatter, path: absPath });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const computeInput = taskRecords.map((r) => ({
|
|
224
|
+
id: r.id,
|
|
225
|
+
depends_on: r.frontmatter.depends_on || [],
|
|
226
|
+
wave: r.frontmatter.wave,
|
|
227
|
+
}));
|
|
228
|
+
|
|
229
|
+
const { waves, wavesById, warnings } = computeWaves(computeInput);
|
|
230
|
+
|
|
231
|
+
const graph = new Map();
|
|
232
|
+
for (const r of taskRecords) graph.set(r.id, []);
|
|
233
|
+
for (const r of taskRecords) {
|
|
234
|
+
for (const dep of r.frontmatter.depends_on || []) {
|
|
235
|
+
graph.get(dep).push(r.id);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
tasks: taskRecords,
|
|
241
|
+
graph,
|
|
242
|
+
waves,
|
|
243
|
+
wavesById,
|
|
244
|
+
warnings,
|
|
245
|
+
errors: [],
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function _findTaskFile(taskId, planDir) {
|
|
250
|
+
const candidate = path.join(planDir, 'tasks', `${taskId}.md`);
|
|
251
|
+
try {
|
|
252
|
+
fs.accessSync(candidate, fs.constants.F_OK);
|
|
253
|
+
return candidate;
|
|
254
|
+
} catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function _rewriteStatusLine(raw, newStatus) {
|
|
260
|
+
const fmMatch = raw.match(/^(---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/);
|
|
261
|
+
if (!fmMatch) {
|
|
262
|
+
throw new NubosPilotError(
|
|
263
|
+
'task-frontmatter-missing',
|
|
264
|
+
'Task file has no YAML frontmatter block',
|
|
265
|
+
{},
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
const [, openFence, fmBody, closeFence] = fmMatch;
|
|
269
|
+
|
|
270
|
+
const statusRe = /^status:\s*.*$/m;
|
|
271
|
+
if (!statusRe.test(fmBody)) {
|
|
272
|
+
throw new NubosPilotError(
|
|
273
|
+
'task-status-line-missing',
|
|
274
|
+
'Frontmatter does not contain a top-level status: field',
|
|
275
|
+
{},
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
const newFmBody = fmBody.replace(statusRe, `status: ${newStatus}`);
|
|
279
|
+
const rest = raw.slice(fmMatch[0].length);
|
|
280
|
+
return openFence + newFmBody + closeFence + rest;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function setTaskStatus(taskId, newStatus, planDir = process.cwd()) {
|
|
284
|
+
if (!TASK_STATUS_ENUM.has(newStatus)) {
|
|
285
|
+
throw new NubosPilotError(
|
|
286
|
+
'invalid-task-status',
|
|
287
|
+
`Status '${newStatus}' not in enum [${[...TASK_STATUS_ENUM].join(', ')}]`,
|
|
288
|
+
{ taskId, newStatus, allowed: [...TASK_STATUS_ENUM] },
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
const filePath = _findTaskFile(taskId, planDir);
|
|
292
|
+
if (!filePath) {
|
|
293
|
+
throw new NubosPilotError(
|
|
294
|
+
'task-not-found',
|
|
295
|
+
`No task file for id ${taskId} under ${planDir}/tasks/`,
|
|
296
|
+
{ taskId, planDir },
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
return withFileLock(filePath, () => {
|
|
300
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
const { frontmatter } = extractFrontmatter(raw);
|
|
305
|
+
if (!('status' in frontmatter)) {
|
|
306
|
+
throw new NubosPilotError(
|
|
307
|
+
'task-status-line-missing',
|
|
308
|
+
`Task ${taskId} frontmatter has no status field`,
|
|
309
|
+
{ taskId },
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
const next = _rewriteStatusLine(raw, newStatus);
|
|
313
|
+
atomicWriteFileSync(filePath, next);
|
|
314
|
+
return newStatus;
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
module.exports = {
|
|
319
|
+
loadTaskGraph,
|
|
320
|
+
validateTaskFrontmatter,
|
|
321
|
+
computeWaves,
|
|
322
|
+
setTaskStatus,
|
|
323
|
+
TASK_REQUIRED_FIELDS,
|
|
324
|
+
TASK_STATUS_ENUM,
|
|
325
|
+
TASK_TIER_ENUM,
|
|
326
|
+
TASK_ID_RE,
|
|
327
|
+
};
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
|
|
5
|
+
const tasks = require('./tasks.cjs');
|
|
6
|
+
|
|
7
|
+
const FIXTURES = path.join(__dirname, 'fixtures', 'plans');
|
|
8
|
+
|
|
9
|
+
function validFm(overrides) {
|
|
10
|
+
return Object.assign({
|
|
11
|
+
id: '99-01-T01',
|
|
12
|
+
phase: 99,
|
|
13
|
+
plan: '01',
|
|
14
|
+
type: 'execute',
|
|
15
|
+
status: 'pending',
|
|
16
|
+
tier: 'sonnet',
|
|
17
|
+
owner: 'np-executor',
|
|
18
|
+
wave: 1,
|
|
19
|
+
depends_on: [],
|
|
20
|
+
files_modified: [],
|
|
21
|
+
autonomous: true,
|
|
22
|
+
must_haves: { truths: ['stub'], artifacts: [], key_links: [] },
|
|
23
|
+
}, overrides || {});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('TA-1: validateTaskFrontmatter on complete valid fm does not throw', () => {
|
|
27
|
+
assert.doesNotThrow(() => tasks.validateTaskFrontmatter(validFm(), 'T-01'));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('TA-2: missing depends_on throws tasks-invalid-frontmatter with missing field', () => {
|
|
31
|
+
const fm = validFm();
|
|
32
|
+
delete fm.depends_on;
|
|
33
|
+
assert.throws(
|
|
34
|
+
() => tasks.validateTaskFrontmatter(fm, 'T-01'),
|
|
35
|
+
(err) => {
|
|
36
|
+
return err.name === 'NubosPilotError'
|
|
37
|
+
&& err.code === 'tasks-invalid-frontmatter'
|
|
38
|
+
&& Array.isArray(err.details.missing)
|
|
39
|
+
&& err.details.missing.includes('depends_on');
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('TA-3: depends_on as string instead of array throws wrong_type', () => {
|
|
45
|
+
const fm = validFm({ depends_on: 'T-01' });
|
|
46
|
+
assert.throws(
|
|
47
|
+
() => tasks.validateTaskFrontmatter(fm, 'T-02'),
|
|
48
|
+
(err) => {
|
|
49
|
+
return err.code === 'tasks-invalid-frontmatter'
|
|
50
|
+
&& Array.isArray(err.details.wrong_type)
|
|
51
|
+
&& err.details.wrong_type.includes('depends_on');
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('TA-4: autonomous as string throws tasks-invalid-frontmatter', () => {
|
|
57
|
+
const fm = validFm({ autonomous: 'true' });
|
|
58
|
+
assert.throws(
|
|
59
|
+
() => tasks.validateTaskFrontmatter(fm, 'T-01'),
|
|
60
|
+
(err) => {
|
|
61
|
+
return err.code === 'tasks-invalid-frontmatter'
|
|
62
|
+
&& Array.isArray(err.details.wrong_type)
|
|
63
|
+
&& err.details.wrong_type.includes('autonomous');
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('TA-5: computeWaves linear chain produces three sequential waves', () => {
|
|
69
|
+
const result = tasks.computeWaves([
|
|
70
|
+
{ id: 'T-01', depends_on: [] },
|
|
71
|
+
{ id: 'T-02', depends_on: ['T-01'] },
|
|
72
|
+
{ id: 'T-03', depends_on: ['T-02'] },
|
|
73
|
+
]);
|
|
74
|
+
assert.deepEqual(result.waves, [['T-01'], ['T-02'], ['T-03']]);
|
|
75
|
+
assert.equal(result.wavesById.get('T-01'), 1);
|
|
76
|
+
assert.equal(result.wavesById.get('T-03'), 3);
|
|
77
|
+
assert.deepEqual(result.warnings, []);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('TA-6: computeWaves parallel fanout yields two waves with sorted tie-break', () => {
|
|
81
|
+
const result = tasks.computeWaves([
|
|
82
|
+
{ id: 'T-03', depends_on: ['T-01'] },
|
|
83
|
+
{ id: 'T-01', depends_on: [] },
|
|
84
|
+
{ id: 'T-02', depends_on: ['T-01'] },
|
|
85
|
+
]);
|
|
86
|
+
assert.deepEqual(result.waves, [['T-01'], ['T-02', 'T-03']]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('TA-7: computeWaves cycle throws tasks-cyclic with concrete closed cycle', () => {
|
|
90
|
+
assert.throws(
|
|
91
|
+
() => tasks.computeWaves([
|
|
92
|
+
{ id: 'T-01', depends_on: ['T-03'] },
|
|
93
|
+
{ id: 'T-02', depends_on: ['T-01'] },
|
|
94
|
+
{ id: 'T-03', depends_on: ['T-02'] },
|
|
95
|
+
]),
|
|
96
|
+
(err) => {
|
|
97
|
+
return err.code === 'tasks-cyclic'
|
|
98
|
+
&& Array.isArray(err.details.cycle)
|
|
99
|
+
&& err.details.cycle.length >= 3
|
|
100
|
+
&& err.details.cycle[0] === err.details.cycle[err.details.cycle.length - 1];
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('TA-8: computeWaves with unknown dep throws tasks-unknown-dep', () => {
|
|
106
|
+
assert.throws(
|
|
107
|
+
() => tasks.computeWaves([
|
|
108
|
+
{ id: 'T-01', depends_on: ['T-99'] },
|
|
109
|
+
]),
|
|
110
|
+
(err) => {
|
|
111
|
+
return err.code === 'tasks-unknown-dep'
|
|
112
|
+
&& err.details.task === 'T-01'
|
|
113
|
+
&& err.details.missing_dep === 'T-99';
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('TA-9: computeWaves wave-override-conflict warning emitted (computed wins)', () => {
|
|
119
|
+
const result = tasks.computeWaves([
|
|
120
|
+
{ id: 'T-01', depends_on: [], wave: 1 },
|
|
121
|
+
{ id: 'T-02', depends_on: ['T-01'], wave: 5 },
|
|
122
|
+
]);
|
|
123
|
+
assert.deepEqual(result.waves, [['T-01'], ['T-02']]);
|
|
124
|
+
assert.equal(result.warnings.length, 1);
|
|
125
|
+
const w = result.warnings[0];
|
|
126
|
+
assert.equal(w.code, 'wave-override-conflict');
|
|
127
|
+
assert.equal(w.task, 'T-02');
|
|
128
|
+
assert.equal(w.user_wave, 5);
|
|
129
|
+
assert.equal(w.computed_wave, 2);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('TA-10: loadTaskGraph linear fixture returns full graph + waves', () => {
|
|
133
|
+
const result = tasks.loadTaskGraph(path.join(FIXTURES, 'linear'));
|
|
134
|
+
assert.equal(result.tasks.length, 3);
|
|
135
|
+
assert.deepEqual(result.tasks.map((t) => t.id), ['T-01', 'T-02', 'T-03']);
|
|
136
|
+
assert.deepEqual(result.waves, [['T-01'], ['T-02'], ['T-03']]);
|
|
137
|
+
assert.deepEqual(result.warnings, []);
|
|
138
|
+
assert.deepEqual(result.errors, []);
|
|
139
|
+
assert.ok(result.wavesById instanceof Map);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('TA-11: loadTaskGraph on plan with no tasks/ dir returns empty result', () => {
|
|
143
|
+
const fs = require('node:fs');
|
|
144
|
+
const os = require('node:os');
|
|
145
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'np-tasks-'));
|
|
146
|
+
try {
|
|
147
|
+
const result = tasks.loadTaskGraph(root);
|
|
148
|
+
assert.deepEqual(result.tasks, []);
|
|
149
|
+
assert.deepEqual(result.waves, []);
|
|
150
|
+
assert.deepEqual(result.warnings, []);
|
|
151
|
+
assert.deepEqual(result.errors, []);
|
|
152
|
+
} finally {
|
|
153
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('TA-12: loadTaskGraph cycle fixture throws tasks-cyclic', () => {
|
|
158
|
+
assert.throws(
|
|
159
|
+
() => tasks.loadTaskGraph(path.join(FIXTURES, 'cycle')),
|
|
160
|
+
(err) => err.code === 'tasks-cyclic',
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('TA-13: loadTaskGraph wave-conflict fixture surfaces override warning', () => {
|
|
165
|
+
const result = tasks.loadTaskGraph(path.join(FIXTURES, 'wave-conflict'));
|
|
166
|
+
assert.equal(result.tasks.length, 2);
|
|
167
|
+
const conflict = result.warnings.find((w) => w.code === 'wave-override-conflict');
|
|
168
|
+
assert.ok(conflict, 'expected wave-override-conflict warning');
|
|
169
|
+
assert.equal(conflict.task, 'T-02');
|
|
170
|
+
assert.equal(conflict.user_wave, 5);
|
|
171
|
+
assert.equal(conflict.computed_wave, 2);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('TA-14: task id derived from filename strips .md suffix', () => {
|
|
175
|
+
const fs = require('node:fs');
|
|
176
|
+
const os = require('node:os');
|
|
177
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'np-tasks-'));
|
|
178
|
+
try {
|
|
179
|
+
fs.mkdirSync(path.join(root, 'tasks'));
|
|
180
|
+
const fmBlock =
|
|
181
|
+
'---\nid: 99-01-T01\nphase: 99\nplan: 01\ntype: execute\nstatus: pending\ntier: sonnet\nowner: executor\nwave: 1\ndepends_on: []\nfiles_modified: []\nautonomous: true\nmust_haves:\n truths:\n - "stub"\n artifacts: []\n key_links: []\n---\n\nbody\n';
|
|
182
|
+
fs.writeFileSync(path.join(root, 'tasks', 'T-01.md'), fmBlock);
|
|
183
|
+
fs.writeFileSync(path.join(root, 'tasks', '99-weird.md'), fmBlock);
|
|
184
|
+
const result = tasks.loadTaskGraph(root);
|
|
185
|
+
const ids = result.tasks.map((t) => t.id).sort();
|
|
186
|
+
assert.deepEqual(ids, ['99-weird', 'T-01']);
|
|
187
|
+
} finally {
|
|
188
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('TA-15: computeWaves output is deterministic across runs (sorted tie-break)', () => {
|
|
193
|
+
const input = [
|
|
194
|
+
{ id: 'T-03', depends_on: ['T-01'] },
|
|
195
|
+
{ id: 'T-02', depends_on: ['T-01'] },
|
|
196
|
+
{ id: 'T-01', depends_on: [] },
|
|
197
|
+
];
|
|
198
|
+
function serialize(r) {
|
|
199
|
+
return JSON.stringify({
|
|
200
|
+
waves: r.waves,
|
|
201
|
+
wavesById: [...r.wavesById.entries()].sort(),
|
|
202
|
+
warnings: r.warnings,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const a = serialize(tasks.computeWaves(input));
|
|
206
|
+
const b = serialize(tasks.computeWaves(input));
|
|
207
|
+
assert.equal(a, b);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('TA-16: TASK_REQUIRED_FIELDS exports the D-12 list', () => {
|
|
211
|
+
assert.ok(Array.isArray(tasks.TASK_REQUIRED_FIELDS));
|
|
212
|
+
for (const f of ['phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves']) {
|
|
213
|
+
assert.ok(tasks.TASK_REQUIRED_FIELDS.includes(f), `missing ${f}`);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('TA-17: invalid status throws tasks-invalid-status with allowed enum in details', () => {
|
|
218
|
+
const fm = validFm({ status: 'bogus' });
|
|
219
|
+
assert.throws(
|
|
220
|
+
() => tasks.validateTaskFrontmatter(fm, 'T-01'),
|
|
221
|
+
(err) => {
|
|
222
|
+
return err.name === 'NubosPilotError'
|
|
223
|
+
&& err.code === 'tasks-invalid-status'
|
|
224
|
+
&& err.details.got === 'bogus'
|
|
225
|
+
&& Array.isArray(err.details.allowed)
|
|
226
|
+
&& err.details.allowed.includes('pending')
|
|
227
|
+
&& err.details.allowed.includes('done');
|
|
228
|
+
},
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('TA-18: invalid tier throws tasks-invalid-tier with allowed enum in details', () => {
|
|
233
|
+
const fm = validFm({ tier: 'gpt' });
|
|
234
|
+
assert.throws(
|
|
235
|
+
() => tasks.validateTaskFrontmatter(fm, 'T-01'),
|
|
236
|
+
(err) => {
|
|
237
|
+
return err.code === 'tasks-invalid-tier'
|
|
238
|
+
&& err.details.got === 'gpt'
|
|
239
|
+
&& Array.isArray(err.details.allowed)
|
|
240
|
+
&& err.details.allowed.includes('sonnet');
|
|
241
|
+
},
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('TA-19: empty-string owner throws tasks-invalid-owner', () => {
|
|
246
|
+
const fm = validFm({ owner: '' });
|
|
247
|
+
assert.throws(
|
|
248
|
+
() => tasks.validateTaskFrontmatter(fm, 'T-01'),
|
|
249
|
+
(err) => err.code === 'tasks-invalid-owner',
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('TA-20: non-string owner throws tasks-invalid-owner', () => {
|
|
254
|
+
const fm = validFm({ owner: 42 });
|
|
255
|
+
assert.throws(
|
|
256
|
+
() => tasks.validateTaskFrontmatter(fm, 'T-01'),
|
|
257
|
+
(err) => err.code === 'tasks-invalid-owner',
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('TA-21: malformed id throws tasks-invalid-frontmatter with field=id', () => {
|
|
262
|
+
const fm = validFm({ id: 'T-01' });
|
|
263
|
+
assert.throws(
|
|
264
|
+
() => tasks.validateTaskFrontmatter(fm, 'T-01'),
|
|
265
|
+
(err) => {
|
|
266
|
+
return err.code === 'tasks-invalid-frontmatter'
|
|
267
|
+
&& err.details.field === 'id'
|
|
268
|
+
&& err.details.expected === '<plan-id>-T<NN>';
|
|
269
|
+
},
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('TA-22: missing id / status / tier / owner reported as missing fields (tasks-invalid-frontmatter)', () => {
|
|
274
|
+
const fm = validFm();
|
|
275
|
+
delete fm.id;
|
|
276
|
+
delete fm.status;
|
|
277
|
+
delete fm.tier;
|
|
278
|
+
delete fm.owner;
|
|
279
|
+
assert.throws(
|
|
280
|
+
() => tasks.validateTaskFrontmatter(fm, 'T-01'),
|
|
281
|
+
(err) => {
|
|
282
|
+
if (err.code !== 'tasks-invalid-frontmatter') return false;
|
|
283
|
+
const m = err.details.missing;
|
|
284
|
+
return Array.isArray(m)
|
|
285
|
+
&& m.includes('id') && m.includes('status')
|
|
286
|
+
&& m.includes('tier') && m.includes('owner');
|
|
287
|
+
},
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('TA-23: 12-field round-trip — all fields preserved through validateTaskFrontmatter', () => {
|
|
292
|
+
const fm = validFm({ id: '04-01-T07', status: 'in-progress', tier: 'opus', owner: 'np-planner' });
|
|
293
|
+
|
|
294
|
+
const before = JSON.parse(JSON.stringify(fm));
|
|
295
|
+
tasks.validateTaskFrontmatter(fm, '04-01-T07');
|
|
296
|
+
|
|
297
|
+
assert.deepEqual(fm, before);
|
|
298
|
+
|
|
299
|
+
for (const f of tasks.TASK_REQUIRED_FIELDS) {
|
|
300
|
+
assert.ok(f in fm, `field ${f} missing after round-trip`);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('TA-24: TASK_REQUIRED_FIELDS exports the full Phase-4 superset', () => {
|
|
305
|
+
for (const f of ['id', 'status', 'tier', 'owner']) {
|
|
306
|
+
assert.ok(tasks.TASK_REQUIRED_FIELDS.includes(f), `missing phase-4 field ${f}`);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const fs6 = require('node:fs');
|
|
311
|
+
const os6 = require('node:os');
|
|
312
|
+
|
|
313
|
+
function makeTaskFile(planDir, id, status) {
|
|
314
|
+
const dir = path.join(planDir, 'tasks');
|
|
315
|
+
fs6.mkdirSync(dir, { recursive: true });
|
|
316
|
+
const fm = [
|
|
317
|
+
'---',
|
|
318
|
+
`id: ${id}`,
|
|
319
|
+
'phase: 6',
|
|
320
|
+
"plan: '06-01'",
|
|
321
|
+
'type: execute',
|
|
322
|
+
`status: ${status}`,
|
|
323
|
+
'tier: sonnet',
|
|
324
|
+
'owner: np-executor',
|
|
325
|
+
'wave: 1',
|
|
326
|
+
'depends_on: []',
|
|
327
|
+
'files_modified: []',
|
|
328
|
+
'autonomous: true',
|
|
329
|
+
'must_haves:',
|
|
330
|
+
' truths:',
|
|
331
|
+
' - "stub"',
|
|
332
|
+
' artifacts: []',
|
|
333
|
+
' key_links: []',
|
|
334
|
+
'---',
|
|
335
|
+
'',
|
|
336
|
+
'task body',
|
|
337
|
+
'',
|
|
338
|
+
].join('\n');
|
|
339
|
+
const file = path.join(dir, `${id}.md`);
|
|
340
|
+
fs6.writeFileSync(file, fm, 'utf-8');
|
|
341
|
+
return file;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function makePlanSandbox(id, status) {
|
|
345
|
+
const root = fs6.mkdtempSync(path.join(os6.tmpdir(), 'np-tasks-st-'));
|
|
346
|
+
makeTaskFile(root, id, status);
|
|
347
|
+
return root;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
test('TA-25: setTaskStatus mutates frontmatter.status and persists round-trip', () => {
|
|
351
|
+
const planDir = makePlanSandbox('06-01-T01', 'pending');
|
|
352
|
+
try {
|
|
353
|
+
tasks.setTaskStatus('06-01-T01', 'done', planDir);
|
|
354
|
+
const reloaded = tasks.loadTaskGraph(planDir);
|
|
355
|
+
const t = reloaded.tasks.find((x) => x.id === '06-01-T01');
|
|
356
|
+
assert.ok(t, 'task must still be findable after status mutation');
|
|
357
|
+
assert.equal(t.frontmatter.status, 'done');
|
|
358
|
+
} finally {
|
|
359
|
+
fs6.rmSync(planDir, { recursive: true, force: true });
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test('TA-26: setTaskStatus rejects out-of-enum status with NubosPilotError', () => {
|
|
364
|
+
const planDir = makePlanSandbox('06-01-T02', 'pending');
|
|
365
|
+
try {
|
|
366
|
+
assert.throws(
|
|
367
|
+
() => tasks.setTaskStatus('06-01-T02', 'bogus', planDir),
|
|
368
|
+
(err) => {
|
|
369
|
+
return err.name === 'NubosPilotError'
|
|
370
|
+
&& err.code === 'invalid-task-status'
|
|
371
|
+
&& err.details.newStatus === 'bogus';
|
|
372
|
+
},
|
|
373
|
+
);
|
|
374
|
+
} finally {
|
|
375
|
+
fs6.rmSync(planDir, { recursive: true, force: true });
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test('TA-27: setTaskStatus on missing task throws task-not-found', () => {
|
|
380
|
+
const planDir = makePlanSandbox('06-01-T03', 'pending');
|
|
381
|
+
try {
|
|
382
|
+
assert.throws(
|
|
383
|
+
() => tasks.setTaskStatus('06-01-T99', 'done', planDir),
|
|
384
|
+
(err) => err.name === 'NubosPilotError' && err.code === 'task-not-found',
|
|
385
|
+
);
|
|
386
|
+
} finally {
|
|
387
|
+
fs6.rmSync(planDir, { recursive: true, force: true });
|
|
388
|
+
}
|
|
389
|
+
});
|