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
@@ -1,9 +1,10 @@
1
- // FreeHarness context monitor (Claude Code Stop hook).
2
- // Reads the latest cost snapshot of the session (usage.jsonl, written by the cost tracker) and,
3
- // when the context crosses 60% and then 85% of its token window, prints one advisory line as a
4
- // `systemMessage` (shown to the user, never to the model: a Stop `additionalContext` would force
5
- // another turn). Re-arm and window fallback: see `crossed` and `respond` below.
6
- // Never blocks; exit 0 on every path. Disable: FREEHARNESS_HOOK_CONTEXT_MONITOR=off.
1
+ // FreeHarness context monitor (Claude Code Stop hook). When the session's latest cost snapshot
2
+ // crosses 60% and then 85% of its token window, it prints one advisory `systemMessage` (a Stop
3
+ // `additionalContext` would force another turn). A hook cannot run /clear, so at the highest
4
+ // threshold it first saves a handoff the project's next session claims. Never blocks; exit 0
5
+ // on every path. Disable: FREEHARNESS_HOOK_CONTEXT_MONITOR=off.
6
+ import { execFileSync } from "node:child_process";
7
+ import crypto from "node:crypto";
7
8
  import fs from "node:fs";
8
9
  import os from "node:os";
9
10
  import path from "node:path";
@@ -12,11 +13,7 @@ import { fileURLToPath } from "node:url";
12
13
  /** Defaults; the fragment overrides them with --budget / --thresholds. */
13
14
  export const DEFAULTS = Object.freeze({ budget: 200_000, thresholds: [60, 85] });
14
15
 
