freeharness 0.4.1 → 0.4.3

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 (203) hide show
  1. package/NOTICE +2 -1
  2. package/README.md +24 -19
  3. package/canonical/HARVEST.yaml +27 -16
  4. package/canonical/PROVENANCE.yaml +3 -3
  5. package/canonical/agents/README.md +77 -36
  6. package/canonical/agents/agentic-architect.md +2 -1
  7. package/canonical/agents/database-reviewer.md +6 -8
  8. package/canonical/agents/explorer-ai-engineer-analyst.md +2 -6
  9. package/canonical/agents/implementer.md +30 -0
  10. package/canonical/agents/pr-reviewer.md +9 -11
  11. package/canonical/agents/python-reviewer.md +7 -9
  12. package/canonical/agents/report-formatter.md +7 -3
  13. package/canonical/agents/security-reviewer.md +14 -9
  14. package/canonical/agents/test-writer.md +5 -2
  15. package/canonical/hooks/cost-prices.json +11 -9
  16. package/canonical/hooks/freeharness-config-guard.mjs +4155 -93
  17. package/canonical/hooks/freeharness-context-monitor.mjs +88 -54
  18. package/canonical/hooks/freeharness-converge.mjs +78 -23
  19. package/canonical/hooks/freeharness-cost-tracker.mjs +1116 -218
  20. package/canonical/hooks/freeharness-memory-guard.mjs +20 -23
  21. package/canonical/hooks/freeharness-precompact.mjs +206 -87
  22. package/canonical/hooks/freeharness-quota-tap.mjs +173 -0
  23. package/canonical/hooks/freeharness-reload-context.mjs +197 -0
  24. package/canonical/hooks/freeharness-repeat-tool.mjs +1 -1
  25. package/canonical/hooks/freeharness-sdd-context.mjs +959 -0
  26. package/canonical/hooks/freeharness-session-start +297 -59
  27. package/canonical/hooks/hooks.claude-code.json +1 -1
  28. package/canonical/hooks/hooks.config-guard.claude-code.json +2 -2
  29. package/canonical/hooks/hooks.context-monitor.claude-code.json +1 -1
  30. package/canonical/hooks/hooks.converge.claude-code.json +1 -1
  31. package/canonical/hooks/hooks.memory-guard.claude-code.json +1 -1
  32. package/canonical/hooks/hooks.reload-context.claude-code.json +15 -0
  33. package/canonical/openspec/schema.spec-driven.yaml +29 -8
  34. package/canonical/openspec/templates/follow-ups.md +3 -2
  35. package/canonical/openspec/templates/proposal.md +1 -1
  36. package/canonical/openspec/templates/tasks.md +8 -0
  37. package/canonical/rules/CLAUDE.md +5 -4
  38. package/canonical/rules/routing.md +10 -9
  39. package/canonical/skills/agent-evaluation/SKILL.md +17 -6
  40. package/canonical/skills/agent-evaluation/evals/cases.yaml +38 -23
  41. package/canonical/skills/agentic-architecture/SKILL.md +4 -0
  42. package/canonical/skills/agentic-architecture/evals/cases.yaml +27 -0
  43. package/canonical/skills/code-review/SKILL.md +10 -3
  44. package/canonical/skills/code-review/evals/cases.yaml +1118 -27
  45. package/canonical/skills/git-workflow/SKILL.md +2 -0
  46. package/canonical/skills/local-code/SKILL.md +2 -2
  47. package/canonical/skills/local-code/evals/cases.yaml +1 -1
  48. package/canonical/skills/report-formatting/SKILL.md +10 -2
  49. package/canonical/skills/report-formatting/reference/ARCHITECTURE.md +3 -3
  50. package/canonical/skills/report-formatting/reference/HELPERS.md +10 -5
  51. package/canonical/skills/report-formatting/reference/RELEASE.md +1 -2
  52. package/canonical/skills/report-formatting/reference/TEMPLATES.md +3 -2
  53. package/canonical/skills/report-formatting/reference/authoring.md +25 -37
  54. package/canonical/skills/report-formatting/scripts/build-architecture.js +1 -1
  55. package/canonical/skills/report-formatting/scripts/build-md-claro.js +5 -8
  56. package/canonical/skills/report-formatting/scripts/check-env.js +0 -4
  57. package/canonical/skills/report-formatting/scripts/docmodel.js +3 -11
  58. package/canonical/skills/report-formatting/scripts/example-build.js +8 -10
  59. package/canonical/skills/report-formatting/scripts/example_build_ArchitectureReview_docx.js +8 -10
  60. package/canonical/skills/report-formatting/scripts/example_build_ExecutiveReport_docx.js +8 -10
  61. package/canonical/skills/report-formatting/scripts/theme-claro.js +39 -10
  62. package/canonical/skills/subagent-driven-development/SKILL.md +35 -28
  63. package/canonical/skills/subagent-driven-development/evals/cases.yaml +3625 -19
  64. package/canonical/skills/subagent-driven-development/reference/brief.md +187 -35
  65. package/canonical/skills/subagent-driven-development/reference/handoff.md +96 -2
  66. package/canonical/skills/subagent-driven-development/reference/loop.md +56 -0
  67. package/canonical/skills/subagent-driven-development/reference/trees.md +40 -0
  68. package/canonical/skills/subagent-driven-development/reference/workspace.md +31 -0
  69. package/canonical/skills/subagent-driven-development/scripts/validate-handoff.mjs +4035 -327
  70. package/dist/adapters/harness/claude-code.d.ts +26 -3
  71. package/dist/adapters/harness/claude-code.js +214 -43
  72. package/dist/adapters/harness/opencode.js +17 -3
  73. package/dist/adapters/harness/plugin.js +28 -17
  74. package/dist/adapters/memory/doctor.d.ts +172 -33
  75. package/dist/adapters/memory/doctor.js +750 -139
  76. package/dist/adapters/memory/migrate.d.ts +95 -0
  77. package/dist/adapters/memory/migrate.js +497 -0
  78. package/dist/adapters/memory/paths.d.ts +15 -0
  79. package/dist/adapters/memory/paths.js +47 -0
  80. package/dist/adapters/memory/vendored.d.ts +52 -30
  81. package/dist/adapters/memory/vendored.js +161 -169
  82. package/dist/adapters/memory/worker-control.d.ts +9 -0
  83. package/dist/adapters/memory/worker-control.js +30 -0
  84. package/dist/adapters/sync/diff.js +4 -1
  85. package/dist/cli/cleanup-prompt.d.ts +35 -0
  86. package/dist/cli/cleanup-prompt.js +68 -0
  87. package/dist/cli/cleanup.d.ts +22 -24
  88. package/dist/cli/cleanup.js +204 -75
  89. package/dist/cli/cost-quota.d.ts +158 -0
  90. package/dist/cli/cost-quota.js +645 -0
  91. package/dist/cli/cost.d.ts +408 -46
  92. package/dist/cli/cost.js +1731 -57
  93. package/dist/cli/doctor.d.ts +52 -13
  94. package/dist/cli/doctor.js +199 -45
  95. package/dist/cli/hard-cleanup.d.ts +93 -0
  96. package/dist/cli/hard-cleanup.js +552 -0
  97. package/dist/cli/install-facts.d.ts +29 -0
  98. package/dist/cli/install-facts.js +68 -0
  99. package/dist/cli/json-key-edit.d.ts +18 -0
  100. package/dist/cli/json-key-edit.js +118 -0
  101. package/dist/cli/memory-import.d.ts +74 -0
  102. package/dist/cli/memory-import.js +469 -0
  103. package/dist/cli/plugin-validate.d.ts +12 -0
  104. package/dist/cli/plugin-validate.js +43 -0
  105. package/dist/cli/profile.d.ts +1 -1
  106. package/dist/cli/profile.js +10 -10
  107. package/dist/cli/uninstall.d.ts +1 -1
  108. package/dist/cli/uninstall.js +2 -2
  109. package/dist/cli/upstream-launchers.d.ts +49 -0
  110. package/dist/cli/upstream-launchers.js +287 -0
  111. package/dist/index.js +252 -73
  112. package/dist/infra/fsutil.d.ts +30 -3
  113. package/dist/infra/fsutil.js +68 -6
  114. package/dist/infra/manifest.d.ts +2 -1
  115. package/dist/infra/manifest.js +3 -2
  116. package/dist/infra/prices.d.ts +18 -0
  117. package/dist/infra/prices.js +64 -0
  118. package/node_modules/@freeharness/core/dist/registry.d.ts +1 -1
  119. package/node_modules/@freeharness/core/dist/registry.js +3 -3
  120. package/node_modules/@freeharness/core/dist/tiers.d.ts +10 -1
  121. package/node_modules/@freeharness/core/dist/tiers.js +24 -2
  122. package/node_modules/@freeharness/core/package.json +1 -1
  123. package/node_modules/@freeharness/sdd/PROVENANCE.yaml +279 -2
  124. package/node_modules/@freeharness/sdd/dist/src/bin/openspec.js +34 -8
  125. package/node_modules/@freeharness/sdd/dist/src/shim/archive.d.ts +7 -6
  126. package/node_modules/@freeharness/sdd/dist/src/shim/archive.js +72 -64
  127. package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.d.ts +48 -16
  128. package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.js +168 -70
  129. package/node_modules/@freeharness/sdd/dist/src/shim/instructions.d.ts +36 -6
  130. package/node_modules/@freeharness/sdd/dist/src/shim/instructions.js +110 -49
  131. package/node_modules/@freeharness/sdd/dist/src/shim/list.d.ts +1 -1
  132. package/node_modules/@freeharness/sdd/dist/src/shim/list.js +57 -14
  133. package/node_modules/@freeharness/sdd/dist/src/shim/nested-change.d.ts +7 -0
  134. package/node_modules/@freeharness/sdd/dist/src/shim/nested-change.js +118 -0
  135. package/node_modules/@freeharness/sdd/dist/src/shim/new-change.js +3 -1
  136. package/node_modules/@freeharness/sdd/dist/src/shim/proposal-capabilities.d.ts +12 -0
  137. package/node_modules/@freeharness/sdd/dist/src/shim/proposal-capabilities.js +94 -0
  138. package/node_modules/@freeharness/sdd/dist/src/shim/shared.d.ts +17 -0
  139. package/node_modules/@freeharness/sdd/dist/src/shim/shared.js +182 -1
  140. package/node_modules/@freeharness/sdd/dist/src/shim/status.d.ts +2 -2
  141. package/node_modules/@freeharness/sdd/dist/src/shim/status.js +34 -5
  142. package/node_modules/@freeharness/sdd/dist/src/shim/validate.d.ts +9 -0
  143. package/node_modules/@freeharness/sdd/dist/src/shim/validate.js +49 -41
  144. package/node_modules/@freeharness/sdd/dist/src/shim/warnings.d.ts +21 -0
  145. package/node_modules/@freeharness/sdd/dist/src/shim/warnings.js +66 -0
  146. package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/line-endings.d.ts +4 -0
  147. package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/line-endings.js +17 -0
  148. package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/specs-apply.js +95 -22
  149. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.d.ts +6 -0
  150. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.js +14 -6
  151. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.d.ts +1 -1
  152. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.js +1 -1
  153. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.d.ts +3 -3
  154. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.js +24 -4
  155. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/task-progress.js +4 -7
  156. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/types.js +13 -1
  157. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/apply-change.js +21 -6
  158. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/archive-change.js +80 -19
  159. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/explore.js +42 -22
  160. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/project-root.d.ts +32 -0
  161. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/project-root.js +39 -0
  162. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/propose.js +16 -9
  163. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/sync-specs.js +36 -1
  164. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/update-change.js +65 -30
  165. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.d.ts +1 -1
  166. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.js +1 -1
  167. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.d.ts +8 -13
  168. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.js +79 -102
  169. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/markdown-parser.js +4 -9
  170. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.d.ts +50 -10
  171. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.js +165 -37
  172. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.d.ts +24 -3
  173. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.js +55 -8
  174. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/spec-structure.js +4 -1
  175. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/purpose-placeholder.js +19 -9
  176. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.d.ts +10 -0
  177. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.js +115 -0
  178. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.d.ts +3 -3
  179. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.js +20 -20
  180. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.d.ts +6 -0
  181. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.js +75 -14
  182. package/node_modules/@freeharness/sdd/package.json +1 -1
  183. package/node_modules/@freeharness/sdd/vendor/openspec/assets/schema.upstream.yaml +47 -7
  184. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-apply-change/SKILL.md +25 -7
  185. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-archive-change/SKILL.md +50 -11
  186. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-bulk-archive-change/SKILL.md +48 -10
  187. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-continue-change/SKILL.md +27 -7
  188. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-explore/SKILL.md +25 -12
  189. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-ff-change/SKILL.md +17 -5
  190. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-new-change/SKILL.md +12 -1
  191. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-onboard/SKILL.md +47 -33
  192. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-propose/SKILL.md +17 -5
  193. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-sync-specs/SKILL.md +29 -1
  194. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-update-change/SKILL.md +31 -13
  195. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-verify-change/SKILL.md +75 -27
  196. package/package.json +6 -4
  197. package/provenance/canonical.PROVENANCE.yaml +3 -3
  198. package/provenance/memory.PROVENANCE.yaml +472 -6
  199. package/provenance/sdd.PROVENANCE.yaml +279 -2
  200. package/canonical/hooks/freeharness-auto-memory.mjs +0 -158
  201. package/canonical/hooks/hooks.auto-memory.claude-code.json +0 -16
  202. package/canonical/openspec/config.base.yaml +0 -89
  203. package/canonical/rules/safety.md +0 -41
