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,7 +1,9 @@
1
- import type { Capability } from "@freeharness/core";
1
+ import type { Capability, HarnessTarget } from "@freeharness/core";
2
2
  import type { AdapterResult, InstallContext } from "@freeharness/core";
3
3
  import type { HarnessPort } from "@freeharness/core";
4
4
  export { ROUTING_FILE } from "../../infra/routing.js";
5
+ /** Writes the SDD skill's `reference/tiers.md` under `skillsDir`; nothing when the canonical skills lack the SDD skill. */
6
+ export declare function emitTierReference(canonicalDir: string, skillsDir: string, target: HarnessTarget, dryRun: boolean): string[];
5
7
  /**
6
8
  * Capabilities that live in the machine-global install (~/.claude). At project scope the
7
9
  * adapter refuses them unless `allowProjectCopies` is set: Claude Code loads user and
@@ -12,10 +14,16 @@ export declare const GLOBAL_ONLY_CAPABILITIES: readonly string[];
12
14
  export declare const FH_CONTEXT_COMMAND: string;
13
15
  /** SessionStart matcher: compact/resume/fork get the re-orientation block, startup/clear the pointer. */
14
16
  export declare const SESSION_START_MATCHER = "startup|clear|compact|resume|fork";
15
- /** Flags baked into the hook commands (the fragment defaults; edit settings.json to tune). */
17
+ /**
18
+ * Flags baked into the hook commands (the fragment defaults). `update` resets FreeHarness
19
+ * hook flags to these shipped values, so a flag tuned in settings.json does not survive it:
20
+ * keeping the tuned entry beside the shipped one would run the hook twice.
21
+ */
16
22
  export declare const OUTPUT_PRUNER_FLAGS = "--max-bytes 8192 --max-lines 200";
17
23
  export declare const CONTEXT_MONITOR_FLAGS = "--budget 200000 --thresholds 60,85";
18
- export declare const CONFIG_GUARD_FLAGS = "--ttl 30";
24
+ /** Config-guard allow-marker window in minutes; feeds both the hook flag and the install note. */
25
+ export declare const CONFIG_GUARD_TTL_MIN = 240;
26
+ export declare const CONFIG_GUARD_FLAGS = "--ttl 240";
19
27
  export declare const REPEAT_TOOL_FLAGS = "--thresholds 3,5,8 --preview-chars 500";
20
28
  /**
21
29
  * Per-hook timeout (seconds) on SessionEnd entries. Claude Code gives SessionEnd hooks a
@@ -24,12 +32,20 @@ export declare const REPEAT_TOOL_FLAGS = "--thresholds 3,5,8 --preview-chars 500
24
32
  * pushes past 1.5 s.
25
33
  */
26
34
  export declare const SESSION_END_TIMEOUT_S = 10;
35
+ /** Status line quota tap, emitted with the cost capability; the owner wires it, never a settings hook. */
36
+ export declare const QUOTA_TAP_SCRIPT = "freeharness-quota-tap.mjs";
27
37
  /** Every hook script FreeHarness emits (rules + safety + cost capabilities), for doctor and uninstall. */
28
38
  export declare const HOOK_SCRIPTS: readonly string[];
29
39
  export declare class ClaudeCodeAdapter implements HarnessPort {
30
40
  readonly target: "claude-code";
31
41
  apply(cap: Capability, ctx: InstallContext): Promise<AdapterResult>;
32
42
  }
43
+ /**
44
+ * Mirrors `freeMemEnabled` in `../../cli/install-facts.js`, duplicated rather than
45
+ * shared: adapters never import the cli layer (`scripts/check-arch.mjs`). Exported so a
46
+ * test can hold both copies to the same answers.
47
+ */
48
+ export declare function freeMemEnabled(claudeDir: string): boolean;
33
49
  /**
34
50
  * Reports and, only once confirmed, strips a trailing `[1m]` suffix from the scope's own
35
51
  * `model` value. The emitted defaults never force or replace a model family: a value with no
@@ -1,15 +1,39 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { randomBytes } from "node:crypto";
4
5
  import { copyDir, copyExecutable, ensureDir, mergeClaudeHook, mergeSettingsKeys, readText, writeText, writeTextIfAbsent } from "../../infra/fsutil.js";
6
+ import { mergePriceTable } from "../../infra/prices.js";
5
7
  import { AGENT_MEMORY_GITIGNORE_LINE, AGENT_MEMORY_LOCAL_GITIGNORE_LINE, SDD_DIR, SDD_GITIGNORE_LINE, ensureGitignoreLine, ensureSddDir, } from "../../infra/dogfood.js";
6
- import { rewriteAgentModel } from "@freeharness/core";
8
+ import { mapModel, rewriteAgentModel } from "@freeharness/core";
7
9
  import { initProject } from "@freeharness/sdd/init";
8
- import { VendoredMemory, vendoredMemoryEngineDir } from "../memory/vendored.js";
10
+ import { PLUGIN_KEY, VendoredMemory, vendoredMemoryEngineDir } from "../memory/vendored.js";
9
11
  import { vendoredSddBin } from "../../infra/paths.js";
10
12
  import { ROUTING_FILE, routingSourcePath } from "../../infra/routing.js";
11
13
  // Re-exported: callers keep importing the routing file name from the claude-code adapter.
12
14
  export { ROUTING_FILE } from "../../infra/routing.js";
15
+ const SDD_SKILL = "subagent-driven-development";
16
+ /**
17
+ * The tier -> model table the SDD skill resolves a brief's `Tier:` through, rendered for one target.
18
+ * The use column repeats the implementer dispatch rule's classes, since the agents README that
19
+ * also states them is never emitted and this file is what a dispatching lead reads.
20
+ */
21
+ function tierReference(target) {
22
+ const rows = [
23
+ ["strong", "security and complex (Effort: high), code, any other class or none, every class below"],
24
+ ["standard", "docs, extraction, exploration, mechanical, summary, code (Route: C1-C7)"],
25
+ ["fast", "extraction, summary (reading set under 100,000 tokens)"],
26
+ ["frontier", "agentic-architect role (per dispatch)"],
27
+ ].map(([tier, use]) => `| ${tier} | ${mapModel(target, tier)} | ${use} |`);
28
+ const note = target === "opencode" ? "\nA `${OPENCODE_*_MODEL}` placeholder is the provider model id the environment sets.\n" : "";
29
+ return ["# Tier map", "", "The per-dispatch model for a brief's `Tier:`.", "", "| Tier | Model | Use |", "|------|-------|-----|", ...rows, note].join("\n");
30
+ }
31
+ /** Writes the SDD skill's `reference/tiers.md` under `skillsDir`; nothing when the canonical skills lack the SDD skill. */
32
+ export function emitTierReference(canonicalDir, skillsDir, target, dryRun) {
33
+ if (!fs.existsSync(path.join(canonicalDir, "skills", SDD_SKILL, "SKILL.md")))
34
+ return [];
35
+ return [writeText(path.join(skillsDir, SDD_SKILL, "reference", "tiers.md"), tierReference(target), dryRun)];
36
+ }
13
37
  /**
14
38
  * Capabilities that live in the machine-global install (~/.claude). At project scope the
15
39
  * adapter refuses them unless `allowProjectCopies` is set: Claude Code loads user and
@@ -20,10 +44,16 @@ export const GLOBAL_ONLY_CAPABILITIES = ["skills", "agents", "safety", "cost", "
20
44
  export const FH_CONTEXT_COMMAND = path.join(".claude", "commands", "fh-context.md");
21
45
  /** SessionStart matcher: compact/resume/fork get the re-orientation block, startup/clear the pointer. */