15
- /**
16
- * Parses --budget and --thresholds (percentages, ascending).
17
- * @param {string[]} argv Arguments after the script path.
18
- * @returns {{budget:number, thresholds:number[]}} Options.
19
- */
16
+ /** --budget and --thresholds (percentages, sorted ascending). */
20
17
  export function parseOptions(argv) {
21
18
  const opts = { budget: DEFAULTS.budget, thresholds: [...DEFAULTS.thresholds] };
22
19
  for (let i = 0; i < argv.length; i++) {
@@ -34,22 +31,15 @@ export function parseOptions(argv) {
34
31
  return opts;
35
32
  }
36
33
 
37
- /**
38
- * Resolves the state file of a session.
39
- * @param {string} sessionId Session id.
40
- * @returns {string} Absolute path.
41
- */
42
34
  function stateFile(sessionId) {
43
- const dir = process.env.FREEHARNESS_STATE_DIR || path.join(os.homedir(), ".claude", "freeharness", "state");
44
- return path.join(dir, `${sessionId.replace(/[^\w.-]/g, "_") || "unknown"}.context.json`);
35
+ return path.join(stateDir(), `${sessionId.replace(/[^\w.-]/g, "_") || "unknown"}.context.json`);
45
36
  }
46
37
 
47
- /**
48
- * Latest context size and token window recorded for the session in the cost log.
49
- * @param {string} sessionId Session id.
50
- * @returns {{tokens: number, window: number} | null} Tokens and window (default 200,000 when the
51
- * snapshot lacks `context_window`), or null when no snapshot carries `context_tokens`.
52
- */
38
+ function stateDir() {
39
+ return process.env.FREEHARNESS_STATE_DIR || path.join(os.homedir(), ".claude", "freeharness", "state");
40
+ }
41
+
42
+ /** Latest `{tokens, window}` of the session in the cost log (window 200,000 when unrecorded), or null. */
53
43
  export function contextFromSnapshot(sessionId) {
54
44
  const file = process.env.FREEHARNESS_COST_LOG || path.join(os.homedir(), ".claude", "freeharness", "cost", "usage.jsonl");
55
45
  let text;
@@ -74,11 +64,7 @@ export function contextFromSnapshot(sessionId) {
74
64
  return best;
75
65
  }
76
66
 
77
- /**
78
- * Context size from the transcript: the last assistant message's input + cache tokens.
79
- * @param {string} transcript Transcript path.
80
- * @returns {number | null} Tokens, or null when unreadable.
81
- */
67
+ /** Input plus cache tokens of the transcript's last usage record, or null. */
82
68
  export function contextFromTranscript(transcript) {
83
69
  let text;
84
70
  try {
@@ -101,15 +87,8 @@ export function contextFromTranscript(transcript) {
101
87
  }
102
88
 
103
89
  /**
104
- * Decides which threshold (if any) to announce against the given window, records it, and
105
- * re-arms (clears the announced set, bumps the epoch) once the context drops back below the
106
- * lowest threshold, so a later climb announces both thresholds again.
107
- * @param {number} tokens Current context tokens.
108
- * @param {number} window Token window the percentage is computed against.
109
- * @param {{budget:number, thresholds:number[]}} opts Options (thresholds only; budget is the
110
- * caller's fallback window, not used here).
111
- * @param {string} file State file.
112
- * @returns {number | null} The percentage to announce, or null.
90
+ * The threshold to announce, or null; each is announced once per epoch. A drop below the lowest
91
+ * threshold re-arms (clears the set, bumps the epoch) so a later climb announces again.
113
92
  */
114
93
  export function crossed(tokens, window, opts, file) {
115
94
  let state = { announced: [], epoch: 0 };
@@ -136,21 +115,69 @@ export function crossed(tokens, window, opts, file) {
136
115
  }
137
116
  if (changed) {
138
117
  state.updated = new Date().toISOString();
139
- fs.mkdirSync(path.dirname(file), { recursive: true });
140
- fs.writeFileSync(file, JSON.stringify(state), "utf8");
118
+ try {
119
+ fs.mkdirSync(path.dirname(file), { recursive: true });
120
+ fs.writeFileSync(file, JSON.stringify(state), "utf8");
121
+ } catch {
122
+ // An unwritable state dir still gets the advisory; it repeats until the state persists.
123
+ }
141
124
  }
142
125
  return top;
143
126
  }
144
127
 
128
+ const HANDOFF_MAX_BYTES = 16_000;
129
+
130
+ /** SessionStart derives the same key to claim the handoff: SHA-256 of the real git toplevel, else of the cwd. */
131
+ export function projectKey(cwd) {
132
+ let root = cwd;
133
+ try {
134
+ const top = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] }).trim();
135
+ if (top) root = top;
136
+ } catch {
137
+ // not a repository, or no git: the cwd is the root
138
+ }
139
+ return crypto.createHash("sha256").update(fs.realpathSync(root)).digest("hex").slice(0, 16);
140
+ }
141
+
142
+ /** The rename makes the write atomic and replaces an older unclaimed handoff; resolves to the path, or null. */
143
+ export async function writeHandoff(payload) {
144
+ if (typeof payload.transcript_path !== "string" || !payload.transcript_path) return null;
145
+ // Imported lazily: the PreCompact script is emitted with another hook group and may be absent.
146
+ const pc = await import(new URL("./freeharness-precompact.mjs", import.meta.url).href);
147
+ const facts = pc.parseTranscript(payload.transcript_path);
148
+ if (!facts) return null;
149
+ const cwd = typeof payload.cwd === "string" && path.isAbsolute(payload.cwd) ? payload.cwd : process.cwd();
150
+ const key = projectKey(cwd);
151
+ const session = String(typeof payload.session_id === "string" ? payload.session_id : "").replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 64) || "unknown";
152
+ const head = `handoff ${key} ${session}\n`;
153
+ const body = pc.skeletonFromFacts(facts, payload, { mapBody: pc.neutralize, maxBytes: HANDOFF_MAX_BYTES - Buffer.byteLength(head) });
154
+ const dir = path.join(stateDir(), "handoff");
155
+ const file = path.join(dir, `${key}.md`);
156
+ const tmp = path.join(dir, `.${key}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`);
157
+ let written = null;
158
+ try {
159
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
160
+ // A symlinked handoff dir would put the file wherever the link points.
161
+ if (fs.lstatSync(dir).isDirectory()) {
162
+ fs.writeFileSync(tmp, head + body, { encoding: "utf8", mode: 0o600, flag: "wx" });
163
+ fs.renameSync(tmp, file);
164
+ written = file;
165
+ }
166
+ } catch {
167
+ fs.rmSync(tmp, { force: true });
168
+ } finally {
169
+ // The engine summary does not depend on the local file, so it is asked for either way.
170
+ const lastAssistant = facts.assistants.length ? facts.assistants[facts.assistants.length - 1] : "";
171
+ await pc.requestSummary(typeof payload.session_id === "string" && payload.session_id ? payload.session_id : "unknown", lastAssistant);
172
+ }
173
+ return written;
174
+ }
175
+
145
176
  /**
146
- * Builds the advisory for one Stop payload, or null when nothing is due. The window is the
147
- * snapshot's own `context_window`; the transcript fallback carries no window of its own, so
148
- * --budget stands in for it there.
149
- * @param {Record<string, any>} payload Parsed hook payload.
150
- * @param {{budget:number, thresholds:number[]}} opts Options.
151
- * @returns {Record<string, unknown> | null} JSON to print, or null.
177
+ * The advisory JSON for one Stop payload, or null. The transcript fallback carries no window of
178
+ * its own, so --budget stands in for it. A failed handoff leaves the plain advisory.
152
179
  */
153
- export function respond(payload, opts) {
180
+ export async function respond(payload, opts) {
154
181
  const sessionId = typeof payload.session_id === "string" && payload.session_id ? payload.session_id : "unknown";
155
182
  const snap = contextFromSnapshot(sessionId);
156
183
  let tokens = null;
@@ -168,13 +195,21 @@ export function respond(payload, opts) {
168
195
  const usedPct = Math.round((tokens / window) * 100);
169
196
  const tokensK = Math.round(tokens / 1000);
170
197
  const windowK = Math.round(window / 1000);
171
- return {
172
- systemMessage: `context ${tokensK}k of ${windowK}k (${usedPct}%): compact with the project instructions, or /clear before unrelated work`,
173
- };
198
+ const usage = `context ${tokensK}k of ${windowK}k (${usedPct}%)`;
199
+ if (threshold === opts.thresholds[opts.thresholds.length - 1]) {
200
+ let handoff = null;
201
+ try {
202
+ handoff = await writeHandoff(payload);
203
+ } catch {
204
+ // a failed handoff leaves the plain advisory
205
+ }
206
+ if (handoff) return { systemMessage: `${usage}: handoff saved to ${handoff}; /clear now and the next session in this project picks it up` };
207
+ }
208
+ return { systemMessage: `${usage}: compact with the project instructions, or /clear before unrelated work` };
174
209
  }
175
210
 
176
211
  /** Entry point: read stdin, respond or stay silent, exit 0. */
177
- function main() {
212
+ async function main() {
178
213
  if ((process.env.FREEHARNESS_HOOK_CONTEXT_MONITOR || "").toLowerCase() === "off") return;
179
214
  let payload = {};
180
215
  try {
@@ -185,7 +220,7 @@ function main() {
185
220
  }
186
221
  if (!payload || typeof payload !== "object") return;
187
222
  try {
188
- const out = respond(payload, parseOptions(process.argv.slice(2)));
223
+ const out = await respond(payload, parseOptions(process.argv.slice(2)));
189
224
  if (out) process.stdout.write(JSON.stringify(out));
190
225
  } catch (e) {
191
226
  process.stderr.write(`freeharness-context-monitor: ${e instanceof Error ? e.message : String(e)}\n`);
@@ -193,6 +228,5 @@ function main() {
193
228
  }
194
229
 
195
230
  if (process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url))) {
196
- main();
197
- process.exit(0);
231
+ main().finally(() => process.exit(0));
198
232
  }
@@ -7,7 +7,8 @@
7
7
  // is below max_rounds it answers {"decision":"block","reason":"<next task + its Verify
8
8
  // line>"} (the Claude Code Stop contract: `block` keeps the agent working, `reason` is what
9
9
  // it reads next). When every task is checked, the limit is reached, or the loop is disabled,
10
- // the session ends normally. Each round is appended to .freeharness/converge.log. A tasks.md
10
+ // the session ends normally. Each round is appended to .freeharness/converge.log (task text
11
+ // JSON-encoded); a linked or special .freeharness, config or log turns the loop off. A tasks.md
11
12
  // that cannot be read as a task list (missing, empty, or no checkbox lines) disables the loop
12
13
  // for good: converge.json is rewritten with "disabled": true and the reason, so a broken
13
14
  // plan never traps a session. The promise text is echoed in the block reason so the agent
@@ -21,13 +22,12 @@ import { fileURLToPath } from "node:url";
21
22
  /** Defaults applied to a converge.json that omits them. */
22
23
  export const DEFAULTS = Object.freeze({ maxRounds: 5, promise: "every task in tasks.md is checked and its Verify line passed" });
23
24
 
24
- const TASK_LINE = /^\s*[-*]\s*\[([\sxX])\]\s*(.*)/;
25
- // The trailing `\s*` of the annotation patterns absorbs the `\r` of a CRLF file, which `.`
26
- // never matches; without it every annotation line of such a file reads as prose.
27
- const VERIFY_LINE = /^\s*Verify:\s*(.*?)\s*$/;
28
- const DISPOSITION_LINE = /^\s*Disposition:\s*(superseded|moved|external)\b\s*(.*?)\s*$/;
25
+ const TASK_LINE = /^\s*(?:[-*+]|\d{1,9}[.)])\s*\[(?:\s*([^\]\s])\s*\](?![([])|\](?![([])|\s+\])\s*(.*)/;
26
+ // Values run to end of line and are trimmed in code; a group before `\s*$` is quadratic.
27
+ const VERIFY_LINE = /^\s*Verify:(.*)$/s;
28
+ const DISPOSITION_LINE = /^\s*Disposition:\s*(superseded|moved|external)\b(.*)$/s;
29
29
  /** The annotation lines a task may carry; any other line ends its block. */
30
- const ANNOTATION_LINE = /^\s*(?:Verify|Interfaces|Boundary|Depends|Disposition):\s*(.*)\s*$/;
30
+ const ANNOTATION_LINE = /^\s*(?:Verify|Interfaces|Boundary|Depends|Disposition):/;
31
31
 
32
32
  /**
33
33
  * Resolves the project root of a Stop payload.
@@ -47,11 +47,16 @@ export function projectDir(payload) {
47
47
  export function readConfig(project) {
48
48
  const file = path.join(project, ".freeharness", "converge.json");
49
49
  if (!fs.existsSync(file)) return { file, config: null };
50
+ if (!contained(project)) return { file, config: null };
51
+ const fd = openRegular(file, fs.constants.O_RDONLY);
52
+ if (fd === null) return { file, config: null };
50
53
  let parsed;
51
54
  try {
52
- parsed = JSON.parse(fs.readFileSync(file, "utf8"));
55
+ parsed = JSON.parse(fs.readFileSync(fd, "utf8"));
53
56
  } catch (e) {
54
57
  return { file, config: null, error: `converge.json is not valid JSON (${e instanceof Error ? e.message : String(e)}); loop off` };
58
+ } finally {
59
+ fs.closeSync(fd);
55
60
  }
56
61
  if (!parsed || typeof parsed !== "object" || typeof parsed.change !== "string" || !/^[A-Za-z0-9._-]+$/.test(parsed.change)) {
57
62
  return { file, config: null, error: "converge.json needs a \"change\" id (letters, digits, . _ -); loop off" };
@@ -79,7 +84,7 @@ export function parseTasks(content) {
79
84
  for (const line of content.split("\n")) {
80
85
  const t = line.match(TASK_LINE);
81
86
  if (t) {
82
- current = { done: t[1].toLowerCase() === "x", text: t[2].trim(), verify: null, disposition: null };
87
+ current = { done: (t[1] ?? "").toLowerCase() === "x", text: t[2].trim(), verify: null, disposition: null };
83
88
  tasks.push(current);
84
89
  continue;
85
90
  }
@@ -110,15 +115,56 @@ export function parseTasks(content) {
110
115
  return tasks;
111
116
  }
112
117
 
118
+ const NO_FOLLOW = (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0);
119
+
120
+ /** True when `file` is absent or a single-link regular file (never a symlink). */
121
+ function plainOrAbsent(file) {
122
+ try {
123
+ const st = fs.lstatSync(file);
124
+ return st.isFile() && st.nlink === 1;
125
+ } catch (e) {
126
+ return e?.code === "ENOENT";
127
+ }
128
+ }
129
+
130
+ /** True when .freeharness is a real directory holding a plain config and log: a cloned repo can link either to a shell rc file. */
131
+ function contained(project) {
132
+ const dir = path.join(project, ".freeharness");
133
+ try {
134
+ if (!fs.lstatSync(dir).isDirectory()) return false;
135
+ } catch {
136
+ return false;
137
+ }
138
+ return plainOrAbsent(path.join(dir, "converge.json")) && plainOrAbsent(path.join(dir, "converge.log"));
139
+ }
140
+
141
+ /** Descriptor of `file` unless it is a symlink, special or hard-linked; checked on the fd, so a swap after `contained` fails too. */
142
+ function openRegular(file, flags) {
143
+ let fd;
144
+ try {
145
+ fd = fs.openSync(file, flags | NO_FOLLOW, 0o644);
146
+ const st = fs.fstatSync(fd);
147
+ if (st.isFile() && st.nlink === 1) return fd;
148
+ } catch {
149
+ // refused below
150
+ }
151
+ if (fd !== undefined) fs.closeSync(fd);
152
+ return null;
153
+ }
154
+
113
155
  /**
114
156
  * Appends one line to the round log.
115
157
  * @param {string} project Project dir.
116
158
  * @param {string} line Text.
117
159
  */
118
160
  function log(project, line) {
119
- const file = path.join(project, ".freeharness", "converge.log");
120
- fs.mkdirSync(path.dirname(file), { recursive: true });
121
- fs.appendFileSync(file, `${new Date().toISOString()} ${line}\n`, "utf8");
161
+ const fd = openRegular(path.join(project, ".freeharness", "converge.log"), fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT);
162
+ if (fd === null) return;
163
+ try {
164
+ fs.writeSync(fd, `${new Date().toISOString()} ${line}\n`);
165
+ } finally {
166
+ fs.closeSync(fd);
167
+ }
122
168
  }
123
169
 
124
170
  /**
@@ -127,13 +173,20 @@ function log(project, line) {
127
173
  * @param {Record<string, unknown>} patch Fields to merge over the current file.
128
174
  */
129
175
  function writeConfig(file, patch) {
130
- let current = {};
176
+ const fd = openRegular(file, fs.constants.O_RDWR);
177
+ if (fd === null) return;
131
178
  try {
132
- current = JSON.parse(fs.readFileSync(file, "utf8"));
133
- } catch {
134
- // unreadable: the patch alone is written (the reader already validated the shape)
179
+ let current = {};
180
+ try {
181
+ current = JSON.parse(fs.readFileSync(fd, "utf8"));
182
+ } catch {
183
+ // unreadable: the patch alone is written (the reader already validated the shape)
184
+ }
185
+ fs.ftruncateSync(fd, 0);
186
+ fs.writeSync(fd, `${JSON.stringify({ ...current, ...patch }, null, 2)}\n`, 0);
187
+ } finally {
188
+ fs.closeSync(fd);
135
189
  }
136
- fs.writeFileSync(file, `${JSON.stringify({ ...current, ...patch }, null, 2)}\n`, "utf8");
137
190
  }
138
191
 
139
192
  /**
@@ -157,13 +210,13 @@ export function respond(payload) {
157
210
  } catch (e) {
158
211
  const reason = `tasks.md unreadable at ${tasksFile}: ${e instanceof Error ? e.message : String(e)}`;
159
212
  writeConfig(file, { disabled: true, reason });
160
- log(project, `disabled change=${config.change} reason="${reason}"`);
213
+ log(project, `disabled change=${config.change} reason=${JSON.stringify(reason)}`);
161
214
  return { systemMessage: `freeharness-converge: disabled (${reason}). Fix the change id or the file and remove "disabled" from .freeharness/converge.json to re-arm.` };
162
215
  }
163
216
  if (tasks.length === 0) {
164
217
  const reason = `tasks.md has no checkbox task lines (${tasksFile})`;
165
218
  writeConfig(file, { disabled: true, reason });
166
- log(project, `disabled change=${config.change} reason="${reason}"`);
219
+ log(project, `disabled change=${config.change} reason=${JSON.stringify(reason)}`);
167
220
  return { systemMessage: `freeharness-converge: disabled (${reason}). Write the tasks as "- [ ] N.M ..." lines and remove "disabled" from .freeharness/converge.json to re-arm.` };
168
221
  }
169
222
 
@@ -181,10 +234,10 @@ export function respond(payload) {
181
234
  }
182
235
  if (config.rounds >= config.maxRounds) {
183
236
  const remaining = open.map((t) => t.text).join(" | ");
184
- log(project, `limit change=${config.change} rounds=${config.rounds}/${config.maxRounds} remaining=${open.length} tasks="${remaining}"`);
237
+ log(project, `limit change=${config.change} rounds=${config.rounds}/${config.maxRounds} remaining=${open.length} tasks=${JSON.stringify(remaining)}`);
185
238
  if (dispositioned.length > 0) {
186
239
  const skipped = dispositioned.map((t) => `${t.text} (${t.disposition.kind})`).join(" | ");
187
- log(project, `dispositioned change=${config.change} tasks="${skipped}"`);
240
+ log(project, `dispositioned change=${config.change} tasks=${JSON.stringify(skipped)}`);
188
241
  }
189
242
  return { systemMessage: `freeharness-converge: round limit ${config.maxRounds} reached for ${config.change} with ${open.length} task(s) open: ${remaining}. Raise max_rounds or reset rounds in .freeharness/converge.json to continue.` };
190
243
  }
@@ -192,7 +245,7 @@ export function respond(payload) {
192
245
  const next = open[0];
193
246
  const round = config.rounds + 1;
194
247
  writeConfig(file, { rounds: round });
195
- log(project, `round=${round}/${config.maxRounds} change=${config.change} open=${open.length} next="${next.text}"`);
248
+ log(project, `round=${round}/${config.maxRounds} change=${config.change} open=${open.length} next=${JSON.stringify(next.text)}`);
196
249
  const reason = [
197
250
  `freeharness-converge round ${round}/${config.maxRounds} for change ${config.change}: ${open.length} task(s) still unchecked.`,
198
251
  `Next task: ${next.text}`,
@@ -222,6 +275,8 @@ function main() {
222
275
  }
223
276
 
224
277
  if (process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url))) {
278
+ // No exit(): it truncates a reason past the pipe buffer. EPIPE on either stream is ignored.
279
+ process.stdout.on("error", () => {});
280
+ process.stderr.on("error", () => {});
225
281
  main();
226
- process.exit(0);
227
282
  }