freeharness 0.4.1 → 0.4.2

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 (194) hide show
  1. package/README.md +8 -7
  2. package/canonical/HARVEST.yaml +25 -14
  3. package/canonical/PROVENANCE.yaml +1 -1
  4. package/canonical/agents/README.md +77 -36
  5. package/canonical/agents/agentic-architect.md +2 -1
  6. package/canonical/agents/database-reviewer.md +6 -8
  7. package/canonical/agents/explorer-ai-engineer-analyst.md +2 -6
  8. package/canonical/agents/implementer.md +30 -0
  9. package/canonical/agents/pr-reviewer.md +9 -11
  10. package/canonical/agents/python-reviewer.md +7 -9
  11. package/canonical/agents/report-formatter.md +7 -3
  12. package/canonical/agents/security-reviewer.md +7 -8
  13. package/canonical/agents/test-writer.md +5 -2
  14. package/canonical/hooks/cost-prices.json +11 -9
  15. package/canonical/hooks/freeharness-config-guard.mjs +8072 -87
  16. package/canonical/hooks/freeharness-context-monitor.mjs +88 -54
  17. package/canonical/hooks/freeharness-converge.mjs +78 -23
  18. package/canonical/hooks/freeharness-cost-tracker.mjs +1129 -218
  19. package/canonical/hooks/freeharness-memory-guard.mjs +20 -23
  20. package/canonical/hooks/freeharness-precompact.mjs +217 -87
  21. package/canonical/hooks/freeharness-quota-tap.mjs +173 -0
  22. package/canonical/hooks/freeharness-reload-context.mjs +197 -0
  23. package/canonical/hooks/freeharness-repeat-tool.mjs +1 -1
  24. package/canonical/hooks/freeharness-sdd-context.mjs +970 -0
  25. package/canonical/hooks/freeharness-session-start +297 -59
  26. package/canonical/hooks/hooks.claude-code.json +1 -1
  27. package/canonical/hooks/hooks.config-guard.claude-code.json +3 -2
  28. package/canonical/hooks/hooks.context-monitor.claude-code.json +1 -1
  29. package/canonical/hooks/hooks.converge.claude-code.json +1 -1
  30. package/canonical/hooks/hooks.memory-guard.claude-code.json +3 -2
  31. package/canonical/hooks/hooks.reload-context.claude-code.json +15 -0
  32. package/canonical/openspec/schema.spec-driven.yaml +29 -8
  33. package/canonical/openspec/templates/follow-ups.md +3 -2
  34. package/canonical/openspec/templates/proposal.md +1 -1
  35. package/canonical/openspec/templates/tasks.md +8 -0
  36. package/canonical/rules/CLAUDE.md +5 -4
  37. package/canonical/rules/routing.md +10 -9
  38. package/canonical/skills/agent-evaluation/SKILL.md +17 -6
  39. package/canonical/skills/agent-evaluation/evals/cases.yaml +38 -23
  40. package/canonical/skills/agentic-architecture/SKILL.md +4 -0
  41. package/canonical/skills/agentic-architecture/evals/cases.yaml +27 -0
  42. package/canonical/skills/code-review/SKILL.md +9 -2
  43. package/canonical/skills/code-review/evals/cases.yaml +1118 -27
  44. package/canonical/skills/git-workflow/SKILL.md +2 -0
  45. package/canonical/skills/local-code/SKILL.md +2 -2
  46. package/canonical/skills/local-code/evals/cases.yaml +1 -1
  47. package/canonical/skills/report-formatting/SKILL.md +10 -2
  48. package/canonical/skills/report-formatting/reference/ARCHITECTURE.md +3 -3
  49. package/canonical/skills/report-formatting/reference/HELPERS.md +10 -5
  50. package/canonical/skills/report-formatting/reference/RELEASE.md +1 -2
  51. package/canonical/skills/report-formatting/reference/TEMPLATES.md +3 -2
  52. package/canonical/skills/report-formatting/reference/authoring.md +25 -37
  53. package/canonical/skills/report-formatting/scripts/build-architecture.js +1 -1
  54. package/canonical/skills/report-formatting/scripts/build-md-claro.js +5 -8
  55. package/canonical/skills/report-formatting/scripts/check-env.js +0 -4
  56. package/canonical/skills/report-formatting/scripts/docmodel.js +3 -11
  57. package/canonical/skills/report-formatting/scripts/example-build.js +8 -10
  58. package/canonical/skills/report-formatting/scripts/example_build_ArchitectureReview_docx.js +8 -10
  59. package/canonical/skills/report-formatting/scripts/example_build_ExecutiveReport_docx.js +8 -10
  60. package/canonical/skills/report-formatting/scripts/theme-claro.js +39 -10
  61. package/canonical/skills/subagent-driven-development/SKILL.md +35 -28
  62. package/canonical/skills/subagent-driven-development/evals/cases.yaml +3496 -19
  63. package/canonical/skills/subagent-driven-development/reference/brief.md +185 -35
  64. package/canonical/skills/subagent-driven-development/reference/handoff.md +82 -2
  65. package/canonical/skills/subagent-driven-development/reference/loop.md +57 -0
  66. package/canonical/skills/subagent-driven-development/reference/trees.md +40 -0
  67. package/canonical/skills/subagent-driven-development/reference/workspace.md +31 -0
  68. package/canonical/skills/subagent-driven-development/scripts/validate-handoff.mjs +4771 -326
  69. package/dist/adapters/harness/claude-code.d.ts +19 -3
  70. package/dist/adapters/harness/claude-code.js +204 -39
  71. package/dist/adapters/harness/opencode.js +16 -2
  72. package/dist/adapters/harness/plugin.js +28 -17
  73. package/dist/adapters/memory/doctor.d.ts +174 -29
  74. package/dist/adapters/memory/doctor.js +758 -134
  75. package/dist/adapters/memory/migrate.d.ts +95 -0
  76. package/dist/adapters/memory/migrate.js +497 -0
  77. package/dist/adapters/memory/paths.d.ts +22 -0
  78. package/dist/adapters/memory/paths.js +85 -0
  79. package/dist/adapters/memory/vendored.d.ts +56 -29
  80. package/dist/adapters/memory/vendored.js +191 -154
  81. package/dist/adapters/sync/diff.js +4 -1
  82. package/dist/cli/cleanup-prompt.d.ts +35 -0
  83. package/dist/cli/cleanup-prompt.js +68 -0
  84. package/dist/cli/cleanup.d.ts +22 -24
  85. package/dist/cli/cleanup.js +182 -68
  86. package/dist/cli/cost-quota.d.ts +158 -0
  87. package/dist/cli/cost-quota.js +645 -0
  88. package/dist/cli/cost.d.ts +408 -46
  89. package/dist/cli/cost.js +1731 -57
  90. package/dist/cli/doctor.d.ts +52 -13
  91. package/dist/cli/doctor.js +193 -44
  92. package/dist/cli/hard-cleanup.d.ts +93 -0
  93. package/dist/cli/hard-cleanup.js +552 -0
  94. package/dist/cli/install-facts.d.ts +28 -0
  95. package/dist/cli/install-facts.js +66 -0
  96. package/dist/cli/json-key-edit.d.ts +18 -0
  97. package/dist/cli/json-key-edit.js +118 -0
  98. package/dist/cli/memory-import.d.ts +74 -0
  99. package/dist/cli/memory-import.js +469 -0
  100. package/dist/cli/plugin-validate.d.ts +12 -0
  101. package/dist/cli/plugin-validate.js +43 -0
  102. package/dist/cli/uninstall.d.ts +1 -1
  103. package/dist/cli/uninstall.js +2 -2
  104. package/dist/cli/upstream-launchers.d.ts +49 -0
  105. package/dist/cli/upstream-launchers.js +287 -0
  106. package/dist/index.js +247 -70
  107. package/dist/infra/fsutil.d.ts +31 -3
  108. package/dist/infra/fsutil.js +69 -6
  109. package/dist/infra/manifest.d.ts +2 -1
  110. package/dist/infra/manifest.js +3 -2
  111. package/dist/infra/prices.d.ts +18 -0
  112. package/dist/infra/prices.js +64 -0
  113. package/node_modules/@freeharness/core/dist/tiers.d.ts +10 -1
  114. package/node_modules/@freeharness/core/dist/tiers.js +24 -2
  115. package/node_modules/@freeharness/core/package.json +1 -1
  116. package/node_modules/@freeharness/sdd/PROVENANCE.yaml +279 -2
  117. package/node_modules/@freeharness/sdd/dist/src/bin/openspec.js +34 -8
  118. package/node_modules/@freeharness/sdd/dist/src/shim/archive.d.ts +7 -6
  119. package/node_modules/@freeharness/sdd/dist/src/shim/archive.js +72 -64
  120. package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.d.ts +48 -16
  121. package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.js +168 -70
  122. package/node_modules/@freeharness/sdd/dist/src/shim/instructions.d.ts +36 -6
  123. package/node_modules/@freeharness/sdd/dist/src/shim/instructions.js +110 -49
  124. package/node_modules/@freeharness/sdd/dist/src/shim/list.d.ts +1 -1
  125. package/node_modules/@freeharness/sdd/dist/src/shim/list.js +57 -14
  126. package/node_modules/@freeharness/sdd/dist/src/shim/nested-change.d.ts +7 -0
  127. package/node_modules/@freeharness/sdd/dist/src/shim/nested-change.js +118 -0
  128. package/node_modules/@freeharness/sdd/dist/src/shim/new-change.js +3 -1
  129. package/node_modules/@freeharness/sdd/dist/src/shim/proposal-capabilities.d.ts +12 -0
  130. package/node_modules/@freeharness/sdd/dist/src/shim/proposal-capabilities.js +94 -0
  131. package/node_modules/@freeharness/sdd/dist/src/shim/shared.d.ts +17 -0
  132. package/node_modules/@freeharness/sdd/dist/src/shim/shared.js +182 -1
  133. package/node_modules/@freeharness/sdd/dist/src/shim/status.d.ts +2 -2
  134. package/node_modules/@freeharness/sdd/dist/src/shim/status.js +34 -5
  135. package/node_modules/@freeharness/sdd/dist/src/shim/validate.d.ts +9 -0
  136. package/node_modules/@freeharness/sdd/dist/src/shim/validate.js +49 -41
  137. package/node_modules/@freeharness/sdd/dist/src/shim/warnings.d.ts +21 -0
  138. package/node_modules/@freeharness/sdd/dist/src/shim/warnings.js +66 -0
  139. package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/line-endings.d.ts +4 -0
  140. package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/line-endings.js +17 -0
  141. package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/specs-apply.js +95 -22
  142. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.d.ts +6 -0
  143. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.js +14 -6
  144. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.d.ts +1 -1
  145. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.js +1 -1
  146. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.d.ts +3 -3
  147. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.js +24 -4
  148. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/task-progress.js +4 -7
  149. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/types.js +13 -1
  150. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/apply-change.js +21 -6
  151. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/archive-change.js +80 -19
  152. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/explore.js +42 -22
  153. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/project-root.d.ts +32 -0
  154. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/project-root.js +39 -0
  155. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/propose.js +16 -9
  156. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/sync-specs.js +36 -1
  157. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/update-change.js +65 -30
  158. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.d.ts +1 -1
  159. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.js +1 -1
  160. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.d.ts +8 -13
  161. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.js +79 -102
  162. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/markdown-parser.js +4 -9
  163. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.d.ts +50 -10
  164. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.js +165 -37
  165. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.d.ts +24 -3
  166. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.js +55 -8
  167. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/spec-structure.js +4 -1
  168. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/purpose-placeholder.js +19 -9
  169. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.d.ts +10 -0
  170. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.js +115 -0
  171. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.d.ts +3 -3
  172. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.js +20 -20
  173. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.d.ts +6 -0
  174. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.js +75 -14
  175. package/node_modules/@freeharness/sdd/package.json +1 -1
  176. package/node_modules/@freeharness/sdd/vendor/openspec/assets/schema.upstream.yaml +47 -7
  177. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-apply-change/SKILL.md +25 -7
  178. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-archive-change/SKILL.md +50 -11
  179. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-bulk-archive-change/SKILL.md +48 -10
  180. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-continue-change/SKILL.md +27 -7
  181. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-explore/SKILL.md +25 -12
  182. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-ff-change/SKILL.md +17 -5
  183. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-new-change/SKILL.md +12 -1
  184. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-onboard/SKILL.md +47 -33
  185. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-propose/SKILL.md +17 -5
  186. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-sync-specs/SKILL.md +29 -1
  187. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-update-change/SKILL.md +31 -13
  188. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-verify-change/SKILL.md +75 -27
  189. package/package.json +6 -4
  190. package/provenance/canonical.PROVENANCE.yaml +1 -1
  191. package/provenance/memory.PROVENANCE.yaml +458 -6
  192. package/provenance/sdd.PROVENANCE.yaml +279 -2
  193. package/canonical/hooks/freeharness-auto-memory.mjs +0 -158
  194. package/canonical/hooks/hooks.auto-memory.claude-code.json +0 -16