22
46
  export const SESSION_START_MATCHER = "startup|clear|compact|resume|fork";
23
- /** Flags baked into the hook commands (the fragment defaults; edit settings.json to tune). */
47
+ /**
48
+ * Flags baked into the hook commands (the fragment defaults). `update` resets FreeHarness
49
+ * hook flags to these shipped values, so a flag tuned in settings.json does not survive it:
50
+ * keeping the tuned entry beside the shipped one would run the hook twice.
51
+ */
24
52
  export const OUTPUT_PRUNER_FLAGS = "--max-bytes 8192 --max-lines 200";
25
53
  export const CONTEXT_MONITOR_FLAGS = "--budget 200000 --thresholds 60,85";
26
- export const CONFIG_GUARD_FLAGS = "--ttl 30";
54
+ /** Config-guard allow-marker window in minutes; feeds both the hook flag and the install note. */
55
+ export const CONFIG_GUARD_TTL_MIN = 240;
56
+ export const CONFIG_GUARD_FLAGS = `--ttl ${CONFIG_GUARD_TTL_MIN}`;
27
57
  export const REPEAT_TOOL_FLAGS = "--thresholds 3,5,8 --preview-chars 500";
28
58
  /**
29
59
  * Per-hook timeout (seconds) on SessionEnd entries. Claude Code gives SessionEnd hooks a
@@ -32,6 +62,8 @@ export const REPEAT_TOOL_FLAGS = "--thresholds 3,5,8 --preview-chars 500";
32
62
  * pushes past 1.5 s.
33
63
  */
34
64
  export const SESSION_END_TIMEOUT_S = 10;
65
+ /** Status line quota tap, emitted with the cost capability; the owner wires it, never a settings hook. */
66
+ export const QUOTA_TAP_SCRIPT = "freeharness-quota-tap.mjs";
35
67
  /** Every hook script FreeHarness emits (rules + safety + cost capabilities), for doctor and uninstall. */