@@ -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,27 @@ 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";
41
+ private readonly autoMemoryMerged;
42
+ private turnOffAutoMemoryOnce;
31
43
  apply(cap: Capability, ctx: InstallContext): Promise<AdapterResult>;
32
44
  }
45
+ /**
46
+ * Mirrors `freeMemEnabled` in `../../cli/install-facts.js`, duplicated rather than
47
+ * shared: adapters never import the cli layer (`scripts/check-arch.mjs`). Exported so a
48
+ * test can hold both copies to the same answers.
49
+ */
50
+ export declare function freeMemEnabled(claudeDir: string): boolean;
51
+ /**
52
+ * free-mem is the only memory owner; Claude Code auto memory (and agent memory with it) would
53
+ * keep a second store nothing recalls. Added only when absent, so an owner's explicit value wins.
54
+ */
55
+ export declare function turnOffAutoMemory(settingsPath: string, dry: boolean, res: AdapterResult): void;
33
56
  /**
34
57
  * Reports and, only once confirmed, strips a trailing `[1m]` suffix from the scope's own
35
58
  * `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,11 +75,23 @@ 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";
86
+ // The runner keeps one adapter per pass, so a later capability case sees the key this pass
87
+ // already merged and would report it as the owner's value.
88
+ autoMemoryMerged = new Set();
89
+ turnOffAutoMemoryOnce(settingsPath, dry, res) {
90
+ if (this.autoMemoryMerged.has(settingsPath))
91
+ return;
92
+ this.autoMemoryMerged.add(settingsPath);
93
+ turnOffAutoMemory(settingsPath, dry, res);
94
+ }
51
95
  async apply(cap, ctx) {
52
96
  const res = { capabilityId: cap.id, target: this.target, written: [], actions: [], notes: [] };
53
97
  const C = ctx.canonicalDir;
@@ -69,9 +113,6 @@ export class ClaudeCodeAdapter {
69
113
  const hooksDir = user
70
114
  ? path.join(home, ".claude", "freeharness", "hooks")
71
115
  : path.join(ctx.projectRoot, ".freeharness", "hooks");
72
- const safetyFile = user
73
- ? path.join(home, ".claude", "freeharness", "safety.md")
74
- : path.join(ctx.projectRoot, ".freeharness", "safety.md");
75
116
  const ssScript = path.join(hooksDir, "freeharness-session-start");
76
117
  const mgScript = path.join(hooksDir, "freeharness-memory-guard.mjs");
77
118
  // user scope bakes an absolute path; project scope uses ${CLAUDE_PROJECT_DIR}.
@@ -89,15 +130,38 @@ export class ClaudeCodeAdapter {
89
130
  ? `node "${ctScript}"`
90
131
  : 'node "${CLAUDE_PROJECT_DIR}/.freeharness/hooks/freeharness-cost-tracker.mjs"';
91
132
  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) => {
133
+ const mergeOwned = (event, entry, ownerKey) => ({ event, r: mergeClaudeHook(settingsPath, event, entry, dry, ownerKey) });
134
+ const mainNote = (script, cmd, merged, addedNote, presentNote, guard) => {
135
+ const replaced = merged.flatMap((m) => (m.r.outcome === "replaced" ? [m.r] : []));
136
+ const hasAdded = merged.some((m) => m.r.outcome === "added");
137
+ if (replaced.length > 0) {
138
+ const stale = replaced.find((r) => r.replacedCommand !== undefined)?.replacedCommand;
139
+ const updatedNote = stale !== undefined
140
+ ? `Updated ${script} hook (flags changed: ${stale} -> ${cmd}). Update resets FreeHarness hook flags to the shipped values.`
141
+ : replaced.some((r) => r.onFailureChanged)
142
+ ? `Updated ${script} hook (onFailure reset to the shipped value).`
143
+ : `Updated ${script} hook (collapsed duplicate ${guard ? "guard" : "hook"} entries).`;
144
+ if (hasAdded)
145
+ return [updatedNote, addedNote];
146
+ return [updatedNote];
147
+ }
148
+ if (hasAdded)
149
+ return [addedNote];
150
+ return [presentNote];
151
+ };
152
+ // A shared entry is never rewritten, so the owner removes that copy by hand.
153
+ const hookNotes = (script, cmd, merged, addedNote, presentNote, guard = false) => {
154
+ 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.`);
155
+ return [...mainNote(script, cmd, merged, addedNote, presentNote, guard), ...shared];
156
+ };
157
+ const emitHook = (script, flags, events, note, guard = false) => {
93
158
  res.written.push(writeText(path.join(hooksDir, script), readText(path.join(C, "hooks", script)), dry));
94
159
  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.`);
160
+ const merged = events.map((e) => {
161
+ const hook = { type: "command", command: cmd, ...(e.event === "SessionEnd" || e.event === "SubagentStop" ? { timeout: SESSION_END_TIMEOUT_S } : {}) };
162
+ return mergeOwned(e.event, { ...(e.matcher ? { matcher: e.matcher } : {}), hooks: [hook] }, hookCmd(script, ""));
163
+ });
164
+ res.notes.push(...hookNotes(script, cmd, merged, `Added ${note}.`, `${script} hook already present.`, guard));
101
165
  };
102
166
  switch (cap.id) {
103
167
  case "rules": {
@@ -127,18 +191,23 @@ export class ClaudeCodeAdapter {
127
191
  }
128
192
  res.written.push(copyExecutable(path.join(C, "hooks", "freeharness-session-start"), ssScript, dry));
129
193
  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.");
194
+ // The session-start script runs without `node`, so its quoted path alone is the owner key.
195
+ const ss = mergeOwned("SessionStart", ssEntry, ssCmd);
196
+ 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
197
  // Routing table beside the script the hook reads it from.
133
198
  res.written.push(writeText(path.join(hooksDir, ROUTING_FILE), readText(routingSourcePath(C)), dry));
134
199
  res.written.push(writeText(mgScript, readText(path.join(C, "hooks", "freeharness-memory-guard.mjs")), dry));
200
+ // The guards sit under the permission layer, so a guard that cannot run allows the call.
135
201
  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)");
202
+ const mg = mergeOwned("PreToolUse", mgEntry, mgCmd);
203
+ 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));
204
+ 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
205
  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
206
  // 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)");
207
+ 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)");
208
+ 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)");
209
+ if (user && freeMemEnabled(claudeDir))
210
+ this.turnOffAutoMemoryOnce(settingsPath, dry, res);
142
211
  break;
143
212
  }
144
213
  case "skills": {
@@ -146,6 +215,7 @@ export class ClaudeCodeAdapter {
146
215
  transform: (c) => rewriteAgentModel(c, this.target),
147
216
  transformOnly: (n) => n === "SKILL.md",
148
217
  }));
218
+ res.written.push(...emitTierReference(C, skillsDir, this.target, dry));
149
219
  const rfScripts = path.join(skillsDir, "report-formatting", "scripts");
150
220
  res.actions.push({
151
221
  description: "Install report-formatting .docx deps (docx, image-size)",
@@ -168,13 +238,12 @@ export class ClaudeCodeAdapter {
168
238
  }));
169
239
  break;
170
240
  case "safety": {
171
- res.written.push(writeText(safetyFile, readText(path.join(C, "rules", "safety.md")), dry));
172
241
  // Advisory PreToolUse hook: warns on vulnerability sinks in Edit/Write content,
173
242
  // never blocks. Same additive merge as the memory guard.
174
243
  res.written.push(writeText(sgScript, readText(path.join(C, "hooks", "freeharness-security-guidance.mjs")), dry));
175
244
  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.");
245
+ const sg = mergeOwned("PreToolUse", sgEntry, sgCmd);
246
+ 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
247
  break;
179
248
  }
180
249
  case "sdd": {
@@ -226,14 +295,9 @@ export class ClaudeCodeAdapter {
226
295
  break;
227
296
  }
228
297
  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
298
  const engineDir = vendoredMemoryEngineDir(C);
233
299
  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`.");
300
+ res.notes.push("Vendored engine not found; the memory capability is skipped.");
237
301
  break;
238
302
  }
239
303
  // Real plugin install from OUR build (MemoryPort). Dry-run previews every step
@@ -253,6 +317,9 @@ export class ClaudeCodeAdapter {
253
317
  merge(await mem.migrateExisting(ctx));
254
318
  merge(await mem.install(ctx.scope, ctx));
255
319
  merge(await mem.registerHooks(ctx.scope, ctx));
320
+ // Registering enables free-mem, so the key follows in the same run; pass 1 previews it.
321
+ if (user)
322
+ this.turnOffAutoMemoryOnce(settingsPath, dry, res);
256
323
  res.notes.push("Worker start + recall are verified on the machine (needs the Claude Agent SDK key / network); not exercised by the installer.");
257
324
  break;
258
325
  }
@@ -264,29 +331,30 @@ export class ClaudeCodeAdapter {
264
331
  continue;
265
332
  res.actions.push({
266
333
  description: `Register MCP server ${s.name}${s.optional ? " (optional)" : ""}`,
267
- command: `claude mcp add ${s.name} -s ${sc} -- ${s.command} ${s.args.join(" ")}`,
334
+ // `claude mcp add` exits 1 for a registered name; the get guard keeps a re-run quiet.
335
+ command: `claude mcp get ${s.name} >/dev/null 2>&1 || claude mcp add ${s.name} -s ${sc} -- ${s.command} ${s.args.join(" ")}`,
268
336
  optional: true, // a failing/missing `claude` must not abort the whole install
269
337
  });
270
338
  }
271
339
  break;
272
340
  }
273
341
  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
342
  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);
343
+ applyPriceTable(ctPrices, readText(path.join(C, "hooks", "cost-prices.json")), dry, res);
344
+ const stop = mergeOwned("Stop", { hooks: [{ type: "command", command: ctCmd }] }, ctCmd);
345
+ const end = mergeOwned("SessionEnd", { hooks: [{ type: "command", command: ctCmd, timeout: SESSION_END_TIMEOUT_S }] }, ctCmd);
283
346
  // SubagentStop mirrors SessionEnd: a long dispatch also pushes the subagent's own
284
347
  // 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.`);
348
+ const subagentStop = mergeOwned("SubagentStop", { hooks: [{ type: "command", command: ctCmd, timeout: SESSION_END_TIMEOUT_S }] }, ctCmd);
349
+ 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
350
  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)");
351
+ 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)");
352
+ // Emitted beside the tracker, whose brief-path rule it imports from its own directory.
353
+ 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)");
354
+ // The status line feeds the tap, not a hook event: no settings entry, and statusLine
355
+ // stays the owner's to wire.
356
+ res.written.push(writeText(path.join(hooksDir, QUOTA_TAP_SCRIPT), readText(path.join(C, "hooks", QUOTA_TAP_SCRIPT)), dry));
357
+ 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
358
  // Cache-hygiene settings: additive, existing keys win.
291
359
  const tpl = JSON.parse(readText(path.join(C, "settings", "settings.cache-hygiene.claude-code.json")));
292
360
  const merged = mergeSettingsKeys(settingsPath, tpl, dry);
@@ -302,6 +370,109 @@ export class ClaudeCodeAdapter {
302
370
  return res;
303
371
  }
304
372
  }
373
+ /**
374
+ * Mirrors `freeMemEnabled` in `../../cli/install-facts.js`, duplicated rather than
375
+ * shared: adapters never import the cli layer (`scripts/check-arch.mjs`). Exported so a
376
+ * test can hold both copies to the same answers.
377
+ */
378
+ export function freeMemEnabled(claudeDir) {
379
+ let settings;
380
+ try {
381
+ settings = JSON.parse(fs.readFileSync(path.join(claudeDir, "settings.json"), "utf8"));
382
+ }
383
+ catch {
384
+ return false;
385
+ }
386
+ return settings?.enabledPlugins?.[PLUGIN_KEY] === true;
387
+ }
388
+ /**
389
+ * free-mem is the only memory owner; Claude Code auto memory (and agent memory with it) would
390
+ * keep a second store nothing recalls. Added only when absent, so an owner's explicit value wins.
391
+ */
392
+ export function turnOffAutoMemory(settingsPath, dry, res) {
393
+ let merged;
394
+ try {
395
+ merged = mergeSettingsKeys(settingsPath, { autoMemoryEnabled: false }, dry);
396
+ }
397
+ catch (e) {
398
+ const reason = e instanceof SyntaxError ? "not parsed" : "not updated";
399
+ res.notes.push(`autoMemoryEnabled not set: ${settingsPath} ${reason} (${e instanceof Error ? e.message : String(e)}).`);
400
+ return;
401
+ }
402
+ if (merged.added.length > 0) {
403
+ 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).`);
404
+ }
405
+ else {
406
+ res.notes.push(`autoMemoryEnabled already set in ${settingsPath}; left as the owner set it.`);
407
+ }
408
+ }
409
+ /** Seeds the price table once, then merges missing rows and previously shipped values into it on later runs. */
410
+ function applyPriceTable(file, shippedText, dry, res) {
411
+ if (!fs.existsSync(file)) {
412
+ const seeded = writeTextIfAbsent(file, shippedText, dry);
413
+ res.written.push(seeded.path);
414
+ res.notes.push(`Price table seeded at ${file} — placeholders; edit to your contract. Report: freeharness cost report.`);
415
+ return;
416
+ }
417
+ res.written.push(file);
418
+ // An unreadable table is left as is.
419
+ let installedText;
420
+ try {
421
+ installedText = readText(file);
422
+ }
423
+ catch (e) {
424
+ (res.skipped ??= []).push(file);
425
+ res.notes.push(`Price table left as is (${e instanceof Error ? e.message : String(e)}): ${file}.`);
426
+ return;
427
+ }
428
+ const merge = mergePriceTable(installedText, shippedText);
429
+ if ("warning" in merge) {
430
+ (res.skipped ??= []).push(file);
431
+ res.notes.push(`Price table left as is (${merge.warning}): ${file}.`);
432
+ return;
433
+ }
434
+ if (merge.added.length === 0 && merge.corrected.length === 0) {
435
+ (res.skipped ??= []).push(file);
436
+ res.notes.push(`Price table kept as is: ${file} (edit to your contract).`);
437
+ return;
438
+ }
439
+ // Pass-2 notes never print; one wording covers both.
440
+ const changes = [
441
+ ...(merge.added.length > 0 ? [`adds rows ${merge.added.join(", ")}`] : []),
442
+ ...(merge.corrected.length > 0 ? [`corrects ${merge.corrected.length} previously shipped value(s)`] : []),
443
+ ];
444
+ res.notes.push(`Price table ${file}: ${changes.join("; ")}.`);
445
+ for (const key of merge.added)
446
+ res.notes.push(`add ${key}`);
447
+ for (const c of merge.corrected) {
448
+ res.notes.push(`correct ${c.row}.${c.field} ${JSON.stringify(c.from)} -> ${JSON.stringify(c.to)}`);
449
+ }
450
+ if (dry) {
451
+ (res.skipped ??= []).push(file);
452
+ return;
453
+ }
454
+ // Merge into the symlink's real path so its link survives.
455
+ let target = file;
456
+ try {
457
+ if (fs.lstatSync(file).isSymbolicLink())
458
+ target = fs.realpathSync(file);
459
+ }
460
+ catch (e) {
461
+ (res.skipped ??= []).push(file);
462
+ res.notes.push(`Price table left as is (symlink target unreadable: ${e instanceof Error ? e.message : String(e)}): ${file}.`);
463
+ return;
464
+ }
465
+ // Random suffix + "wx" refuse a symlinked temp path.
466
+ const tmp = path.join(path.dirname(target), `.${path.basename(target)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
467
+ try {
468
+ fs.writeFileSync(tmp, merge.text, { encoding: "utf8", flag: "wx" });
469
+ fs.renameSync(tmp, target);
470
+ }
471
+ catch (e) {
472
+ fs.rmSync(tmp, { force: true });
473
+ throw e;
474
+ }
475
+ }
305
476
  /**
306
477
  * Splits a model id on a trailing `[suffix]` tag (e.g. `claude-fable-5-1[1m]`). Mirrors
307
478
  * `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)
@@ -94,7 +108,7 @@ export class OpenCodeAdapter {
94
108
  break;
95
109
  }
96
110
  case "safety":
97
- res.written.push(writeText(path.join(P, ".freeharness", "safety.md"), readText(path.join(C, "rules", "safety.md")), dry));
111
+ res.notes.push("safety: OpenCode has no hook contract FreeHarness emits to; nothing emitted.");
98
112
  break;
99
113
  case "sdd": {
100
114
  const src = path.join(C, "openspec");
@@ -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 drops a stale `onFailure`
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
@@ -63,7 +71,8 @@ export class PluginAdapter {
63
71
  const emitHook = (script, flags, events) => {
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 } : {}) };
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));
94
+ // The guards sit under the permission layer, so a guard that cannot run allows the call.
85
95
  const mg = { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: hookCmd("freeharness-memory-guard.mjs") }] };
86
96
  mergePluginHook(hooksFile, "PreToolUse", mg, dry);
87
97
  emitHook("freeharness-config-guard.mjs", CONFIG_GUARD_FLAGS, [{ event: "PreToolUse", matcher: "Edit|Write|MultiEdit|Bash" }]);
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":
@@ -104,7 +116,7 @@ export class PluginAdapter {
104
116
  }));
105
117
  break;
106
118
  case "safety": {
107
- res.notes.push("safety: rules/safety.md is a rules file (not a plugin component); only the advisory security-guidance hook ships in the plugin.");
119
+ res.notes.push("safety: the advisory security-guidance hook ships in the plugin.");
108
120
  res.written.push(writeText(path.join(hooksDir, "freeharness-security-guidance.mjs"), readText(path.join(C, "hooks", "freeharness-security-guidance.mjs")), dry));
109
121
  const sg = { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: hookCmd("freeharness-security-guidance.mjs") }] };
110
122
  mergePluginHook(hooksFile, "PreToolUse", sg, dry);
@@ -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}'.`);
@@ -245,8 +256,8 @@ its agents, skills, and hooks.
245
256
 
246
257
  ## What is not in the plugin
247
258
 
248
- - Behavior rules (\`CLAUDE.md\`) and \`safety.md\`: not plugin components. Install them per user with
249
- \`freeharness init --scope user --only rules,safety\`.
259
+ - Behavior rules (\`CLAUDE.md\`): not a plugin component. Install them per user with
260
+ \`freeharness init --scope user --only rules\`. The security-guidance hook ships in the plugin.
250
261
  - SDD (OpenSpec \`/opsx\`): per-project init, \`freeharness bootstrap\` inside the project.
251
262
  - Memory (free-mem): its own plugin, \`freeharness init --scope user --only memory\`.
252
263
  - Report-formatting runtime deps: after install run \`npm install\` in