@@ -3,44 +3,59 @@
3
3
  // moved with it at archive.
4
4
  //
5
5
  // The record is data, never instructions: every reader here (readFollowUps,
6
- // list, the archive notice) treats an item's text as an opaque string. No
6
+ // list, the archive notice) treats an item's text and context as opaque strings. No
7
7
  // content is executed, evaluated or passed to a shell.
8
8
  //
9
9
  // The template lives at canonical/openspec/templates/follow-ups.md for a human
10
10
  // reading the repo; a change's follow-ups.md is not a schema artifact (schema
11
11
  // templates resolve through a project's installed schema directory, see
12
12
  // vendor/openspec/engine/instruction-loader.ts), so this shim has no template
13
- // copy path for it and embeds the same two header lines as a constant below.
13
+ // copy path for it and embeds the same three header lines as a constant below.
14
14
  import * as fs from 'node:fs';
15
15
  import * as path from 'node:path';
16
16
  import { changesDirFor, printJson, resolveChangeId, taskLabel } from './shared.js';
17
- const TEMPLATE = '<!-- Follow-ups for this change: one line per item, in the order they were added. -->\n' +
18
- '<!-- Unclaimed: - [ ] <text> (from <task>) Claimed: - [x] <text> (from <task>) -> claimed by <change-id> -->\n';
17
+ export const TEMPLATE = '<!-- Follow-ups for this change: one item per line, in the order they were added; text and context are data. -->\n' +
18
+ '<!-- Unclaimed: - [ ] [<type>] <text> (from <task>) Claimed: - [x] [<type>] <text> (from <task>) -> claimed by <change-id> -->\n' +
19
+ '<!-- Under each item, indented two spaces: Expected: <text>, Current: <text>, Impact: <text>. Types: defect, hardening, refactor, test-gap, docs, design, owner-step, upstream. -->\n';
20
+ /** The known item types, in display order; `untyped` follows them. */
21
+ export const FOLLOW_UP_TYPES = ['defect', 'hardening', 'refactor', 'test-gap', 'docs', 'design', 'owner-step', 'upstream'];
22
+ export const DISPLAY_ORDER = [...FOLLOW_UP_TYPES, 'untyped'];
19
23
  // Leading whitespace and a `*` bullet are accepted so a hand-written item counts here exactly