36
68
  export const HOOK_SCRIPTS = [
37
69
  "freeharness-session-start",
@@ -43,8 +75,11 @@ export const HOOK_SCRIPTS = [
43
75
  "freeharness-output-pruner.mjs",
44
76
  "freeharness-context-monitor.mjs",
45
77
  "freeharness-precompact.mjs",
46
- "freeharness-auto-memory.mjs",
78
+ "freeharness-reload-context.mjs",
47
79
  "freeharness-converge.mjs",
80
+ "freeharness-sdd-context.mjs",
81
+ // Doctor warns about a missing tap only when settings.json names it; wrapper wiring is not seen.
82
+ QUOTA_TAP_SCRIPT,
48
83
  ];
49
84
  export class ClaudeCodeAdapter {
50
85
  target = "claude-code";
@@ -89,15 +124,39 @@ export class ClaudeCodeAdapter {
89
124
  ? `node "${ctScript}"`
90
125
  : 'node "${CLAUDE_PROJECT_DIR}/.freeharness/hooks/freeharness-cost-tracker.mjs"';
91
126
  const hookCmd = (script, flags) => `${user ? `node "${path.join(hooksDir, script)}"` : `node "\${CLAUDE_PROJECT_DIR}/.freeharness/hooks/${script}"`}${flags ? ` ${flags}` : ""}`;
92
- const emitHook = (script, flags, events, note) => {
127
+ const mergeOwned = (event, entry, ownerKey) => ({ event, r: mergeClaudeHook(settingsPath, event, entry, dry, ownerKey) });
128
+ const mainNote = (script, cmd, merged, addedNote, presentNote, failClosed) => {
129
+ const replaced = merged.flatMap((m) => (m.r.outcome === "replaced" ? [m.r] : []));
130
+ const hasAdded = merged.some((m) => m.r.outcome === "added");
131
+ if (replaced.length > 0) {
132
+ const stale = replaced.find((r) => r.replacedCommand !== undefined)?.replacedCommand;
133
+ const updatedNote = stale !== undefined
134
+ ? `Updated ${script} hook (flags changed: ${stale} -> ${cmd}). Update resets FreeHarness hook flags to the shipped values.`
135
+ : replaced.some((r) => r.onFailureChanged)
136
+ ? `Updated ${script} hook (onFailure reset to the shipped value).`
137
+ : `Updated ${script} hook (collapsed duplicate ${failClosed ? "guard" : "hook"} entries).`;
138
+ if (hasAdded)
139
+ return [updatedNote, addedNote];
140
+ return [updatedNote];
141
+ }
142
+ if (hasAdded)
143
+ return [addedNote];
144
+ return [presentNote];
145
+ };
146
+ // A shared entry is never rewritten, so a guard copy there cannot gain onFailure block.
147
+ const hookNotes = (script, cmd, merged, addedNote, presentNote, failClosed = false) => {
148
+ const why = failClosed ? " That copy runs without fail-closed (no onFailure block): if it cannot run, the tool call proceeds unjudged." : "";
149
+ const shared = merged.filter((m) => m.r.hasSharedCopy).map((m) => `${script} also runs inside a hook entry shared with other commands in ${m.event}; remove it there by hand.${why}`);
150
+ return [...mainNote(script, cmd, merged, addedNote, presentNote, failClosed), ...shared];
151
+ };
152
+ const emitHook = (script, flags, events, note, failClosed = false) => {
93
153
  res.written.push(writeText(path.join(hooksDir, script), readText(path.join(C, "hooks", script)), dry));
94
154
  const cmd = hookCmd(script, flags);
95
- let added = false;
96
- for (const e of events) {
97
- const entry = { ...(e.matcher ? { matcher: e.matcher } : {}), hooks: [{ type: "command", command: cmd, ...(e.event === "SessionEnd" ? { timeout: SESSION_END_TIMEOUT_S } : {}) }] };
98
- added = mergeClaudeHook(settingsPath, e.event, entry, dry).added || added;
99
- }
100
- res.notes.push(added ? `Added ${note}.` : `${script} hook already present.`);
155
+ const merged = events.map((e) => {
156
+ const hook = { type: "command", command: cmd, ...(e.event === "SessionEnd" || e.event === "SubagentStop" ? { timeout: SESSION_END_TIMEOUT_S } : {}), ...(failClosed ? { onFailure: "block" } : {}) };
157
+ return mergeOwned(e.event, { ...(e.matcher ? { matcher: e.matcher } : {}), hooks: [hook] }, hookCmd(script, ""));
158
+ });
159
+ res.notes.push(...hookNotes(script, cmd, merged, `Added ${note}.`, `${script} hook already present.`, failClosed));
101
160
  };
102
161
  switch (cap.id) {
103
162
  case "rules": {
@@ -127,18 +186,23 @@ export class ClaudeCodeAdapter {
127
186
  }
128
187
  res.written.push(copyExecutable(path.join(C, "hooks", "freeharness-session-start"), ssScript, dry));
129
188
  const ssEntry = { matcher: SESSION_START_MATCHER, hooks: [{ type: "command", command: ssCmd, async: false }] };
130
- const ss = mergeClaudeHook(settingsPath, "SessionStart", ssEntry, dry);
131
- res.notes.push(ss.added ? "Added SessionStart pointer hook (additive; claude-mem untouched; compact/resume re-inject the checkpoint)." : "SessionStart hook already present.");
189
+ // The session-start script runs without `node`, so its quoted path alone is the owner key.
190
+ const ss = mergeOwned("SessionStart", ssEntry, ssCmd);
191
+ res.notes.push(...hookNotes("freeharness-session-start", ssCmd, [ss], "Added SessionStart pointer hook (additive; memory plugin hooks untouched; compact/resume re-inject the checkpoint).", "SessionStart hook already present."));
132
192
  // Routing table beside the script the hook reads it from.
133
193
  res.written.push(writeText(path.join(hooksDir, ROUTING_FILE), readText(routingSourcePath(C)), dry));
134
194
  res.written.push(writeText(mgScript, readText(path.join(C, "hooks", "freeharness-memory-guard.mjs")), dry));
135
- const mgEntry = { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: mgCmd }] };
136
- const mg = mergeClaudeHook(settingsPath, "PreToolUse", mgEntry, dry);
137
- res.notes.push(mg.added ? "Added memory-guard hook (PreToolUse denies memory .md writes; saves tokens)." : "Memory-guard hook already present.");
138
- emitHook("freeharness-config-guard.mjs", CONFIG_GUARD_FLAGS, [{ event: "PreToolUse", matcher: "Edit|Write|MultiEdit|Bash" }], "config-guard hook (PreToolUse denies settings/.mcp.json/hook/CLAUDE.md writes without .freeharness/allow-config-edit)");
195
+ // The guards fail closed: a guard that cannot run blocks the call instead of passing it.
196
+ const mgEntry = { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: mgCmd, onFailure: "block" }] };
197
+ const mg = mergeOwned("PreToolUse", mgEntry, mgCmd);
198
+ res.notes.push(...hookNotes("freeharness-memory-guard.mjs", mgCmd, [mg], "Added memory-guard hook (PreToolUse denies memory .md writes; saves tokens).", "Memory-guard hook already present.", true));
199
+ emitHook("freeharness-config-guard.mjs", CONFIG_GUARD_FLAGS, [{ event: "PreToolUse", matcher: "Edit|Write|MultiEdit|Bash" }], `config-guard hook (PreToolUse denies edits to live configuration by location: settings in a .claude directory, .mcp.json, the user .claude.json and CLAUDE.md, hook directories and the plugin tree, unless an allow marker from the last ${CONFIG_GUARD_TTL_MIN} minutes is present)`, true);
139
200
  emitHook("freeharness-repeat-tool.mjs", REPEAT_TOOL_FLAGS, [{ event: "PreToolUse" }, { event: "UserPromptSubmit" }], "repeat-tool hook (PreToolUse reminds at 3/5/8 identical calls; UserPromptSubmit resets; advisory)");
140
201
  // Memory-independent: the skeleton is built locally; the worker summary is best effort.
141
- emitHook("freeharness-precompact.mjs", "", [{ event: "PreCompact" }], "precompact hook (PreCompact writes the checkpoint skeleton + file-op list for SessionStart and asks a running claude-mem worker for a summary; worker down = skipped)");
202
+ emitHook("freeharness-precompact.mjs", "", [{ event: "PreCompact" }], "precompact hook (PreCompact writes the checkpoint skeleton + file-op list for SessionStart and asks a running memory worker for a summary; worker down = skipped)");
203
+ emitHook("freeharness-reload-context.mjs", "", [{ event: "UserPromptSubmit" }], "reload-context hook (UserPromptSubmit answers the exact prompt `reload context` with the newest saved handoff or PreCompact skeleton; other prompts untouched)");
204
+ if (user && freeMemEnabled(claudeDir))
205
+ turnOffAutoMemory(settingsPath, dry, res);
142
206
  break;
143
207
  }
144
208
  case "skills": {
@@ -146,6 +210,7 @@ export class ClaudeCodeAdapter {
146
210
  transform: (c) => rewriteAgentModel(c, this.target),
147
211
  transformOnly: (n) => n === "SKILL.md",
148
212
  }));
