@michaelxwb/code-flow 0.6.5 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.md +75 -110
  2. package/bin/code-flow.js +12 -0
  3. package/docs/README.md +19 -0
  4. package/docs/USAGE.md +268 -0
  5. package/docs/v1-design.md +1858 -0
  6. package/docs/v1-implementation.md +186 -0
  7. package/lib/adapters.js +126 -0
  8. package/lib/cli.js +258 -0
  9. package/lib/config.js +85 -0
  10. package/lib/doctor.js +283 -0
  11. package/lib/frontmatter.js +42 -0
  12. package/lib/fs.js +141 -0
  13. package/lib/glob.js +23 -0
  14. package/lib/hook.js +99 -0
  15. package/lib/init.js +132 -0
  16. package/lib/learn.js +170 -0
  17. package/lib/locks.js +52 -0
  18. package/lib/markdown.js +13 -0
  19. package/lib/process.js +116 -0
  20. package/lib/render.js +115 -0
  21. package/lib/repo.js +48 -0
  22. package/lib/runs.js +155 -0
  23. package/lib/session.js +40 -0
  24. package/lib/spec.js +80 -0
  25. package/lib/task.js +258 -0
  26. package/lib/taskdoc.js +308 -0
  27. package/lib/toml.js +140 -0
  28. package/lib/verify.js +168 -0
  29. package/lib/worktree.js +930 -0
  30. package/package.json +21 -9
  31. package/templates/adapters/opencode.mjs +65 -0
  32. package/templates/config.json +39 -0
  33. package/templates/instructions.md +13 -0
  34. package/templates/launcher/bridge.cjs +295 -0
  35. package/templates/launcher/hook.cmd +15 -0
  36. package/templates/launcher/hook.ps1 +10 -0
  37. package/templates/launcher/hook.sh +20 -0
  38. package/templates/skills/cf-check.md +19 -0
  39. package/templates/skills/cf-init.md +16 -0
  40. package/templates/skills/cf-spec.md +20 -0
  41. package/templates/skills/cf-task-align.md +19 -0
  42. package/templates/skills/cf-task-archive.md +20 -0
  43. package/templates/skills/cf-task-plan.md +31 -0
  44. package/templates/skills/cf-task-prd.md +14 -0
  45. package/templates/skills/cf-task-start.md +41 -0
  46. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-frontend.md +22 -19
  47. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-full.md +36 -28
  48. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-lite.md +27 -17
  49. package/{src/core/code-flow/specs/shared/prd-template.md → templates/workflow/prd.md} +1 -1
  50. package/templates/workflow/project-guidelines.md +48 -0
  51. package/templates/workflow/scenarios.md +67 -0
  52. package/templates/workflow/task.md +56 -0
  53. package/docs/migrations/spec-workflow-0.6.0.md +0 -42
  54. package/src/adapters/claude/CLAUDE.md +0 -45
  55. package/src/adapters/claude/commands/cf-init.md +0 -264
  56. package/src/adapters/claude/commands/cf-learn.md +0 -291
  57. package/src/adapters/claude/commands/cf-spec.md +0 -67
  58. package/src/adapters/claude/commands/cf-stats.md +0 -86
  59. package/src/adapters/claude/commands/cf-sync.md +0 -25
  60. package/src/adapters/claude/commands/cf-task/align.md +0 -309
  61. package/src/adapters/claude/commands/cf-task/archive.md +0 -161
  62. package/src/adapters/claude/commands/cf-task/block.md +0 -36
  63. package/src/adapters/claude/commands/cf-task/graph.md +0 -80
  64. package/src/adapters/claude/commands/cf-task/note.md +0 -236
  65. package/src/adapters/claude/commands/cf-task/plan.md +0 -363
  66. package/src/adapters/claude/commands/cf-task/prd.md +0 -162
  67. package/src/adapters/claude/commands/cf-task/start.md +0 -304
  68. package/src/adapters/claude/commands/cf-task/status.md +0 -59
  69. package/src/adapters/claude/commands/cf-task/verify-e2e.md +0 -115
  70. package/src/adapters/claude/commands/cf-validate.md +0 -75
  71. package/src/adapters/claude/settings.local.json +0 -50
  72. package/src/adapters/codex/AGENTS.md +0 -45
  73. package/src/adapters/codex/config.toml +0 -3
  74. package/src/adapters/codex/hooks.json +0 -50
  75. package/src/adapters/codex/skills/cf-init/SKILL.md +0 -273
  76. package/src/adapters/codex/skills/cf-learn/SKILL.md +0 -296
  77. package/src/adapters/codex/skills/cf-spec/SKILL.md +0 -60
  78. package/src/adapters/codex/skills/cf-stats/SKILL.md +0 -87
  79. package/src/adapters/codex/skills/cf-sync/SKILL.md +0 -30
  80. package/src/adapters/codex/skills/cf-task-align/SKILL.md +0 -310
  81. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +0 -162
  82. package/src/adapters/codex/skills/cf-task-block/SKILL.md +0 -37
  83. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +0 -81
  84. package/src/adapters/codex/skills/cf-task-note/SKILL.md +0 -233
  85. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +0 -324
  86. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +0 -163
  87. package/src/adapters/codex/skills/cf-task-start/SKILL.md +0 -305
  88. package/src/adapters/codex/skills/cf-task-status/SKILL.md +0 -60
  89. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +0 -111
  90. package/src/adapters/codex/skills/cf-validate/SKILL.md +0 -76
  91. package/src/adapters/costrict/CLAUDE.md +0 -45
  92. package/src/adapters/costrict/commands/cf-init.md +0 -264
  93. package/src/adapters/costrict/commands/cf-learn.md +0 -291
  94. package/src/adapters/costrict/commands/cf-spec.md +0 -67
  95. package/src/adapters/costrict/commands/cf-stats.md +0 -86
  96. package/src/adapters/costrict/commands/cf-sync.md +0 -25
  97. package/src/adapters/costrict/commands/cf-task/align.md +0 -309
  98. package/src/adapters/costrict/commands/cf-task/archive.md +0 -161
  99. package/src/adapters/costrict/commands/cf-task/block.md +0 -36
  100. package/src/adapters/costrict/commands/cf-task/graph.md +0 -80
  101. package/src/adapters/costrict/commands/cf-task/note.md +0 -236
  102. package/src/adapters/costrict/commands/cf-task/plan.md +0 -363
  103. package/src/adapters/costrict/commands/cf-task/prd.md +0 -162
  104. package/src/adapters/costrict/commands/cf-task/start.md +0 -304
  105. package/src/adapters/costrict/commands/cf-task/status.md +0 -59
  106. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +0 -115
  107. package/src/adapters/costrict/commands/cf-validate.md +0 -75
  108. package/src/adapters/costrict/settings.local.json +0 -50
  109. package/src/adapters/opencode/AGENTS.md +0 -45
  110. package/src/adapters/opencode/commands/cf-init.md +0 -281
  111. package/src/adapters/opencode/commands/cf-learn.md +0 -295
  112. package/src/adapters/opencode/commands/cf-spec.md +0 -59
  113. package/src/adapters/opencode/commands/cf-stats.md +0 -90
  114. package/src/adapters/opencode/commands/cf-sync.md +0 -29
  115. package/src/adapters/opencode/commands/cf-task/align.md +0 -314
  116. package/src/adapters/opencode/commands/cf-task/archive.md +0 -166
  117. package/src/adapters/opencode/commands/cf-task/block.md +0 -40
  118. package/src/adapters/opencode/commands/cf-task/graph.md +0 -84
  119. package/src/adapters/opencode/commands/cf-task/note.md +0 -240
  120. package/src/adapters/opencode/commands/cf-task/plan.md +0 -363
  121. package/src/adapters/opencode/commands/cf-task/prd.md +0 -167
  122. package/src/adapters/opencode/commands/cf-task/start.md +0 -309
  123. package/src/adapters/opencode/commands/cf-task/status.md +0 -63
  124. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +0 -115
  125. package/src/adapters/opencode/commands/cf-validate.md +0 -79
  126. package/src/adapters/opencode/opencode.json +0 -3
  127. package/src/adapters/opencode/plugins/code-flow/index.js +0 -213
  128. package/src/adapters/opencode/plugins/code-flow/package.json +0 -6
  129. package/src/cli.js +0 -1023
  130. package/src/core/code-flow/config.yml +0 -158
  131. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
  132. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +0 -294
  133. package/src/core/code-flow/scripts/cf_acceptance_runner.py +0 -192
  134. package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
  135. package/src/core/code-flow/scripts/cf_checks.py +0 -336
  136. package/src/core/code-flow/scripts/cf_core.py +0 -787
  137. package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
  138. package/src/core/code-flow/scripts/cf_feedback.py +0 -56
  139. package/src/core/code-flow/scripts/cf_log.py +0 -154
  140. package/src/core/code-flow/scripts/cf_post_hook.py +0 -196
  141. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +0 -104
  142. package/src/core/code-flow/scripts/cf_scan.py +0 -295
  143. package/src/core/code-flow/scripts/cf_session_state.py +0 -37
  144. package/src/core/code-flow/scripts/cf_spec_context.py +0 -1746
  145. package/src/core/code-flow/scripts/cf_spec_gate.py +0 -264
  146. package/src/core/code-flow/scripts/cf_spec_metadata.py +0 -384
  147. package/src/core/code-flow/scripts/cf_spec_migrate.py +0 -512
  148. package/src/core/code-flow/scripts/cf_spec_resolver.py +0 -347
  149. package/src/core/code-flow/scripts/cf_spec_router.py +0 -244
  150. package/src/core/code-flow/scripts/cf_spec_session.py +0 -119
  151. package/src/core/code-flow/scripts/cf_spec_verify.py +0 -559
  152. package/src/core/code-flow/scripts/cf_stats.py +0 -506
  153. package/src/core/code-flow/scripts/cf_stop_hook.py +0 -381
  154. package/src/core/code-flow/scripts/cf_sync.py +0 -140
  155. package/src/core/code-flow/scripts/cf_task_index.py +0 -148
  156. package/src/core/code-flow/scripts/cf_task_parallel.py +0 -394
  157. package/src/core/code-flow/scripts/cf_task_runtime.py +0 -304
  158. package/src/core/code-flow/scripts/cf_task_state.py +0 -3
  159. package/src/core/code-flow/scripts/cf_task_workflow.py +0 -205
  160. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +0 -178
  161. package/src/core/code-flow/scripts/cf_validation.py +0 -81
  162. package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
  163. package/src/core/code-flow/scripts/cf_workflow_transaction.py +0 -96
  164. package/src/core/code-flow/specs/backend/_map.md +0 -56
  165. package/src/core/code-flow/specs/backend/code-quality-performance.md +0 -59
  166. package/src/core/code-flow/specs/backend/database.md +0 -51
  167. package/src/core/code-flow/specs/backend/directory-structure.md +0 -54
  168. package/src/core/code-flow/specs/backend/logging.md +0 -50
  169. package/src/core/code-flow/specs/backend/platform-rules.md +0 -55
  170. package/src/core/code-flow/specs/frontend/_map.md +0 -52
  171. package/src/core/code-flow/specs/frontend/component-specs.md +0 -68
  172. package/src/core/code-flow/specs/frontend/directory-structure.md +0 -53
  173. package/src/core/code-flow/specs/frontend/quality-standards.md +0 -58
  174. package/src/core/code-flow/specs/shared/_map.md +0 -57
  175. package/src/core/code-flow/tasks/.gitkeep +0 -0
  176. package/src/core/code-flow/validation.yml +0 -53
  177. package/src/migrate/spec-workflow.js +0 -238