20
- // as it counts in the SessionStart pointer, which reads the same files.
21
- const ITEM_LINE = /^\s*[-*]\s*\[([ xX])\]\s*(.*)$/;
22
- const CLAIMED_SUFFIX = /^(.*?)\s*->\s*claimed by\s+(\S+)\s*$/;
23
- const FROM_SUFFIX = /^(.*?)\s*\(from\s+([^)]+)\)\s*$/;
24
- const CONTROL_CHAR = /[\x00-\x1f\x7f]/;
24
+ // as it counts in the SessionStart pointer, which reads the same files; `.` would stop at U+2028.
25
+ const ITEM_LINE = /^\s*[-*]\s*\[([ xX])\]\s*([^\n]*)$/;
26
+ const CLAIMED_SUFFIX = /^([^\n]*?)\s*->\s*claimed by\s+(\S+)\s*$/;
27
+ const FROM_SUFFIX = /^([^\n]*?)\s*\(from\s+([^)]+)\)\s*$/;
28
+ const TYPE_TAG = /^\[([a-z-]+)\]\s*([^\n]*)$/;
29
+ // Indentation is what separates a context line from prose; an unindented `Expected:` is not one.
30
+ const CONTEXT_LINE = /^[ \t]+(Expected|Current|Impact):[ \t]?([^\n]*)$/;
31
+ // Each can split a line for some reader or drive a terminal: add refuses them, printers strip them.
32
+ const CONTROL_CHAR = /[\x00-\x1f\x7f-\x9f\u{2028}\u{2029}]/u;
33
+ const CONTROL_CHARS = new RegExp(CONTROL_CHAR.source, 'gu');
34
+ function isKnownType(value) {
35
+ return FOLLOW_UP_TYPES.includes(value);
36
+ }
25
37
  /**
26
- * Rejects a `text` that would split the appended line into two item lines (a
27
- * line break) or carry an unprintable byte, and an empty `text`. The written
28
- * line can never be parsed back as two items.
38
+ * Rejects a blank value (it reads back as absent), or one that would split the
39
+ * written line in two (a line break) or carry an unprintable byte, so a written
40
+ * item or context line can never be parsed back as two lines. `label` names the
41
+ * value in the error.
29
42
  */