213
+ res.written.push(...emitTierReference(C, skillsDir, this.target, dry));
149
214
  const rfScripts = path.join(skillsDir, "report-formatting", "scripts");
150
215
  res.actions.push({
151
216
  description: "Install report-formatting .docx deps (docx, image-size)",
@@ -173,8 +238,8 @@ export class ClaudeCodeAdapter {
173
238
  // never blocks. Same additive merge as the memory guard.
174
239
  res.written.push(writeText(sgScript, readText(path.join(C, "hooks", "freeharness-security-guidance.mjs")), dry));
175
240
  const sgEntry = { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: sgCmd }] };
176
- const sg = mergeClaudeHook(settingsPath, "PreToolUse", sgEntry, dry);
177
- res.notes.push(sg.added ? "Added security-guidance hook (PreToolUse warns on vulnerability sinks; advisory only)." : "Security-guidance hook already present.");
241
+ const sg = mergeOwned("PreToolUse", sgEntry, sgCmd);
242
+ res.notes.push(...hookNotes("freeharness-security-guidance.mjs", sgCmd, [sg], "Added security-guidance hook (PreToolUse warns on vulnerability sinks; advisory only).", "Security-guidance hook already present."));
178
243
  break;
179
244
  }
180
245
  case "sdd": {
@@ -226,14 +291,9 @@ export class ClaudeCodeAdapter {
226
291
  break;
227
292
  }
228
293
  case "memory": {
229
- // Optional tier-0 spike, off by default (FREEHARNESS_AUTO_MEMORY=on); registered even
230
- // when the vendored engine is absent because it only reads the dogfood log.
231
- emitHook("freeharness-auto-memory.mjs", "", [{ event: "SessionEnd" }], "auto-memory hook (SessionEnd curates one line per project into ~/.claude/freeharness/MEMORY.md; OFF until FREEHARNESS_AUTO_MEMORY=on)");
232
294
  const engineDir = vendoredMemoryEngineDir(C);
233
295
  if (!engineDir) {
234
- // --bridge fallback: vendored engine absent; upstream installer does the rest.
235
- res.actions.push({ description: "Install claude-mem plugin + wire hooks/MCP (bridge: vendored engine absent)", command: "npx claude-mem install", optional: true });
236
- res.notes.push("Vendored engine not found; bridging to upstream `npx claude-mem install`.");
296
+ res.notes.push("Vendored engine not found; the memory capability is skipped.");
237
297
  break;
238
298
  }
239
299
  // Real plugin install from OUR build (MemoryPort). Dry-run previews every step
@@ -253,6 +313,9 @@ export class ClaudeCodeAdapter {
253
313
  merge(await mem.migrateExisting(ctx));
254
314
  merge(await mem.install(ctx.scope, ctx));
255
315
  merge(await mem.registerHooks(ctx.scope, ctx));
316
+ // Registering enables free-mem, so the key follows in the same run; pass 1 previews it.
317
+ if (user)
318
+ turnOffAutoMemory(settingsPath, dry, res);
256
319
  res.notes.push("Worker start + recall are verified on the machine (needs the Claude Agent SDK key / network); not exercised by the installer.");
257
320
  break;
258
321
  }
@@ -271,22 +334,22 @@ export class ClaudeCodeAdapter {
271
334
  break;
272
335
  }
273
336
  case "cost": {
274
- // Stop + SessionEnd append usage snapshots to cost/usage.jsonl; the price table is
275
- // seeded once and never overwritten (the user edits it to the account's contract).
276
337
  res.written.push(writeText(ctScript, readText(path.join(C, "hooks", "freeharness-cost-tracker.mjs")), dry));
277
- const prices = writeTextIfAbsent(ctPrices, readText(path.join(C, "hooks", "cost-prices.json")), dry);
278
- res.written.push(prices.path);
279
- if (prices.skipped)
280
- (res.skipped ??= []).push(prices.path);
281
- const stop = mergeClaudeHook(settingsPath, "Stop", { hooks: [{ type: "command", command: ctCmd }] }, dry);
282
- const end = mergeClaudeHook(settingsPath, "SessionEnd", { hooks: [{ type: "command", command: ctCmd, timeout: SESSION_END_TIMEOUT_S }] }, dry);
338
+ applyPriceTable(ctPrices, readText(path.join(C, "hooks", "cost-prices.json")), dry, res);
339
+ const stop = mergeOwned("Stop", { hooks: [{ type: "command", command: ctCmd }] }, ctCmd);
340
+ const end = mergeOwned("SessionEnd", { hooks: [{ type: "command", command: ctCmd, timeout: SESSION_END_TIMEOUT_S }] }, ctCmd);
283
341
  // SubagentStop mirrors SessionEnd: a long dispatch also pushes the subagent's own
284
342
  // transcript past the default budget.
285
- const subagentStop = mergeClaudeHook(settingsPath, "SubagentStop", { hooks: [{ type: "command", command: ctCmd, timeout: SESSION_END_TIMEOUT_S }] }, dry);
286
- res.notes.push(stop.added || end.added || subagentStop.added ? "Added cost-tracker hooks (Stop + SessionEnd + SubagentStop append usage/ledger entries; never block)." : "Cost-tracker hooks already present.");
287
- res.notes.push(prices.skipped ? `Price table kept as is: ${ctPrices} (edit to your contract).` : `Price table seeded at ${ctPrices} — placeholders; edit to your contract. Report: freeharness cost report.`);
343
+ const subagentStop = mergeOwned("SubagentStop", { hooks: [{ type: "command", command: ctCmd, timeout: SESSION_END_TIMEOUT_S }] }, ctCmd);
344
+ res.notes.push(...hookNotes("freeharness-cost-tracker.mjs", ctCmd, [stop, end, subagentStop], "Added cost-tracker hooks (Stop + SessionEnd + SubagentStop append usage/ledger entries; never block).", "Cost-tracker hooks already present."));
288
345
  emitHook("freeharness-output-pruner.mjs", OUTPUT_PRUNER_FLAGS, [{ event: "PostToolUse", matcher: "Bash" }], "output-pruner hook (PostToolUse trims Bash output over 8 KB / 200 lines; full copy under ~/.claude/freeharness/tool-output/)");
289
- emitHook("freeharness-context-monitor.mjs", CONTEXT_MONITOR_FLAGS, [{ event: "Stop" }], "context-monitor hook (Stop warns the user once at 60% and 85% of the context budget)");
346
+ emitHook("freeharness-context-monitor.mjs", CONTEXT_MONITOR_FLAGS, [{ event: "Stop" }], "context-monitor hook (Stop warns the user once at 60% and 85% of the context budget, writes a handoff at the highest threshold and suggests /clear)");
347
+ // Emitted beside the tracker, whose brief-path rule it imports from its own directory.
348
+ emitHook("freeharness-sdd-context.mjs", "", [{ event: "SubagentStart" }, { event: "SubagentStop" }], "sdd-context hook (SubagentStart gives every subagent a free-mem project brief; SubagentStop saves an implementer's checkpoint to the task state and leaves the report, tagged with its change and task, with the worker; FREEHARNESS_SDD_CONTEXT_WORKER=off turns the worker calls off)");
349
+ // The status line feeds the tap, not a hook event: no settings entry, and statusLine
350
+ // stays the owner's to wire.
351
+ res.written.push(writeText(path.join(hooksDir, QUOTA_TAP_SCRIPT), readText(path.join(C, "hooks", QUOTA_TAP_SCRIPT)), dry));
352
+ res.notes.push(`${QUOTA_TAP_SCRIPT} emitted; pipe your status line input to it by hand to record quota readings (Documents/ADVANCED.md, Quota tap). FreeHarness never writes statusLine.`);
290
353
  // Cache-hygiene settings: additive, existing keys win.
291
354
  const tpl = JSON.parse(readText(path.join(C, "settings", "settings.cache-hygiene.claude-code.json")));
292
355
  const merged = mergeSettingsKeys(settingsPath, tpl, dry);
@@ -302,6 +365,108 @@ export class ClaudeCodeAdapter {
302
365
  return res;
303
366
  }
304
367
  }
368
+ /**
369
+ * Mirrors `freeMemEnabled` in `../../cli/install-facts.js`, duplicated rather than
370
+ * shared: adapters never import the cli layer (`scripts/check-arch.mjs`). Exported so a
371
+ * test can hold both copies to the same answers.
372
+ */
373
+ export function freeMemEnabled(claudeDir) {
374
+ let settings;
375
+ try {
376
+ settings = JSON.parse(fs.readFileSync(path.join(claudeDir, "settings.json"), "utf8"));
377
+ }
378
+ catch {
379
+ return false;
380
+ }
381
+ return settings?.enabledPlugins?.[PLUGIN_KEY] === true;
382
+ }
383
+ /**
384
+ * free-mem is the only memory owner; Claude Code auto memory (and agent memory with it) would
385
+ * keep a second store nothing recalls. Added only when absent, so an owner's explicit value wins.
386
+ */
387
+ function turnOffAutoMemory(settingsPath, dry, res) {
388
+ let merged;
389
+ try {
390
+ merged = mergeSettingsKeys(settingsPath, { autoMemoryEnabled: false }, dry);
391
+ }
392
+ catch (e) {
393
+ res.notes.push(`autoMemoryEnabled not set: ${settingsPath} not parsed (${e instanceof Error ? e.message : String(e)}).`);
394
+ return;
395
+ }
396
+ if (merged.added.length > 0) {
397
+ res.notes.push(`${dry ? "Would add" : "Added"} autoMemoryEnabled = false to ${settingsPath} (free-mem is the only memory; Claude Code auto memory and agent memory off).`);
398
+ }
399
+ else {
400
+ res.notes.push(`autoMemoryEnabled already set in ${settingsPath}; left as the owner set it.`);
401
+ }
402
+ }
403
+ /** Seeds the price table once, then merges missing rows and previously shipped values into it on later runs. */
404
+ function applyPriceTable(file, shippedText, dry, res) {
405
+ if (!fs.existsSync(file)) {
406
+ const seeded = writeTextIfAbsent(file, shippedText, dry);
407
+ res.written.push(seeded.path);
408
+ res.notes.push(`Price table seeded at ${file} — placeholders; edit to your contract. Report: freeharness cost report.`);
409
+ return;
410
+ }
411
+ res.written.push(file);
412
+ // An unreadable table is left as is.
413
+ let installedText;
414
+ try {
415
+ installedText = readText(file);
416
+ }
417
+ catch (e) {
418
+ (res.skipped ??= []).push(file);
419
+ res.notes.push(`Price table left as is (${e instanceof Error ? e.message : String(e)}): ${file}.`);
420
+ return;
421
+ }
422
+ const merge = mergePriceTable(installedText, shippedText);
423
+ if ("warning" in merge) {
424
+ (res.skipped ??= []).push(file);
425
+ res.notes.push(`Price table left as is (${merge.warning}): ${file}.`);
426
+ return;
427
+ }
428
+ if (merge.added.length === 0 && merge.corrected.length === 0) {
429
+ (res.skipped ??= []).push(file);
430
+ res.notes.push(`Price table kept as is: ${file} (edit to your contract).`);
431
+ return;
432
+ }
433
+ // Pass-2 notes never print; one wording covers both.
434
+ const changes = [
435
+ ...(merge.added.length > 0 ? [`adds rows ${merge.added.join(", ")}`] : []),
436
+ ...(merge.corrected.length > 0 ? [`corrects ${merge.corrected.length} previously shipped value(s)`] : []),
437
+ ];
438
+ res.notes.push(`Price table ${file}: ${changes.join("; ")}.`);
439
+ for (const key of merge.added)
440
+ res.notes.push(`add ${key}`);
441
+ for (const c of merge.corrected) {
442
+ res.notes.push(`correct ${c.row}.${c.field} ${JSON.stringify(c.from)} -> ${JSON.stringify(c.to)}`);
443
+ }
444
+ if (dry) {
445
+ (res.skipped ??= []).push(file);
446
+ return;
447
+ }
448
+ // Merge into the symlink's real path so its link survives.
449
+ let target = file;
450
+ try {
451
+ if (fs.lstatSync(file).isSymbolicLink())
452
+ target = fs.realpathSync(file);
453
+ }
454
+ catch (e) {
455
+ (res.skipped ??= []).push(file);
456
+ res.notes.push(`Price table left as is (symlink target unreadable: ${e instanceof Error ? e.message : String(e)}): ${file}.`);
457
+ return;
458
+ }
459
+ // Random suffix + "wx" refuse a symlinked temp path.
460
+ const tmp = path.join(path.dirname(target), `.${path.basename(target)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
461
+ try {
462
+ fs.writeFileSync(tmp, merge.text, { encoding: "utf8", flag: "wx" });
463
+ fs.renameSync(tmp, target);
464
+ }
465
+ catch (e) {
466
+ fs.rmSync(tmp, { force: true });
467
+ throw e;
468
+ }
469
+ }
305
470
  /**
306
471
  * Splits a model id on a trailing `[suffix]` tag (e.g. `claude-fable-5-1[1m]`). Mirrors
307
472
  * `splitModelSuffix` in `../../cli/doctor.js`, duplicated here rather than shared: adapters
@@ -6,7 +6,7 @@ import { routingSourcePath } from "../../infra/routing.js";
6
6
  import { frontmatterHasKey, rewriteAgentModel, stripAgentKeys } from "@freeharness/core";
7
7
  import { initProject } from "@freeharness/sdd/init";
8
8
  import { vendoredSddBin } from "../../infra/paths.js";
9
- import { seedProjectEvidence } from "./claude-code.js";
9
+ import { emitTierReference, seedProjectEvidence } from "./claude-code.js";
10
10
  /**
11
11
  * Whether any canonical agent (excluding README.md) carries a `memory:` front matter key.
12
12
  * Reads the source tree directly, independent of `dryRun`, so a preview names the same
@@ -36,6 +36,19 @@ function anyAgentCarriesMemoryKey(agentsSrc) {
36
36
  };
37
37
  return walk(agentsSrc);
38
38
  }
39
+ /**
40
+ * Drops the free-mem MCP tool ids from an agent's `tools:` front matter line. OpenCode runs no
41
+ * free-mem MCP server, so an agent naming those tools would list tools it can never call; the
42
+ * line's other entries keep their order.
43
+ * @param content The agent file text.
44
+ * @returns The text with every `mcp__plugin_free-mem_` entry removed from the first `tools:` line.
45
+ */
46
+ function dropFreeMemTools(content) {
47
+ return content.replace(/^tools:[ \t]*(.*)$/m, (_line, list) => {
48
+ const kept = list.split(",").map((t) => t.trim()).filter((t) => t && !t.startsWith("mcp__plugin_free-mem_"));
49
+ return `tools: ${kept.join(", ")}`;
50
+ });
51
+ }
39
52
  // OpenCode adapter: emits AGENTS.md + best-effort skill/agent layouts; no session-start hook (OpenCode has no hook contract we emit to).
40
53
  export class OpenCodeAdapter {
41
54
  target = "opencode";
@@ -65,6 +78,7 @@ export class OpenCodeAdapter {
65
78
  transform: (c) => stripAgentKeys(c, ["disable-model-invocation", "context", "agent", "model"]),
66
79
  transformOnly: (n) => n === "SKILL.md",
67
80
  }));
81
+ res.written.push(...emitTierReference(C, path.join(P, ".opencode", "skills"), this.target, dry));
68
82
  res.notes.push("Skills emitted to .opencode/skills (verify against your OpenCode version).");
69
83
  const rfScripts = path.join(P, ".opencode", "skills", "report-formatting", "scripts");
70
84
  res.actions.push({
@@ -86,7 +100,7 @@ export class OpenCodeAdapter {
86
100
  const droppedMemoryKey = anyAgentCarriesMemoryKey(agentsSrc);
87
101
  res.written.push(...copyDir(agentsSrc, path.join(P, ".opencode", "agent"), dry, {
88
102
  skip: (name) => name === "README.md",
89
- transform: (content) => stripAgentKeys(rewriteAgentModel(content, this.target), ["memory"]),
103
+ transform: (content) => dropFreeMemTools(stripAgentKeys(rewriteAgentModel(content, this.target), ["memory", "effort", "omitClaudeMd", "cache_ttl"])),
90
104
  }));
91
105
  res.notes.push("Agent model tiers mapped to ${OPENCODE_*_MODEL} placeholders; fill them in.");
92
106
  if (droppedMemoryKey)
@@ -9,36 +9,44 @@ import { rewriteAgentModel } from "@freeharness/core";
9
9
  import { copyDir, copyExecutable, readText, writeText } from "../../infra/fsutil.js";
10
10
  import { cliVersion } from "../../infra/paths.js";
11
11
  import { ROUTING_FILE, routingSourcePath } from "../../infra/routing.js";
12
- import { CONFIG_GUARD_FLAGS, CONTEXT_MONITOR_FLAGS, OUTPUT_PRUNER_FLAGS, REPEAT_TOOL_FLAGS, SESSION_END_TIMEOUT_S, SESSION_START_MATCHER } from "./claude-code.js";
12
+ import { CONFIG_GUARD_FLAGS, CONTEXT_MONITOR_FLAGS, OUTPUT_PRUNER_FLAGS, REPEAT_TOOL_FLAGS, SESSION_END_TIMEOUT_S, SESSION_START_MATCHER, emitTierReference } from "./claude-code.js";
13
13
  /** Plugin directory name inside the project root. */
14
14
  export const PLUGIN_DIR_NAME = "freeharness-plugin";
15
15
  /** Marketplace name (the `@marketplace` suffix in `claude plugin install freeharness@...`). */
16
16
  export const MARKETPLACE_NAME = "freeharness-local";
17
17
  /**
18
18
  * Additively merges a hook entry into the plugin's hooks.json (dedupe by command; the file is
19
- * created with the official `description` + `hooks` shape).
19
+ * created with the official `description` + `hooks` shape). Entries running only this command
20
+ * fold into the first one, rewritten to the current entry, so a re-emit gains the fail-closed
21
+ * field and a stale copy never keeps running beside a current one; an entry mixing it with other
22
+ * commands is left as is.
20
23
  * @param file Absolute path of hooks/hooks.json.
21
24
  * @param event Hook event name (SessionStart, PreToolUse, ...).
22
25
  * @param entry Entry to add.
23
26
  * @param dryRun When true nothing is written.
24
- * @returns Whether the entry was added (false when already present).
27
+ * @returns Whether the file changed (false when the entry was already current).
25
28
  */
26
29
  function mergePluginHook(file, event, entry, dryRun) {
27
30
  let doc = {
28
- description: "FreeHarness hooks: SessionStart pointer (+ post-compaction re-orientation), PreCompact checkpoint, memory guard, config guard, repeat-tool reminder, security guidance (advisory), cost tracker, output pruner, context monitor, auto-memory (flag-gated). Additive; only the memory guard and the config guard deny.",
31
+ description: "FreeHarness hooks: SessionStart pointer (+ post-compaction re-orientation), PreCompact checkpoint, reload-context recap, memory guard, config guard, repeat-tool reminder, security guidance (advisory), cost tracker, output pruner, context monitor, SDD context (SubagentStart task state and open findings, SubagentStop checkpoint written to the task state). Additive; only the memory guard and the config guard deny.",
29
32
  hooks: {},
30
33
  };
31
34
  if (fs.existsSync(file))
32
35
  doc = JSON.parse(fs.readFileSync(file, "utf8"));
33
36
  doc.hooks ??= {};
34
37
  doc.hooks[event] ??= [];
35
- const cmd = entry.hooks[0].command;
36
- const exists = doc.hooks[event].some((e) => e.hooks.some((h) => h.command === cmd));
37
- if (!exists)
38
- doc.hooks[event].push(entry);
39
- if (!dryRun && !exists)
38
+ const { command: cmd, onFailure } = entry.hooks[0];
39
+ const arr = doc.hooks[event];
40
+ const own = arr.flatMap((e, i) => (e.hooks.length === 1 && e.hooks[0].command === cmd ? [i] : []));
41
+ const exists = arr.some((e) => e.hooks.some((h) => h.command === cmd));
42
+ const changed = own.length > 1 || (own.length === 1 && arr[own[0]].hooks[0].onFailure !== onFailure) || !exists;
43
+ if (own.length > 0 && changed)
44
+ doc.hooks[event] = arr.flatMap((e, i) => (i === own[0] ? [entry] : own.includes(i) ? [] : [e]));
45
+ else if (!exists)
46
+ arr.push(entry);
47
+ if (!dryRun && changed)
40
48
  writeText(file, JSON.stringify(doc, null, 2) + "\n", false);
41
- return !exists;
49
+ return changed;
42
50
  }
43
51
  /**
44
52
  * Emits the FreeHarness content as a Claude Code plugin directory under
@@ -60,10 +68,11 @@ export class PluginAdapter {
60
68
  const hooksFile = path.join(hooksDir, "hooks.json");
61
69
  const hookCmd = (script, runner = "node", flags = "") => `${runner} "\${CLAUDE_PLUGIN_ROOT}/hooks/${script}"${flags ? ` ${flags}` : ""}`;
62
70
  /** Copies a canonical hook script and registers it under each event (dedupe by command). */
63
- const emitHook = (script, flags, events) => {
71
+ const emitHook = (script, flags, events, failClosed = false) => {
64
72
  res.written.push(writeText(path.join(hooksDir, script), readText(path.join(C, "hooks", script)), dry));
65
73
  for (const e of events) {
66
- const entry = { ...(e.matcher ? { matcher: e.matcher } : {}), hooks: [{ type: "command", command: hookCmd(script, "node", flags), ...(e.event === "SessionEnd" ? { timeout: SESSION_END_TIMEOUT_S } : {}) }] };
74
+ const hook = { type: "command", command: hookCmd(script, "node", flags), ...(e.event === "SessionEnd" || e.event === "SubagentStop" ? { timeout: SESSION_END_TIMEOUT_S } : {}), ...(failClosed ? { onFailure: "block" } : {}) };
75
+ const entry = { ...(e.matcher ? { matcher: e.matcher } : {}), hooks: [hook] };
67
76
  mergePluginHook(hooksFile, e.event, entry, dry);
68
77
  }
69
78
  };
@@ -82,11 +91,13 @@ export class PluginAdapter {
82
91
  // it from its own directory regardless of which target installed it.
83
92
  res.written.push(writeText(path.join(hooksDir, ROUTING_FILE), readText(routingSourcePath(C)), dry));
84
93
  res.written.push(writeText(path.join(hooksDir, "freeharness-memory-guard.mjs"), readText(path.join(C, "hooks", "freeharness-memory-guard.mjs")), dry));
85
- const mg = { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: hookCmd("freeharness-memory-guard.mjs") }] };
94
+ // The guards fail closed: a guard that cannot run blocks the call instead of passing it.
95
+ const mg = { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: hookCmd("freeharness-memory-guard.mjs"), onFailure: "block" }] };
86
96
  mergePluginHook(hooksFile, "PreToolUse", mg, dry);
87
- emitHook("freeharness-config-guard.mjs", CONFIG_GUARD_FLAGS, [{ event: "PreToolUse", matcher: "Edit|Write|MultiEdit|Bash" }]);
97
+ emitHook("freeharness-config-guard.mjs", CONFIG_GUARD_FLAGS, [{ event: "PreToolUse", matcher: "Edit|Write|MultiEdit|Bash" }], true);
88
98
  emitHook("freeharness-repeat-tool.mjs", REPEAT_TOOL_FLAGS, [{ event: "PreToolUse" }, { event: "UserPromptSubmit" }]);
89
99
  emitHook("freeharness-precompact.mjs", "", [{ event: "PreCompact" }]);
100
+ emitHook("freeharness-reload-context.mjs", "", [{ event: "UserPromptSubmit" }]);
90
101
  res.written.push(hooksFile);
91
102
  break;
92
103
  }
@@ -95,6 +106,7 @@ export class PluginAdapter {
95
106
  transform: (c) => rewriteAgentModel(c, this.target),
96
107
  transformOnly: (n) => n === "SKILL.md",
97
108
  }));
109
+ res.written.push(...emitTierReference(C, path.join(root, "skills"), this.target, dry));
98
110
  res.notes.push("skills: report-formatting runtime deps (docx/python-pptx) are installed by the user after the plugin loads (see the plugin README).");
99
111
  break;
100
112
  case "agents":
@@ -136,6 +148,8 @@ export class PluginAdapter {
136
148
  res.notes.push("cost: SessionEnd hooks run under Claude Code's shared 1.5 s budget, which a plugin timeout does not raise; set CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS=10000 for long transcripts.");
137
149
  emitHook("freeharness-output-pruner.mjs", OUTPUT_PRUNER_FLAGS, [{ event: "PostToolUse", matcher: "Bash" }]);
138
150
  emitHook("freeharness-context-monitor.mjs", CONTEXT_MONITOR_FLAGS, [{ event: "Stop" }]);
151
+ // Beside the tracker: the hook imports its brief-path rule from its own directory.
152
+ emitHook("freeharness-sdd-context.mjs", "", [{ event: "SubagentStart" }, { event: "SubagentStop" }]);
139
153
  res.written.push(hooksFile);
140
154
  res.notes.push("cost: the hook prices with hooks/cost-prices.json next to it (placeholders; edit, or point FREEHARNESS_COST_PRICES at your table).");
141
155
  res.notes.push("cost: a plugin cannot set settings keys; add promptCacheTtl / subagentPromptCacheTtl = 1h to ~/.claude/settings.json by hand or with `freeharness init --scope user --only cost`.");
@@ -149,9 +163,6 @@ export class PluginAdapter {
149
163
  break;
150
164
  case "memory":
151
165
  res.notes.push("memory: not a plugin component (free-mem is its own plugin, built and registered by `freeharness init --scope user --only memory`).");
152
- emitHook("freeharness-auto-memory.mjs", "", [{ event: "SessionEnd" }]);
153
- res.notes.push("memory: the auto-memory tier-0 hook ships (SessionEnd; a no-op until FREEHARNESS_AUTO_MEMORY=on).");
154
- res.written.push(hooksFile);
155
166
  break;
156
167
  default:
157
168
  res.notes.push(`No plugin action for capability '${cap.id}'.`);