package/lib/task.js ADDED
@@ -0,0 +1,258 @@
1
+ 'use strict';
2
+ const path = require('node:path');
3
+ const fs = require('node:fs');
4
+ const f = require('./fs');
5
+ const config = require('./config');
6
+ const session = require('./session');
7
+ const taskdoc = require('./taskdoc');
8
+ const repo = require('./repo');
9
+
10
+ const STATES = taskdoc.STATES;
11
+
12
+ function loadDocument(root, relative, writable = false) {
13
+ f.string(relative, 'task path');
14
+ const normalized = relative.replace(/\\/g, '/').replace(/^\.\//, '');
15
+ f.demand(/^\.code-flow\/tasks\/(?:archived\/)?\d{4}-\d{2}-\d{2}\/[^/]+\/[^/]+\.md$/.test(normalized),
16
+ 'task_path', 'Expected complete task document path: ' + relative, 2);
17
+ f.demand(!normalized.includes('/./'), 'task_path', 'Task path must be canonical', 2);
18
+ f.demand(!/\.(prd|design|scenarios)\.md$/.test(normalized), 'task_path', 'Expected task document, not PRD/design/scenarios', 2);
19
+ f.demand(!writable || !normalized.startsWith('.code-flow/tasks/archived/'),
20
+ 'task_archived', 'Cannot mutate an archived task document', 2);
21
+ const file = f.inside(root, normalized);
22
+ const raw = f.readText(file, 1024 * 1024);
23
+ f.demand(raw !== null, 'task_missing', 'Missing task document: ' + relative, 2);
24
+ const document = taskdoc.parse(raw, file);
25
+ return { ...document, file, relative: normalized, raw };
26
+ }
27
+
28
+ function resolveTask(document, taskId) {
29
+ if (taskId) return taskdoc.find(document, taskId);
30
+ f.demand(document.tasks.length === 1, 'task_id_required',
31
+ 'Task document has multiple TASK sections; pass --task TASK-NNN', 2);
32
+ return document.tasks[0];
33
+ }
34
+
35
+ function loadTask(root, relative, taskId = '', writable = false) {
36
+ const document = loadDocument(root, relative, writable);
37
+ const task = resolveTask(document, taskId);
38
+ return taskdoc.taskFrom(document, task);
39
+ }
40
+
41
+ function load(root, relative, taskId = '') {
42
+ return loadTask(root, relative, taskId);
43
+ }
44
+
45
+ function writeDocument(document) {
46
+ f.atomicWrite(document.file, taskdoc.write(document), document.raw);
47
+ }
48
+
49
+ async function withDocumentLock(root, relative, operation) {
50
+ for (let attempt = 0; attempt < 200; attempt++) {
51
+ try { return await f.withLock(root, 'doc:' + relative, operation); } catch (error) {
52
+ if (error.code !== 'locked') throw error;
53
+ await new Promise(resolve => setTimeout(resolve, 10));
54
+ }
55
+ }
56
+ throw new f.FlowError('locked', 'Document write lock timed out: ' + relative, 1);
57
+ }
58
+
59
+ function withTaskLock(root, relative, taskId, operation) {
60
+ return f.withLock(root, 'task:' + relative + '#' + taskId, () =>
61
+ withDocumentLock(root, relative, operation));
62
+ }
63
+
64
+ async function start(root, relative, taskId, value) {
65
+ config.load(root);
66
+ if (value) session.location(root, value);
67
+ const initial = loadTask(root, relative, taskId, true);
68
+ return withTaskLock(root, initial.relative, initial.id, async () => {
69
+ const task = loadTask(root, initial.relative, initial.id, true);
70
+ f.demand(task.status !== 'done', 'task_state', 'Use task reopen for a done task', 1);
71
+ let document = task.document;
72
+ if (task.status !== 'in-progress') {
73
+ document = taskdoc.updateTask(document, task.id,
74
+ { status: 'in-progress', log: 'started tests=' + taskdoc.testsDigest(task) });
75
+ writeDocument({ ...document, raw: document.raw });
76
+ }
77
+ const updated = loadTask(root, initial.relative, initial.id);
78
+ const result = require('./render').inject(root, { taskFile: updated.relative, taskId: updated.id, session: value });
79
+ const diagnostics = result.diagnostics;
80
+ if (value) {
81
+ try { await session.bind(root, value, updated.relative, updated.id); } catch (error) {
82
+ diagnostics.push({ code: 'binding_failed', message: 'Task started; binding failed: ' + error.message });
83
+ }
84
+ }
85
+ return { ok: true, action: 'start', task: updated.relative + '#' + updated.id, ...result, diagnostics };
86
+ });
87
+ }
88
+
89
+ async function transition(root, relative, taskId, action, reason) {
90
+ config.load(root); f.string(reason, 'reason');
91
+ const initial = loadTask(root, relative, taskId, true);
92
+ return withTaskLock(root, initial.relative, initial.id, async () => {
93
+ const task = loadTask(root, initial.relative, initial.id, true);
94
+ const from = action === 'block' ? 'in-progress' : 'done';
95
+ f.demand(task.status === from, 'task_state', action + ' requires status ' + from, 1);
96
+ const document = taskdoc.updateTask(task.document, task.id, {
97
+ status: action === 'block' ? 'blocked' : 'in-progress', log: action + ': ' + reason
98
+ });
99
+ writeDocument(document);
100
+ return { ok: true, action, task: task.relative + '#' + task.id };
101
+ });
102
+ }
103
+
104
+ function isTaskDocument(relative) {
105
+ const match = /^\.code-flow\/tasks\/(?:archived\/)?\d{4}-\d{2}-\d{2}\/([^/]+)\/([^/]+)\.md$/.exec(relative);
106
+ return Boolean(match) && match[1] === match[2];
107
+ }
108
+
109
+ function list(root, directory = '.code-flow/tasks') {
110
+ const base = f.inside(root, directory);
111
+ f.demand(fs.existsSync(base), 'task_missing', 'Task directory missing: ' + directory, 1);
112
+ const relative = path.relative(root, base).split(path.sep).join('/');
113
+ f.demand(relative === '.code-flow/tasks' || relative.startsWith('.code-flow/tasks/'),
114
+ 'task_path', 'Expected task directory', 2);
115
+ const names = require('./spec').walk(base).filter(name => name.endsWith('.md'));
116
+ const items = [], diagnostics = [];
117
+ let ignored = 0;
118
+ for (const name of names.filter(name => !name.startsWith('archived/'))) {
119
+ const taskPath = relative + '/' + name;
120
+ if (!isTaskDocument(taskPath)) {
121
+ if (!/\.(prd|design|scenarios)\.md$/.test(name)) ignored++;
122
+ continue;
123
+ }
124
+ try {
125
+ const document = loadDocument(root, taskPath);
126
+ for (const task of document.tasks) {
127
+ const verification = task.text.match(/^- \d{4}-[^\r\n]*(?:PASS|FAIL|SKIPPED)[^\r\n]*/gm) || [];
128
+ const summary = verification.at(-1) ?? null;
129
+ const head = summary ? /\bhead=([a-f0-9]{7,40}|none)\b/.exec(summary)?.[1] ?? null : null;
130
+ items.push({ task: taskPath + '#' + task.id, task_file: taskPath, task_id: task.id,
131
+ status: task.status, depends: task.depends, tests: task.tests.length,
132
+ spec_refs: task.specRefs, scenarios: task.scenarios, verification: summary,
133
+ verified_head: head === 'none' ? null : head,
134
+ tests_changed: Boolean(summary && /\btests-changed\b/.test(summary)) });
135
+ }
136
+ } catch (error) { diagnostics.push({ source: taskPath, code: error.code, message: error.message }); }
137
+ }
138
+ return { ok: diagnostics.length === 0, items, diagnostics, ignored };
139
+ }
140
+
141
+ async function verify(root, relative, taskId, options = {}) {
142
+ const settings = config.load(root), task = loadTask(root, relative, taskId);
143
+ const tests = task.tests;
144
+ if (tests.length === 0) {
145
+ f.demand(task.skipTests, 'tests_missing', 'Declare tests or skip_tests with skip_reason', 1);
146
+ return { ok: true, action: 'verify', skipped: task.skipReason, results: [], executed: 0, deduped: 0 };
147
+ }
148
+ const env = options.env ?? require('./runs').workerEnv(root);
149
+ return { action: 'verify', ...await require('./verify').run(root, tests, settings.tests, { ...options, env }) };
150
+ }
151
+
152
+ function verificationLines(result, meta = {}) {
153
+ const label = result.skipped ? 'SKIPPED' : result.ok ? 'PASS' : 'FAIL';
154
+ const summary = '- ' + new Date().toISOString() + ' ' + label + ' commands=' +
155
+ result.results.filter(item => item.status === 'pass').length + '/' + result.results.length +
156
+ ' executed=' + result.executed + ' deduped=' + result.deduped +
157
+ (result.elapsed_ms === undefined ? '' : ' elapsed=' + Math.round(result.elapsed_ms) + 'ms') +
158
+ ' tests=' + (meta.tests ?? 'unknown') +
159
+ ' head=' + (meta.head ? String(meta.head).slice(0, 12) : 'none') +
160
+ ' dirty=' + (meta.dirty === null || meta.dirty === undefined ? 'unknown' : meta.dirty ? '1' : '0') +
161
+ (meta.testsChanged ? ' tests-changed' : '') +
162
+ (result.skipped ? ' reason=' + result.skipped : '');
163
+ return [summary, ...result.results.map(item => ' - ' + item.id + ' ' + item.status.toUpperCase() +
164
+ (item.exit_code === undefined ? '' : ' exit=' + item.exit_code))];
165
+ }
166
+
167
+ async function finish(root, relative, taskId, options = {}) {
168
+ const initial = loadTask(root, relative, taskId, true);
169
+ return f.withLock(root, 'task:' + initial.relative + '#' + initial.id, async () => {
170
+ const task = loadTask(root, initial.relative, initial.id, true);
171
+ f.demand(task.status === 'in-progress', 'task_state', 'finish requires in-progress', 1);
172
+ const declared = taskdoc.testsDigest(task), started = taskdoc.startedTests(task.text);
173
+ const gitState = repo.state(root);
174
+ const result = await verify(root, task.relative, task.id, options), diagnostics = [];
175
+ try { require('./learn').deltas(task); } catch (error) {
176
+ diagnostics.push({ code: error.code, message: error.message });
177
+ }
178
+ return withDocumentLock(root, task.relative, async () => {
179
+ const current = loadTask(root, task.relative, task.id, true);
180
+ f.demand(current.text === task.text, 'concurrent_change',
181
+ 'Concurrent change: Task section changed during finish', 1);
182
+ const document = taskdoc.updateTask(current.document, task.id, {
183
+ status: result.ok ? 'done' : 'in-progress',
184
+ verification: verificationLines(result, { tests: declared,
185
+ testsChanged: started !== null && started !== declared,
186
+ head: gitState.head, dirty: gitState.dirty })
187
+ });
188
+ writeDocument(document);
189
+ return { ...result, action: 'finish', task: task.relative + '#' + task.id, diagnostics };
190
+ });
191
+ });
192
+ }
193
+
194
+ function requirement(root, relative) {
195
+ const normalized = relative.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, '');
196
+ f.demand(/^\.code-flow\/tasks\/\d{4}-\d{2}-\d{2}\/[^/]+$/.test(normalized),
197
+ 'requirement_path', 'Expected unarchived requirement directory', 2);
198
+ const directory = f.inside(root, normalized);
199
+ const name = path.basename(normalized) + '.md';
200
+ const taskPath = normalized + '/' + name;
201
+ f.demand(fs.existsSync(path.join(root, taskPath)), 'tasks_missing', 'Requirement task document missing: ' + taskPath, 1);
202
+ const document = loadDocument(root, taskPath, true);
203
+ f.demand(document.tasks.length > 0, 'tasks_missing', 'Requirement has no TASK sections', 1);
204
+ const tasks = document.tasks.map(task => taskdoc.taskFrom(document, task));
205
+ f.demand(tasks.every(task => task.status === 'done'), 'tasks_unfinished', 'All tasks must be done', 1);
206
+ const target = f.inside(root, '.code-flow/tasks/archived/' + normalized.slice('.code-flow/tasks/'.length));
207
+ f.demand(!fs.existsSync(target), 'archive_conflict', 'Archive target exists: ' + target, 1);
208
+ return { directory, target, document, tasks, normalized, name };
209
+ }
210
+
211
+ function removeEmptyDirectory(directory) {
212
+ try { fs.rmdirSync(directory); } catch (error) {
213
+ if (error.code !== 'ENOTEMPTY' && error.code !== 'ENOENT') throw error;
214
+ }
215
+ }
216
+
217
+ async function archive(root, relative, options = {}) {
218
+ config.load(root);
219
+ require('./repo').requireCoordinator(root, 'task archive');
220
+ require('./runs').assertArchiveAllowed(root, relative);
221
+ const initial = requirement(root, relative), learn = require('./learn');
222
+ if (options.dryRun) return {
223
+ ...(options.noLearn ? { ok: true, dry_run: true, results: [] } : learn.preview(learn.plan(root, initial.tasks))),
224
+ action: 'archive', archived_to: initial.target };
225
+ return require('./locks').withRepository(root, () => f.withLock(root, 'write', async () => {
226
+ require('./repo').requireCoordinator(root, 'task archive');
227
+ require('./runs').assertArchiveAllowed(root, relative);
228
+ const current = requirement(root, relative);
229
+ let learned = { results: [], learning_skipped: Boolean(options.noLearn) };
230
+ if (!options.noLearn) learned = learn.commit(learn.plan(root, current.tasks));
231
+ f.demand(f.readText(current.document.file) === current.document.raw,
232
+ 'concurrent_change', 'Task document changed during archive', 1);
233
+ f.demand(!fs.existsSync(current.target), 'archive_conflict', 'Archive target appeared', 1);
234
+ fs.mkdirSync(path.dirname(current.target), { recursive: true });
235
+ fs.renameSync(current.directory, current.target);
236
+ removeEmptyDirectory(path.dirname(current.directory));
237
+ return { ...learned, ok: true, action: 'archive', archived_to: current.target };
238
+ }));
239
+ }
240
+
241
+ async function learn(root, relative, taskId, dryRun = false) {
242
+ config.load(root);
243
+ require('./repo').requireCoordinator(root, 'spec learn');
244
+ require('./runs').assertLearnAllowed(root);
245
+ const task = loadTask(root, relative, taskId, true);
246
+ if (dryRun) return require('./learn').apply(root, [task], true);
247
+ return require('./locks').withRepository(root, () => {
248
+ require('./repo').requireCoordinator(root, 'spec learn');
249
+ require('./runs').assertLearnAllowed(root);
250
+ return f.withLock(root, 'task:' + task.relative + '#' + task.id,
251
+ () => require('./learn').apply(root, [loadTask(root, task.relative, task.id, true)]));
252
+ });
253
+ }
254
+
255
+ module.exports = {
256
+ STATES, archive, finish, learn, list, load, loadDocument, loadTask,
257
+ requirement, start, transition, validateTests: taskdoc.validateTests, verify
258
+ };
package/lib/taskdoc.js ADDED
@@ -0,0 +1,308 @@
1
+ 'use strict';
2
+ const path = require('node:path');
3
+ const f = require('./fs');
4
+ const fm = require('./frontmatter');
5
+ const config = require('./config');
6
+ const glob = require('./glob');
7
+ const markdown = require('./markdown');
8
+
9
+ const STATES = ['draft', 'in-progress', 'done', 'blocked'];
10
+ const RESERVED_ENV = new Set(['CF_WORKTREE_ID', 'CF_TASK_ID', 'CF_TASK_IDS', 'CF_RUN_ID',
11
+ 'CF_PORT_OFFSET', 'CF_DB_SUFFIX']);
12
+ const FIELDS = {
13
+ Status: 'status', Depends: 'depends', Source: 'source', Scenarios: 'scenarios',
14
+ 'Spec-Refs': 'specRefs', Paths: 'paths', E2E: 'e2e',
15
+ 'Skip-Tests': 'skipTests', 'Skip-Reason': 'skipReason'
16
+ };
17
+
18
+ function heading(line) {
19
+ const match = /^##[ \t]+(TASK-\d+):[ \t]*(.*)$/.exec(line.replace(/\r$/, ''));
20
+ return match ? { id: match[1], title: match[2].trim() } : null;
21
+ }
22
+
23
+ function splitSections(body) {
24
+ const lines = body.split('\n');
25
+ const headings = [];
26
+ let fence = null, offset = 0;
27
+ for (const line of lines) {
28
+ const marker = markdown.fenceMarker(line, fence);
29
+ if (marker) {
30
+ fence = marker.next;
31
+ } else if (!fence && /^##[ \t]/.test(line)) {
32
+ const parsed = heading(line);
33
+ if (parsed) headings.push({ ...parsed, start: offset });
34
+ }
35
+ offset += line.length + 1;
36
+ }
37
+ if (!headings.length) throw new f.FlowError('tasks_missing', 'Task document has no TASK sections', 2);
38
+ const preamble = body.slice(0, headings[0].start);
39
+ const sections = headings.map((item, index) => ({
40
+ ...item,
41
+ text: body.slice(item.start, index + 1 < headings.length ? headings[index + 1].start : body.length)
42
+ }));
43
+ return { preamble, sections };
44
+ }
45
+
46
+ function fieldEntries(text) {
47
+ const values = new Map();
48
+ let fence = null, offset = 0;
49
+ for (const line of text.split('\n')) {
50
+ const start = offset;
51
+ offset += line.length + 1;
52
+ const marker = markdown.fenceMarker(line, fence);
53
+ if (marker) {
54
+ fence = marker.next;
55
+ continue;
56
+ }
57
+ if (fence) continue;
58
+ const match = /^- \*\*([^*]+)\*\*:[ \t]*(.*)$/.exec(line.replace(/\r$/, ''));
59
+ if (match && Object.hasOwn(FIELDS, match[1])) {
60
+ const name = FIELDS[match[1]];
61
+ f.demand(!values.has(name), 'task_field_duplicate', 'Duplicate Task field: ' + match[1]);
62
+ values.set(name, { value: match[2].trim(), start, end: start + line.length });
63
+ }
64
+ }
65
+ return values;
66
+ }
67
+
68
+ function fieldMap(text) {
69
+ return Object.fromEntries([...fieldEntries(text)].map(([name, item]) => [name, item.value]));
70
+ }
71
+
72
+ function subsectionRange(text, name) {
73
+ let area = null, fence = null, offset = 0;
74
+ for (const line of text.split('\n')) {
75
+ const start = offset;
76
+ offset += line.length + 1;
77
+ const marker = markdown.fenceMarker(line, fence);
78
+ if (marker) {
79
+ fence = marker.next;
80
+ continue;
81
+ }
82
+ if (fence) continue;
83
+ if (area && /^###[ \t]/.test(line)) return { ...area, end: start };
84
+ if (line.replace(/\r$/, '') === '### ' + name) {
85
+ area = { start, contentStart: Math.min(offset, text.length) };
86
+ }
87
+ }
88
+ return area ? { ...area, end: text.length } : null;
89
+ }
90
+
91
+ function subsection(text, name) {
92
+ const area = subsectionRange(text, name);
93
+ return area ? text.slice(area.contentStart, area.end) : null;
94
+ }
95
+
96
+ function jsonArray(text, name) {
97
+ if (text === null) return [];
98
+ const match = /```(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n```/.exec(text);
99
+ if (!match) throw new f.FlowError('task_json_missing', 'Missing JSON block in ### ' + name, 2);
100
+ let value;
101
+ try { value = JSON.parse(match[1]); } catch (error) {
102
+ throw new f.FlowError('task_json_invalid', 'Invalid JSON in ### ' + name + ': ' + error.message, 2);
103
+ }
104
+ f.demand(Array.isArray(value), 'task_json_invalid', '### ' + name + ' must be a JSON array');
105
+ return value;
106
+ }
107
+
108
+ function parseList(value) {
109
+ return [...new Set(String(value ?? '').split(/[,\s]+/).map(item => item.trim()).filter(Boolean))];
110
+ }
111
+
112
+ function canonical(value) {
113
+ if (Array.isArray(value)) return value.map(canonical);
114
+ if (value !== null && typeof value === 'object') {
115
+ return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]));
116
+ }
117
+ return value;
118
+ }
119
+
120
+ function testsDigest(task) {
121
+ return f.hash(JSON.stringify(canonical(task.tests))).slice(0, 12);
122
+ }
123
+
124
+ function startedTests(text) {
125
+ const matches = [...String(text).matchAll(/^-[ \t]+\[[^\]]+\][ \t]+started[ \t]+tests=([a-f0-9]{12})/gm)];
126
+ return matches.length ? matches.at(-1)[1] : null;
127
+ }
128
+
129
+ function validateTests(tests) {
130
+ f.demand(Array.isArray(tests), 'task_tests', 'Task tests must be an array');
131
+ const ids = new Set();
132
+ for (const test of tests) {
133
+ f.object(test, 'test'); f.string(test.id, 'test.id');
134
+ f.demand(!/[\r\n]/.test(test.id), 'test_id', 'test.id must fit on one line');
135
+ f.demand(!ids.has(test.id), 'test_duplicate', 'Duplicate test ID: ' + test.id); ids.add(test.id);
136
+ f.demand((test.argv !== undefined) !== (test.run !== undefined), 'test_command', 'Exactly one of argv/run required');
137
+ if (test.argv !== undefined) {
138
+ f.demand(Array.isArray(test.argv) && test.argv.length > 0 &&
139
+ test.argv.every(arg => typeof arg === 'string' && !arg.includes('\0')), 'test_command', 'argv must be strings');
140
+ f.string(test.argv[0], 'argv executable');
141
+ } else f.string(test.run, 'test.run');
142
+ if (test.cwd !== undefined) f.string(test.cwd, 'test.cwd');
143
+ if (test.timeout !== undefined) f.positive(test.timeout, 'test.timeout');
144
+ if (test.parallel !== undefined) f.demand(typeof test.parallel === 'boolean', 'test_parallel', 'parallel must be boolean');
145
+ if (test.group !== undefined) f.string(test.group, 'test.group');
146
+ if (test.env !== undefined) {
147
+ f.object(test.env, 'test.env');
148
+ for (const [key, value] of Object.entries(test.env)) {
149
+ f.demand(/^[A-Za-z_][A-Za-z0-9_]*$/.test(key), 'test_env', 'Invalid test env name: ' + key);
150
+ f.demand(typeof value === 'string' && !value.includes('\0'), 'test_env',
151
+ 'Test env values must be strings: ' + key);
152
+ f.demand(!RESERVED_ENV.has(key), 'test_env', 'Test env cannot override ' + key);
153
+ }
154
+ }
155
+ }
156
+ return tests;
157
+ }
158
+
159
+ function validateTask(task) {
160
+ f.demand(/^TASK-\d+$/.test(task.id), 'task_identity', 'Invalid TASK ID: ' + task.id);
161
+ f.demand(STATES.includes(task.status), 'task_status', task.id + ': invalid Status: ' + task.status);
162
+ for (const dep of task.depends) f.demand(/^TASK-\d+$/.test(dep) && dep !== task.id,
163
+ 'task_depends', 'Invalid dependency for ' + task.id + ': ' + dep);
164
+ task.scenarios.forEach(id => f.demand(id.length <= 120 && !/[\r\n]/.test(id),
165
+ 'task_scenario', 'Invalid scenario reference: ' + id));
166
+ task.specRefs.forEach(config.reference);
167
+ task.paths.forEach(glob.compile);
168
+ validateTests(task.tests);
169
+ f.demand(typeof task.skipTests === 'boolean', 'task_skip', 'skip_tests must be boolean');
170
+ if (task.skipTests) f.string(task.skipReason, 'skip_reason');
171
+ if (task.e2e) f.string(task.e2e, 'e2e');
172
+ f.demand(Array.isArray(task.specDelta), 'delta_invalid', 'Spec Delta must be an array');
173
+ return task;
174
+ }
175
+
176
+ function parseTask(section) {
177
+ const values = fieldMap(section.text);
178
+ if (values.skipTests !== undefined && values.skipTests !== '') {
179
+ f.demand(['true', 'false'].includes(values.skipTests), 'task_skip',
180
+ 'Skip-Tests must be true or false');
181
+ }
182
+ f.demand(values.status !== undefined && values.status !== '', 'task_status',
183
+ section.id + ': missing Status field');
184
+ const tests = jsonArray(subsection(section.text, 'Tests'), 'Tests');
185
+ const specDelta = jsonArray(subsection(section.text, 'Spec Delta'), 'Spec Delta');
186
+ const task = {
187
+ id: section.id, title: section.title,
188
+ status: values.status || '',
189
+ depends: parseList(values.depends),
190
+ source: values.source || '',
191
+ scenarios: parseList(values.scenarios),
192
+ specRefs: parseList(values.specRefs),
193
+ paths: parseList(values.paths),
194
+ e2e: values.e2e || '',
195
+ skipTests: values.skipTests === 'true',
196
+ skipReason: values.skipReason || '',
197
+ tests, specDelta, text: section.text
198
+ };
199
+ return validateTask(task);
200
+ }
201
+
202
+ function validateSet(tasks) {
203
+ const ids = new Set();
204
+ for (const task of tasks) {
205
+ f.demand(!ids.has(task.id), 'task_duplicate', 'Duplicate TASK ID: ' + task.id);
206
+ ids.add(task.id);
207
+ }
208
+ for (const task of tasks) {
209
+ for (const dep of task.depends) {
210
+ f.demand(ids.has(dep), 'task_depends', task.id + ' depends on missing ' + dep);
211
+ }
212
+ }
213
+ }
214
+
215
+ function parse(raw, file) {
216
+ const document = fm.parse(raw);
217
+ f.demand(document.meta.version === 1, 'task_version', 'Task document version must be 1');
218
+ f.string(document.meta.requirement, 'task requirement');
219
+ f.demand(path.basename(file, '.md') === document.meta.requirement,
220
+ 'task_requirement', 'Task document name must match requirement: ' + document.meta.requirement);
221
+ const split = splitSections(document.body);
222
+ const tasks = split.sections.map(parseTask);
223
+ validateSet(tasks);
224
+ return { ...document, tasks, preamble: split.preamble };
225
+ }
226
+
227
+ function taskFrom(document, task) {
228
+ return { ...task, body: task.text, document, file: document.file, relative: document.relative,
229
+ raw: document.raw, meta: task, documentMeta: document.meta };
230
+ }
231
+
232
+ function find(document, taskId) {
233
+ const task = document.tasks.find(item => item.id === taskId);
234
+ f.demand(task, 'task_missing', 'Missing TASK: ' + taskId, 2);
235
+ return task;
236
+ }
237
+
238
+ function batches(tasks) {
239
+ const ids = new Set(tasks.map(task => task.id));
240
+ const pending = new Set(tasks.filter(task => task.status !== 'done').map(task => task.id));
241
+ const finished = new Set(tasks.filter(task => task.status === 'done').map(task => task.id));
242
+ const deps = Object.fromEntries(tasks.map(task => [task.id, new Set(task.depends)]));
243
+ for (const task of tasks) {
244
+ for (const dep of task.depends) f.demand(ids.has(dep), 'task_depends', task.id + ' depends on missing ' + dep);
245
+ }
246
+ const result = [];
247
+ while (pending.size) {
248
+ const ready = [...pending].filter(id => [...deps[id]].every(dep => finished.has(dep))).sort();
249
+ f.demand(ready.length > 0, 'task_cycle', 'Dependency cycle among: ' + [...pending].sort().join(', '));
250
+ result.push(ready);
251
+ ready.forEach(id => { pending.delete(id); finished.add(id); });
252
+ }
253
+ return result;
254
+ }
255
+
256
+ function fieldLine(name, value) {
257
+ return '- **' + name + '**:' + (value ? ' ' + value : '');
258
+ }
259
+
260
+ function setField(section, name, value) {
261
+ const key = Object.keys(FIELDS).find(item => FIELDS[item] === name);
262
+ const line = fieldLine(key, value);
263
+ const field = fieldEntries(section).get(name);
264
+ if (field) return section.slice(0, field.start) + line + section.slice(field.end);
265
+ const firstBreak = section.indexOf('\n');
266
+ return firstBreak < 0 ? section + '\n' + line : section.slice(0, firstBreak + 1) + line + '\n' + section.slice(firstBreak + 1);
267
+ }
268
+
269
+ function appendSubsection(section, name, lines) {
270
+ const marker = '### ' + name;
271
+ const area = subsectionRange(section, name);
272
+ const addition = lines.join('\n') + '\n';
273
+ if (!area) return section.replace(/\s*$/, '') + '\n\n' + marker + '\n\n' + addition;
274
+ return section.slice(0, area.end).replace(/\s*$/, '') + '\n' + addition +
275
+ (area.end < section.length ? '\n' : '') + section.slice(area.end);
276
+ }
277
+
278
+ function replaceTask(document, taskId, updater) {
279
+ const task = find(document, taskId);
280
+ const sections = splitSections(document.body).sections;
281
+ const section = sections.find(item => item.id === taskId);
282
+ f.demand(section, 'task_missing', 'Missing TASK: ' + taskId, 2);
283
+ const updated = updater(section.text);
284
+ const body = document.body.slice(0, section.start) + updated +
285
+ document.body.slice(section.start + section.text.length);
286
+ return { ...document, body, tasks: document.tasks.map(item => item.id === taskId ? { ...item, text: updated } : item) };
287
+ }
288
+
289
+ function updateTask(document, taskId, { status, field, value, log, verification } = {}) {
290
+ return replaceTask(document, taskId, section => {
291
+ let text = section;
292
+ if (status) text = setField(text, 'status', status);
293
+ if (field) text = setField(text, field, value);
294
+ if (log) text = appendSubsection(text, 'Log', ['- [' + new Date().toISOString() + '] ' +
295
+ log.replace(/[\r\n]+/g, ' ')]);
296
+ if (verification) text = appendSubsection(text, 'Verification', verification);
297
+ return text;
298
+ });
299
+ }
300
+
301
+ function write(document, body = document.body) {
302
+ return fm.stringify(document, document.meta, body);
303
+ }
304
+
305
+ module.exports = {
306
+ STATES, batches, find, parse, startedTests, taskFrom, testsDigest, updateTask,
307
+ validateSet, validateTask, validateTests, write
308
+ };