@michaelxwb/code-flow 0.8.1 → 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 (268) hide show
  1. package/README.md +73 -152
  2. package/bin/code-flow.js +12 -0
  3. package/docs/README.md +19 -0
  4. package/docs/USAGE.md +156 -1417
  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 +18 -21
  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 +12 -12
  47. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-full.md +17 -19
  48. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-lite.md +12 -12
  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/acceptance-contract.md +0 -217
  54. package/docs/ci/acceptance-evidence.md +0 -77
  55. package/docs/migrations/runtime-0.7.0.md +0 -41
  56. package/docs/migrations/runtime-0.8.0.md +0 -154
  57. package/docs/migrations/runtime-0.9.0.md +0 -40
  58. package/docs/migrations/spec-workflow-0.6.0.md +0 -42
  59. package/docs/spec-injection-hardening.md +0 -54
  60. package/docs/spec-injection-performance.json +0 -73
  61. package/docs/spec-optimization.md +0 -12
  62. package/src/adapters/claude/CLAUDE.md +0 -45
  63. package/src/adapters/claude/commands/cf-init.md +0 -262
  64. package/src/adapters/claude/commands/cf-learn.md +0 -291
  65. package/src/adapters/claude/commands/cf-spec.md +0 -87
  66. package/src/adapters/claude/commands/cf-stats.md +0 -96
  67. package/src/adapters/claude/commands/cf-sync.md +0 -39
  68. package/src/adapters/claude/commands/cf-task/align.md +0 -331
  69. package/src/adapters/claude/commands/cf-task/archive.md +0 -155
  70. package/src/adapters/claude/commands/cf-task/block.md +0 -49
  71. package/src/adapters/claude/commands/cf-task/graph.md +0 -92
  72. package/src/adapters/claude/commands/cf-task/note.md +0 -248
  73. package/src/adapters/claude/commands/cf-task/plan.md +0 -477
  74. package/src/adapters/claude/commands/cf-task/prd.md +0 -173
  75. package/src/adapters/claude/commands/cf-task/start.md +0 -317
  76. package/src/adapters/claude/commands/cf-task/status.md +0 -71
  77. package/src/adapters/claude/commands/cf-task/verify-e2e.md +0 -148
  78. package/src/adapters/claude/commands/cf-validate.md +0 -100
  79. package/src/adapters/claude/settings.local.json +0 -50
  80. package/src/adapters/codex/AGENTS.md +0 -45
  81. package/src/adapters/codex/config.toml +0 -3
  82. package/src/adapters/codex/hooks.json +0 -54
  83. package/src/adapters/codex/skills/cf-init/SKILL.md +0 -273
  84. package/src/adapters/codex/skills/cf-learn/SKILL.md +0 -296
  85. package/src/adapters/codex/skills/cf-spec/SKILL.md +0 -88
  86. package/src/adapters/codex/skills/cf-stats/SKILL.md +0 -97
  87. package/src/adapters/codex/skills/cf-sync/SKILL.md +0 -45
  88. package/src/adapters/codex/skills/cf-task-align/SKILL.md +0 -332
  89. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +0 -156
  90. package/src/adapters/codex/skills/cf-task-block/SKILL.md +0 -50
  91. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +0 -93
  92. package/src/adapters/codex/skills/cf-task-note/SKILL.md +0 -245
  93. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +0 -478
  94. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +0 -174
  95. package/src/adapters/codex/skills/cf-task-start/SKILL.md +0 -318
  96. package/src/adapters/codex/skills/cf-task-status/SKILL.md +0 -72
  97. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +0 -145
  98. package/src/adapters/codex/skills/cf-validate/SKILL.md +0 -102
  99. package/src/adapters/commands.json +0 -115
  100. package/src/adapters/costrict/CLAUDE.md +0 -45
  101. package/src/adapters/costrict/commands/cf-init.md +0 -262
  102. package/src/adapters/costrict/commands/cf-learn.md +0 -291
  103. package/src/adapters/costrict/commands/cf-spec.md +0 -87
  104. package/src/adapters/costrict/commands/cf-stats.md +0 -96
  105. package/src/adapters/costrict/commands/cf-sync.md +0 -39
  106. package/src/adapters/costrict/commands/cf-task/align.md +0 -331
  107. package/src/adapters/costrict/commands/cf-task/archive.md +0 -155
  108. package/src/adapters/costrict/commands/cf-task/block.md +0 -49
  109. package/src/adapters/costrict/commands/cf-task/graph.md +0 -92
  110. package/src/adapters/costrict/commands/cf-task/note.md +0 -248
  111. package/src/adapters/costrict/commands/cf-task/plan.md +0 -477
  112. package/src/adapters/costrict/commands/cf-task/prd.md +0 -173
  113. package/src/adapters/costrict/commands/cf-task/start.md +0 -317
  114. package/src/adapters/costrict/commands/cf-task/status.md +0 -71
  115. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +0 -148
  116. package/src/adapters/costrict/commands/cf-validate.md +0 -100
  117. package/src/adapters/costrict/settings.local.json +0 -50
  118. package/src/adapters/install-plan.json +0 -100
  119. package/src/adapters/opencode/AGENTS.md +0 -45
  120. package/src/adapters/opencode/commands/cf-init.md +0 -281
  121. package/src/adapters/opencode/commands/cf-learn.md +0 -295
  122. package/src/adapters/opencode/commands/cf-spec.md +0 -91
  123. package/src/adapters/opencode/commands/cf-stats.md +0 -100
  124. package/src/adapters/opencode/commands/cf-sync.md +0 -44
  125. package/src/adapters/opencode/commands/cf-task/align.md +0 -331
  126. package/src/adapters/opencode/commands/cf-task/archive.md +0 -160
  127. package/src/adapters/opencode/commands/cf-task/block.md +0 -54
  128. package/src/adapters/opencode/commands/cf-task/graph.md +0 -97
  129. package/src/adapters/opencode/commands/cf-task/note.md +0 -253
  130. package/src/adapters/opencode/commands/cf-task/plan.md +0 -477
  131. package/src/adapters/opencode/commands/cf-task/prd.md +0 -178
  132. package/src/adapters/opencode/commands/cf-task/start.md +0 -317
  133. package/src/adapters/opencode/commands/cf-task/status.md +0 -76
  134. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +0 -148
  135. package/src/adapters/opencode/commands/cf-validate.md +0 -105
  136. package/src/adapters/opencode/opencode.json +0 -3
  137. package/src/adapters/opencode/plugins/code-flow/index.js +0 -162
  138. package/src/adapters/opencode/plugins/code-flow/package.json +0 -6
  139. package/src/adapters/platform-bindings.json +0 -370
  140. package/src/cli.js +0 -1076
  141. package/src/core/code-flow/config.yml +0 -161
  142. package/src/core/code-flow/runtime-commands.json +0 -259
  143. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +0 -10
  144. package/src/core/code-flow/scripts/cf_acceptance_runner.py +0 -10
  145. package/src/core/code-flow/scripts/cf_audit.py +0 -10
  146. package/src/core/code-flow/scripts/cf_codex_hook.py +0 -10
  147. package/src/core/code-flow/scripts/cf_design_lint.py +0 -10
  148. package/src/core/code-flow/scripts/cf_evidence_import.py +0 -10
  149. package/src/core/code-flow/scripts/cf_feedback.py +0 -10
  150. package/src/core/code-flow/scripts/cf_opencode_event.py +0 -10
  151. package/src/core/code-flow/scripts/cf_post_hook.py +0 -11
  152. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +0 -11
  153. package/src/core/code-flow/scripts/cf_scenarios.py +0 -10
  154. package/src/core/code-flow/scripts/cf_spec_context.py +0 -10
  155. package/src/core/code-flow/scripts/cf_spec_gate.py +0 -10
  156. package/src/core/code-flow/scripts/cf_spec_session.py +0 -10
  157. package/src/core/code-flow/scripts/cf_stats.py +0 -10
  158. package/src/core/code-flow/scripts/cf_stop_hook.py +0 -11
  159. package/src/core/code-flow/scripts/cf_sync.py +0 -10
  160. package/src/core/code-flow/scripts/cf_task_index.py +0 -10
  161. package/src/core/code-flow/scripts/cf_task_parallel.py +0 -10
  162. package/src/core/code-flow/scripts/cf_task_workflow.py +0 -10
  163. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +0 -11
  164. package/src/core/code-flow/scripts/cf_validation.py +0 -10
  165. package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
  166. package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
  167. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +0 -347
  168. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +0 -660
  169. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +0 -202
  170. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +0 -60
  171. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +0 -73
  172. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +0 -282
  173. package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +0 -266
  174. package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +0 -172
  175. package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +0 -326
  176. package/src/core/code-flow/scripts/codeflow/acceptance/cf_checks.py +0 -333
  177. package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +0 -197
  178. package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +0 -174
  179. package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +0 -300
  180. package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +0 -502
  181. package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
  182. package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +0 -347
  183. package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +0 -1490
  184. package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +0 -533
  185. package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +0 -172
  186. package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +0 -607
  187. package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +0 -53
  188. package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +0 -58
  189. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +0 -1175
  190. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +0 -60
  191. package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +0 -46
  192. package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +0 -540
  193. package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +0 -144
  194. package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +0 -177
  195. package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +0 -1142
  196. package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +0 -742
  197. package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +0 -31
  198. package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
  199. package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +0 -67
  200. package/src/core/code-flow/scripts/codeflow/core/cf_core.py +0 -609
  201. package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +0 -325
  202. package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +0 -57
  203. package/src/core/code-flow/scripts/codeflow/core/cf_git.py +0 -92
  204. package/src/core/code-flow/scripts/codeflow/core/cf_log.py +0 -160
  205. package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +0 -77
  206. package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +0 -26
  207. package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +0 -33
  208. package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +0 -32
  209. package/src/core/code-flow/scripts/codeflow/core/cf_session_state.py +0 -37
  210. package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +0 -53
  211. package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +0 -9
  212. package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +0 -408
  213. package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +0 -253
  214. package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
  215. package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +0 -527
  216. package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
  217. package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +0 -95
  218. package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +0 -98
  219. package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +0 -30
  220. package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +0 -41
  221. package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +0 -292
  222. package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +0 -161
  223. package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
  224. package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +0 -412
  225. package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +0 -301
  226. package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +0 -149
  227. package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +0 -111
  228. package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +0 -114
  229. package/src/core/code-flow/scripts/codeflow/spec/cf_scan.py +0 -367
  230. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +0 -55
  231. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +0 -477
  232. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +0 -119
  233. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_metadata.py +0 -387
  234. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_migrate.py +0 -529
  235. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +0 -193
  236. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +0 -187
  237. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_resolver.py +0 -369
  238. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_router.py +0 -304
  239. package/src/core/code-flow/scripts/codeflow/spec/cf_spec_verify.py +0 -687
  240. package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +0 -330
  241. package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +0 -83
  242. package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +0 -755
  243. package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_transaction.py +0 -110
  244. package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
  245. package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +0 -867
  246. package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +0 -178
  247. package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +0 -635
  248. package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +0 -251
  249. package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +0 -570
  250. package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +0 -73
  251. package/src/core/code-flow/scripts/runtime-resources.json +0 -29
  252. package/src/core/code-flow/specs/backend/_map.md +0 -56
  253. package/src/core/code-flow/specs/backend/code-quality-performance.md +0 -59
  254. package/src/core/code-flow/specs/backend/database.md +0 -51
  255. package/src/core/code-flow/specs/backend/directory-structure.md +0 -54
  256. package/src/core/code-flow/specs/backend/logging.md +0 -50
  257. package/src/core/code-flow/specs/backend/platform-rules.md +0 -55
  258. package/src/core/code-flow/specs/frontend/_map.md +0 -52
  259. package/src/core/code-flow/specs/frontend/component-specs.md +0 -68
  260. package/src/core/code-flow/specs/frontend/directory-structure.md +0 -53
  261. package/src/core/code-flow/specs/frontend/quality-standards.md +0 -58
  262. package/src/core/code-flow/specs/shared/_map.md +0 -57
  263. package/src/core/code-flow/specs/shared/scenarios-template.md +0 -55
  264. package/src/core/code-flow/tasks/.gitkeep +0 -0
  265. package/src/core/code-flow/validation.yml +0 -59
  266. package/src/migrate/runtime.js +0 -213
  267. package/src/migrate/spec-workflow.js +0 -253
  268. package/src/runtime.js +0 -219
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
+ };