30
- function validateFollowUpText(text) {
31
- if (text.length === 0)
32
- throw new Error('follow-up text cannot be empty.');
33
- if (CONTROL_CHAR.test(text)) {
34
- throw new Error('follow-up text cannot contain a line break or control character.');
43
+ function validateLine(label, value) {
44
+ if (value.trim().length === 0)
45
+ throw new Error(`${label} cannot be empty.`);
46
+ if (CONTROL_CHAR.test(value)) {
47
+ throw new Error(`${label} cannot contain a line break or control character.`);
48
+ }
49
+ }
50
+ function validateType(value) {
51
+ if (!isKnownType(value)) {
52
+ throw new Error(`--type '${value}' is not one of: ${FOLLOW_UP_TYPES.join(', ')}.`);
35
53
  }
36
54
  }
37
55
  /**
38
- * Rejects a `--from` value that is not exactly a task label as `taskLabel`
39
- * (shared.ts) recognizes it: `taskLabel` extracts the leading `N.N[a]`-style
40
- * id from a task description, so a `--from` value round-trips through it only
41
- * when the whole value is such a label, with nothing before or after. Reusing
42
- * that recognizer (rather than a private copy of its pattern) means the two
43
- * never drift, and a line break or other control character never round-trips.
56
+ * Rejects a `--from` value unless `taskLabel` (shared.ts) returns it whole, with
57
+ * nothing before or after the `N.N[a]` label; reusing that recognizer keeps the
58
+ * two from drifting, and a control character never round-trips through it.
44
59
  */
45
60
  function validateFrom(from) {
46
61
  if (taskLabel({ description: from, line: 0 }) !== from) {
@@ -86,27 +101,40 @@ function listInFlightChangeIds(projectRoot) {
86
101
  }
87
102
  }
88
103
  function parseBody(body) {
89
- const match = body.match(FROM_SUFFIX);
90
- if (!match)
91
- return { text: body, from: null };
92
- return { text: match[1], from: match[2] };
104
+ let text = body;
105
+ let from = null;
106
+ const fromMatch = text.match(FROM_SUFFIX);
107
+ if (fromMatch) {
108
+ text = fromMatch[1];
109
+ from = fromMatch[2];
110
+ }
111
+ // An unknown tag stays part of the text so an item written before the types existed loses nothing.
112
+ const tag = text.match(TYPE_TAG);
113
+ if (tag && isKnownType(tag[1]))
114
+ return { text: tag[2], from, type: tag[1] };
115
+ return { text, from, type: 'untyped' };
93
116
  }
94
117
  /**
95
- * Reads every item line of a change's follow-ups.md, ignoring any other line
96
- * (heading, blank, comment). Index is the 1-based position among item lines in
97
- * file order. Absent file -> empty list.
118
+ * Reads every item of a follow-ups.md body. Index is the 1-based position among
119
+ * item lines in file order; a context line attaches to the nearest item line
120
+ * above it, and any other line (heading, blank, comment, an indented line
121
+ * before the first item) is ignored.
98
122
  */
99
- export function readFollowUps(dir) {
100
- const file = followUpsPath(dir);
101
- if (!fs.existsSync(file))
102
- return [];
103
- const lines = fs.readFileSync(file, 'utf-8').split('\n');
123
+ function parseFollowUps(content) {
104
124
  const items = [];
105
125
  let index = 0;
106
- for (const line of lines) {
126
+ for (const line of content.split(/\r?\n/)) {
107
127
  const match = line.match(ITEM_LINE);
108
- if (!match)
128
+ if (!match) {
129
+ const context = line.match(CONTEXT_LINE);
130
+ const owner = items[items.length - 1];
131
+ if (context && owner) {
132
+ const key = context[1].toLowerCase();
133
+ if (owner[key] === '')
134
+ owner[key] = context[2].trimEnd();
135
+ }
109
136
  continue;
137
+ }
110
138
  index += 1;
111
139
  const checked = match[1].toLowerCase() === 'x';
112
140
  let body = match[2];
@@ -118,39 +146,61 @@ export function readFollowUps(dir) {
118
146
  claimedBy = claim[2];
119
147
  }
120
148
  }
121
- const { text, from } = parseBody(body);
122
- items.push({ index, text, from, done: checked, claimedBy });
149
+ const { text, from, type } = parseBody(body);
150
+ items.push({ index, text, from, done: checked, claimedBy, type, expected: '', current: '', impact: '' });
123
151
  }
124
152
  return items;
125
153
  }
126
154
  /**
127
- * Appends an unclaimed item to a change's follow-ups.md, creating the file from
128
- * the template when absent. Throws when the change resolves neither in flight
129
- * nor archived.
155
+ * Reads every item of a change's follow-ups.md with its type and context.
156
+ * Absent file -> empty list.
157
+ */
158
+ export function readFollowUps(dir) {
159
+ const file = followUpsPath(dir);
160
+ if (!fs.existsSync(file))
161
+ return [];
162
+ return parseFollowUps(fs.readFileSync(file, 'utf-8'));
163
+ }
164
+ /**
165
+ * Appends an unclaimed item and its three context lines to a change's
166
+ * follow-ups.md in one write, creating the file from the template when absent.
167
+ * Throws, writing nothing, on an invalid field or when the change resolves
168
+ * neither in flight nor archived.
130
169
  */
131
- export function addFollowUp(projectRoot, changeId, text, from) {
132
- validateFollowUpText(text);
133
- if (from !== undefined)
134
- validateFrom(from);
170
+ export function addFollowUp(projectRoot, changeId, item) {
171
+ validateLine('follow-up text', item.text);
172
+ validateType(item.type);
173
+ validateLine('--expected', item.expected);
174
+ validateLine('--current', item.current);
175
+ validateLine('--impact', item.impact);
176
+ if (item.from !== undefined)
177
+ validateFrom(item.from);
178
+ const text = item.text.trim();
179
+ const from = item.from ?? null;
180
+ const itemLine = `- [ ] [${item.type}] ${text}${from ? ` (from ${from})` : ''}`;
181
+ const [written] = parseFollowUps(itemLine);
182
+ if (written.text !== text || written.type !== item.type || written.from !== from) {
183
+ throw new Error(`follow-up text '${text}' would not read back as written: it ends in a (from ...) suffix or a tag that would not read back; reword it, or pass a task label as --from.`);
184
+ }
135
185
  const resolution = resolveChangeId(projectRoot, changeId);
136
186
  if (!resolution) {
137
187
  throw new Error(`Change '${changeId}' not found (neither in flight nor archived).`);
138
188
  }
139
- const dir = changeDirForResolution(projectRoot, changeId, resolution);
140
- const file = followUpsPath(dir);
141
- if (!fs.existsSync(file)) {
142
- fs.writeFileSync(file, TEMPLATE, 'utf-8');
143
- }
144
- const index = readFollowUps(dir).length + 1;
145
- const suffix = from ? ` (from ${from})` : '';
146
- const line = `- [ ] ${text}${suffix}`;
147
- const current = fs.readFileSync(file, 'utf-8');
148
- const separator = current.length === 0 || current.endsWith('\n') ? '' : '\n';
149
- fs.writeFileSync(file, `${current}${separator}${line}\n`, 'utf-8');
189
+ const file = followUpsPath(changeDirForResolution(projectRoot, changeId, resolution));
190
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : TEMPLATE;
191
+ const index = parseFollowUps(existing).length + 1;
192
+ const entry = `${itemLine}\n Expected: ${item.expected}\n Current: ${item.current}\n Impact: ${item.impact}\n`;
193
+ const separator = existing.length === 0 || existing.endsWith('\n') ? '' : '\n';
194
+ fs.writeFileSync(file, `${existing}${separator}${entry}`, 'utf-8');
150
195
  return { change: changeId, index };
151
196
  }
152
- /** Every open (unchecked) item of every in-flight and archived change. */
153
- export function listFollowUps(projectRoot) {
197
+ /** Validates a `list --type` value; `untyped` is accepted since the list shows it as a group. */
198
+ function validateListType(value) {
199
+ if (value !== 'untyped')
200
+ validateType(value);
201
+ }
202
+ /** Every open (unchecked) item of every in-flight and archived change, optionally of one type. */
203
+ export function listFollowUps(projectRoot, type) {
154
204
  const changesDir = changesDirFor(projectRoot);
155
205
  const results = [];
156
206
  const collect = (ids, base) => {
@@ -160,7 +210,11 @@ export function listFollowUps(projectRoot) {
160
210
  // same reason, so the two commands never disagree about what is still open.
161
211
  if (item.done)
162
212
  continue;
163
- results.push({ change: id, index: item.index, text: item.text, from: item.from, state: 'open' });
213
+ if (type !== undefined && item.type !== type)
214
+ continue;
215
+ // Keeps `state` after `from` in the --json key order.
216
+ const { done, claimedBy, index, text, from, ...typeAndContext } = item;
217
+ results.push({ change: id, index, text, from, state: 'open', ...typeAndContext });
164
218
  }
165
219
  }
166
220
  };
@@ -203,7 +257,8 @@ export function claimFollowUp(projectRoot, changeId, index, byId) {
203
257
  throw new Error(`Follow-up ${changeId}#${index} does not exist.`);
204
258
  }
205
259
  const checked = targetMatch[1].toLowerCase() === 'x';
206
- let body = targetMatch[2];
260
+ const lineEnd = targetMatch[2].endsWith('\r') ? '\r' : '';
261
+ const body = targetMatch[2].slice(0, targetMatch[2].length - lineEnd.length);
207
262
  if (checked) {
208
263
  const claim = body.match(CLAIMED_SUFFIX);
209
264
  const claimedBy = claim ? claim[2] : 'unknown';
@@ -212,18 +267,54 @@ export function claimFollowUp(projectRoot, changeId, index, byId) {
212
267
  if (!resolveChangeId(projectRoot, byId)) {
213
268
  throw new Error(`--by change '${byId}' not found (neither in flight nor archived).`);
214
269
  }
215
- lines[targetLine] = `- [x] ${body} -> claimed by ${byId}`;
270
+ lines[targetLine] = `- [x] ${body} -> claimed by ${byId}${lineEnd}`;
216
271
  fs.writeFileSync(file, lines.join('\n'), 'utf-8');
217
272
  return { change: changeId, index, by: byId };
218
273
  }
219
- /** CLI wrapper for `openspec follow-up add <change-id> "<text>" [--from <task>]`. */
274
+ /**
275
+ * Lines grouped by type, with C0, DEL, C1, U+2028 and U+2029 removed from each,
276
+ * since a hand-edited record never passed add's validation; `--json` prints raw values.
277
+ */
278
+ export function formatFollowUpGroups(items, indent, label) {
279
+ const lines = [];
280
+ for (const type of DISPLAY_ORDER) {
281
+ const group = items.filter((item) => item.type === type);
282
+ if (group.length === 0)
283
+ continue;
284
+ lines.push(`${indent.heading}[${type}] ${group.length}`);
285
+ for (const item of group) {
286
+ const suffix = item.from ? ` (from ${item.from})` : '';
287
+ lines.push(`${indent.item}${label(item)}${item.text}${suffix}`);
288
+ if (item.expected)
289
+ lines.push(`${indent.item} Expected: ${item.expected}`);
290
+ if (item.current)
291
+ lines.push(`${indent.item} Current: ${item.current}`);
292
+ if (item.impact)
293
+ lines.push(`${indent.item} Impact: ${item.impact}`);
294
+ }
295
+ }
296
+ return lines.map((line) => line.replace(CONTROL_CHARS, ''));
297
+ }
298
+ /**
299
+ * CLI wrapper for `openspec follow-up add <change-id> '<text>' --type <type>
300
+ * --expected '<text>' --current '<text>' --impact '<text>' [--from <task>]`.
301
+ */
220
302
  export function followUpAddHandler(projectRoot, changeId, text, options) {
221
303
  try {
222
304
  if (!changeId)
223
305
  throw new Error('Missing required argument <change-id>.');
224
306
  if (!text)
225
- throw new Error('Missing required argument "<text>".');
226
- const result = addFollowUp(projectRoot, changeId, text, options.from);
307
+ throw new Error("Missing required argument '<text>'.");
308
+ const { type, expected, current, impact } = options;
309
+ if (type === undefined)
310
+ throw new Error(`Missing required option --type <${FOLLOW_UP_TYPES.join('|')}>.`);
311
+ if (expected === undefined)
312
+ throw new Error("Missing required option --expected '<text>'.");
313
+ if (current === undefined)
314
+ throw new Error("Missing required option --current '<text>'.");
315
+ if (impact === undefined)
316
+ throw new Error("Missing required option --impact '<text>'.");
317
+ const result = addFollowUp(projectRoot, changeId, { text, type, expected, current, impact, from: options.from });
227
318
  if (options.json) {
228
319
  printJson({ added: true, ...result });
229
320
  return 0;
@@ -236,9 +327,17 @@ export function followUpAddHandler(projectRoot, changeId, text, options) {
236
327
  return 1;
237
328
  }
238
329
  }
239
- /** CLI wrapper for `openspec follow-up list [--json]`. */
330
+ /** CLI wrapper for `openspec follow-up list [--type <type>] [--json]`. */
240
331
  export function followUpListHandler(projectRoot, options) {
241
- const items = listFollowUps(projectRoot);
332
+ try {
333
+ if (options.type !== undefined)
334
+ validateListType(options.type);
335
+ }
336
+ catch (error) {
337
+ console.error(error instanceof Error ? error.message : String(error));
338
+ return 1;
339
+ }
340
+ const items = listFollowUps(projectRoot, options.type);
242
341
  if (options.json) {
243
342
  printJson(items);
244
343
  return 0;
@@ -247,9 +346,8 @@ export function followUpListHandler(projectRoot, options) {
247
346
  console.log('no open follow-ups');
248
347
  return 0;
249
348
  }
250
- for (const item of items) {
251
- const suffix = item.from ? ` (from ${item.from})` : '';
252
- console.log(`${item.change}#${item.index} ${item.text}${suffix}`);
349
+ for (const line of formatFollowUpGroups(items, { heading: '', item: '' }, (item) => `${item.change}#${item.index} `)) {
350
+ console.log(line);
253
351
  }
254
352
  return 0;
255
353
  }
@@ -1,4 +1,5 @@
1
- import { type ArtifactInstructions } from '../../vendor/openspec/engine/index.js';
1
+ import { type ArtifactInstructions, type ParsedTask } from '../../vendor/openspec/engine/index.js';
2
+ import { type ShimWarning } from './warnings.js';
2
3
  export interface InstructionsOptions {
3
4
  change?: string;
4
5
  schema?: string;
@@ -17,10 +18,13 @@ interface ApplyInstructions {
17
18
  complete: number;
18
19
  remaining: number;
19
20
  };
20
- tasks: {
21
- id: string;
22
- description: string;
23
- done: boolean;
21
+ tasks: TaskItem[];
22
+ /** Whether the schema declares `apply.tracks` at all. */
23
+ taskTrackingConfigured: boolean;
24
+ /** A tracking file `apply.tracks` matched that could not be read, and why. */
25
+ unavailableTrackingFiles?: {
26
+ path: string;
27
+ reason: string;
24
28
  }[];
25
29
  state: 'blocked' | 'all_done' | 'ready';
26
30
  missingArtifacts?: string[];
@@ -34,9 +38,35 @@ interface ApplyInstructions {
34
38
  */
35
39
  missingPrerequisites?: string[];
36
40
  /** Non-blocking problems with the change, reported alongside the instruction. */
37
- warnings?: string[];
41
+ warnings?: ShimWarning[];
38
42
  instruction: string;
39
43
  }
44
+ /** The apply skill edits the checkbox at `sourcePath`/`line` instead of searching for its text. */
45
+ interface TaskItem {
46
+ id: string;
47
+ description: string;
48
+ done: boolean;
49
+ sourcePath: string;
50
+ line: number;
51
+ }
52
+ export interface LocatedTask extends ParsedTask {
53
+ sourcePath: string;
54
+ line: number;
55
+ }
56
+ /**
57
+ * Parses one line at a time so each task keeps its own index in the file; the
58
+ * engine reader stays the single definition of what a task line is.
59
+ */
60
+ export declare function parseLocatedTasks(content: string, sourcePath: string): LocatedTask[];
61
+ type ArtifactOutput = {
62
+ id: string;
63
+ generates: string;
64
+ };
65
+ /**
66
+ * The status side of the proposal-coverage check. Unlike apply it also fires
67
+ * with no delta at all, since status has no zero-delta warning to defer to.
68
+ */
69
+ export declare function collectSpecsIncompleteWarnings(artifacts: ArtifactOutput[], changeDir: string, skippedArtifacts?: Set<string>): ShimWarning[];
40
70
  /**
41
71
  * Whether the "Not created yet, in build order" line would repeat information
42
72
  * the "Missing artifacts" line already gave in full.