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
package/dist/index.js CHANGED
@@ -12,20 +12,25 @@ import { manifestPath, readManifest, repoHead, writeManifest } from "./infra/man
12
12
  import { runPlan } from "./cli/runner.js";
13
13
  import { collectActions, printPlan, runActions } from "./cli/executor.js";
14
14
  import { uninstallScope } from "./cli/uninstall.js";
15
- import { applyCleanup, describeItem, planCleanup, pruneManifest } from "./cli/cleanup.js";
15
+ import { describeItem, planCleanup, pruneManifest } from "./cli/cleanup.js";
16
+ import { archiveRootFor, describeHard, planHard, printable } from "./cli/hard-cleanup.js";
17
+ import { runCleanupPass2 } from "./cli/cleanup-prompt.js";
18
+ import { freeMemEnabled } from "./cli/install-facts.js";
16
19
  import { FH_CONTEXT_COMMAND } from "./adapters/harness/claude-code.js";
17
- import { baselineLines, budgetLines, contextBudgetLines, depLines, emittedProseRoots, foreignContentLines, hookConflictLines, hookScanLines, installedContentCounts, manifestLines, mcpLines, mcpToolCounts, memoryPluginInstalled, openspecChangeLines, probeDeps, profileLines, runningFromLines } from "./cli/doctor.js";
20
+ import { autoMemoryLine, baselineLines, budgetLines, contextBudgetLines, depLines, emittedProseRoots, foreignContentLines, guardRuntimeLines, hookConflictLines, hookScanLines, installedContentCounts, manifestLines, mcpLines, mcpToolCounts, memoryPluginInstalled, openspecChangeLines, probeDeps, profileLines, reportOnlyLauncherLines, runningFromLines, sessionModelLine } from "./cli/doctor.js";
18
21
  import { applyEnvProfile, applyMcpProfile, applyProviderProfile, loadProfileFile, mcpCatalogItems, removeProfiles, resolveProfile } from "./cli/profile.js";
19
22
  import { formatValidation, validatePluginDir } from "./cli/plugin-validate.js";
20
23
  import { PROFILES, PROFILE_NONE } from "@freeharness/core";
21
- import { agents, aggregate, changes, costLogLine, costLogPath, costPricesPath, formatAgents, formatChanges, formatInjectionLine, formatReport, formatSinks, loadPriceTable, readTelemetry, readUsage, sinks, telemetryPath } from "./cli/cost.js";
24
+ import { agents, aggregate, changes, costLogLine, costLogPath, costPricesPath, escapeJsonControls, formatAgents, formatChanges, formatInjectionLine, formatReport, formatRoutingReport, formatSinks, loadPriceTable, readTelemetry, readUsage, retroJson, retroLine, routingReportForProject, routingReportJson, sinks, telemetryPath, untaggedShare } from "./cli/cost.js";
22
25
  import { DEFAULT_PREFIX_BUDGET, estimatePrefix, mcpServerNames } from "./cli/prefix.js";
26
+ import { DEFAULT_QUOTA_DAYS, costQuota } from "./cli/cost-quota.js";
23
27
  import { HOOK_SCRIPTS } from "./adapters/harness/claude-code.js";
24
28
  import { configMarkerPath, openConfigWindow } from "./infra/fsutil.js";
25
29
  import { NodePackage } from "./adapters/package/node.js";
26
30
  import { ReadlinePrompt } from "./adapters/prompt/readline.js";
27
31
  import { DiffSync } from "./adapters/sync/diff.js";
28
- import { activateOurs, applyObserverSettings, backupMemoryData, dataDir, diagnoseMemory, previewObserverSettings, restartWorker } from "./adapters/memory/doctor.js";
32
+ import { activateOurs, applyObserverSettings, backupMemoryData, dataDir, diagnoseMemory, previewObserverSettings, restartAfterStage, restartWorker, workerRunning } from "./adapters/memory/doctor.js";
33
+ import { claudePluginPaths, pluginVersion, stagePluginCache, stageStamp, vendoredMemoryEngineDir } from "./adapters/memory/vendored.js";
29
34
  const program = new Command();
30
35
  program.name("freeharness").description("Agnostic agentic engineering framework installer").version(cliVersion());
31
36
  function parseTargets(value) {
@@ -158,17 +163,25 @@ async function applyProfile(opts) {
158
163
  await runActions(actions, new NodePackage());
159
164
  console.log("\nApplied.");
160
165
  }
166
+ /**
167
+ * The capability ids a plan installs.
168
+ * @param only The `--only` list, when given.
169
+ * @param extra Capabilities added to the default set when `only` is absent.
170
+ * @returns The requested ids.
171
+ */
172
+ function requestedCapabilities(only, extra = []) {
173
+ return only ? only.split(",").map((s) => s.trim()).filter(Boolean) : [...new Set([...defaultCapabilityIds(), ...extra])];
174
+ }
161
175
  // Two-pass install: pass 1 previews (writes nothing), then (unless --dry-run) confirm
162
- // and pass 2 applies file writes, then run the side-effecting actions.
163
- async function applyPlan(opts) {
176
+ // and pass 2 applies file writes, then run the side-effecting actions. Returns whether
177
+ // pass 2 ran.
178
+ async function applyPlan(opts, hooks = {}) {
164
179
  const targets = parseTargets(opts.target);
165
180
  const scope = parseScope(opts.scope);
166
181
  if (targets.includes("plugin") && scope !== "project") {
167
182
  throw new Error("--target plugin writes a plugin directory under --project <dir>; it is project-scoped (use --scope project).");
168
183
  }
169
- const requested = opts.only
170
- ? opts.only.split(",").map((s) => s.trim()).filter(Boolean)
171
- : defaultCapabilityIds();
184
+ const requested = requestedCapabilities(opts.only, hooks.extra);
172
185
  const plan = buildPlan(requested, targets, scope);
173
186
  const canonicalDir = resolveCanonicalDir();
174
187
  const projectRoot = path.resolve(opts.project);
@@ -183,15 +196,17 @@ async function applyPlan(opts) {
183
196
  const preview = await runPlan(plan, { scope, projectRoot, canonicalDir, dryRun: true, allowProjectCopies });
184
197
  const actions = collectActions(preview);
185
198
  printPlan(preview, actions, opts.dryRun);
199
+ for (const l of hooks.planLines ?? [])
200
+ console.log(`${opts.dryRun ? "[dry-run] " : ""}${l}`);
186
201
  if (opts.dryRun) {
187
202
  console.log("\n[dry-run] nothing was applied.");
188
- return;
203
+ return false;
189
204
  }
190
205
  if (!opts.yes) {
191
206
  const proceed = await new ReadlinePrompt().confirm("\nApply these changes and run the actions above?");
192
207
  if (!proceed) {
193
208
  console.log("Aborted; nothing was applied.");
194
- return;
209
+ return false;
195
210
  }
196
211
  }
197
212
  // Pass 2 — apply file writes, record them in the install manifest, then run the actions.
@@ -206,6 +221,10 @@ async function applyPlan(opts) {
206
221
  finally {
207
222
  closeWindow();
208
223
  }
224
+ const confirmedPassFailures = results.flatMap((r) => r.notes).filter((n) => n.startsWith("autoMemoryEnabled not set"));
225
+ for (const note of confirmedPassFailures) {
226
+ console.log(`WARN ${note}`);
227
+ }
209
228
  const repo = sourceRepoRoot(canonicalDir);
210
229
  // Only capabilities that emitted something are recorded: a refused global-only capability
211
230
  // (project scope) leaves no trace in the project manifest.
@@ -224,6 +243,28 @@ async function applyPlan(opts) {
224
243
  if (actions.length > 0)
225
244
  await runActions(actions, new NodePackage());
226
245
  console.log("\nApplied.");
246
+ return true;
247
+ }
248
+ /**
249
+ * The checkout's vendored memory plugin dir, which the memory install stages.
250
+ * @returns The plugin dir, or undefined without a vendored engine.
251
+ */
252
+ function vendoredPluginDir() {
253
+ const engine = vendoredMemoryEngineDir(resolveCanonicalDir());
254
+ return engine ? path.join(path.dirname(engine), "plugin") : undefined;
255
+ }
256
+ /**
257
+ * Prints the outcome of a worker restart after a stage; a failure names the engine log and
258
+ * never fails the command.
259
+ * @param r The restart decision and outcome.
260
+ */
261
+ function printRestart(r) {
262
+ if (!r.planned)
263
+ return;
264
+ if (r.error)
265
+ console.log(`WARN worker restart failed: ${r.error}; see ${r.log}`);
266
+ else
267
+ console.log(`OK worker restarted: ${r.result?.detail ?? ""}`);
227
268
  }
228
269
  /**
229
270
  * Resolves the prefix budget: --budget, else FREEHARNESS_PREFIX_BUDGET, else the default.
@@ -347,11 +388,27 @@ installOptions(program.command("update").description("Detect a newer FreeHarness
347
388
  if (opts.check)
348
389
  return;
349
390
  console.log(check.newer ? "\nRe-running the install from the newer source:" : "\nRe-emitting anyway (idempotent):");
350
- await applyPlan(opts);
391
+ // Optional capabilities the manifest records (memory) are re-emitted too, so a newer
392
+ // vendored memory build reaches the plugin cache; the worker state is read before any write,
393
+ // and a re-stage is detected by the cache marker the staging rewrites.
394
+ const recorded = readManifest(manifestPath(scope, path.resolve(opts.project)))?.capabilities ?? [];
395
+ const extra = recorded.filter((id) => CAPABILITIES.some((c) => c.id === id));
396
+ const pluginDir = vendoredPluginDir();
397
+ const memory = pluginDir && requestedCapabilities(opts.only, extra).includes("memory") && parseTargets(opts.target).includes("claude-code") ? pluginDir : undefined;
398
+ const cacheDir = memory ? claudePluginPaths(pluginVersion(memory)).cacheDir : "";
399
+ const wasRunning = memory !== undefined && (await workerRunning());
400
+ const before = memory ? stageStamp(cacheDir) : null;
401
+ const planned = memory ? await restartAfterStage(wasRunning, { staged: stagePluginCache(memory, cacheDir, { dryRun: true }).staged }, true) : undefined;
402
+ const planLines = planned ? [`restart worker: ${planned.planned ? "yes" : `no (${planned.reason})`}`] : [];
403
+ const applied = await applyPlan(opts, { extra, planLines });
404
+ if (memory && applied) {
405
+ const after = stageStamp(cacheDir);
406
+ printRestart(await restartAfterStage(wasRunning, { staged: after !== null && after !== before }, false));
407
+ }
351
408
  });
352
409
  program
353
410
  .command("uninstall")
354
- .description("Remove what FreeHarness emitted into a scope (install manifest entries only); never claude-mem data or user files")
411
+ .description("Remove what FreeHarness emitted into a scope (install manifest entries only); never memory data or user files")
355
412
  .option("--scope <scope>", "project | user (global ~/.claude)", "project")
356
413
  .option("--project <dir>", "project root (project scope only)", process.cwd())
357
414
  .option("--dry-run", "list what would be removed without removing anything", false)
@@ -375,7 +432,7 @@ program
375
432
  console.log(` keep: ${k.path} — ${k.reason}`);
376
433
  for (const m of preview.missing)
377
434
  console.log(` gone: ${m}`);
378
- console.log("Never touched: settings.json (beyond our hooks), the claude-mem plugin cache and data dir, files not in the manifest.");
435
+ console.log("Never touched: settings.json (beyond our hooks), the memory plugin cache and memory data dir, files not in the manifest.");
379
436
  if (opts.dryRun) {
380
437
  console.log("\n[dry-run] nothing was removed.");
381
438
  return;
@@ -392,7 +449,7 @@ program
392
449
  program
393
450
  .command("bootstrap")
394
451
  .description("Activate FreeHarness in a project: CLAUDE.md (template, if absent) + /fh-context command + SDD (openspec/, /opsx, openspec-* skills). Skills, agents, hooks, memory and MCP stay global")
395
- .option("--target <list>", "comma-separated harnesses", "claude-code")
452
+ .option("--target <list>", "comma-separated harnesses", DEFAULT_TARGETS.join(","))
396
453
  .option("--project <dir>", "project root", process.cwd())
397
454
  .option("--dry-run", "preview without applying", false)
398
455
  .option("--yes", "apply without confirmation", false)
@@ -414,35 +471,57 @@ export function hasPlaceholders(text) {
414
471
  const body = text.replace(/<!--[\s\S]*?-->/g, "");
415
472
  return /<(?:[A-Za-z0-9][^<>\n]*\s[^<>\n]*|dir_name)>/.test(body);
416
473
  }
474
+ /** Row label of a cleanup item, padded to one width. */
475
+ function itemLabel(i) {
476
+ if (i.group === "backups")
477
+ return "backup";
478
+ if (i.refused)
479
+ return "keep ";
480
+ if (i.archiveDest !== undefined)
481
+ return "move ";
482
+ return "remove";
483
+ }
417
484
  /**
418
485
  * Prints a cleanup plan for the preview / dry-run.
419
486
  * @param plan The plan.
420
487
  * @param tag Line prefix ("[dry-run] " or "").
488
+ * @param hard The `--hard` plan, printed after the cleanup plan.
421
489
  */
422
- function printCleanup(plan, tag) {
490
+ function printCleanup(plan, tag, hard) {
423
491
  const items = plan.items;
424
492
  console.log(`${tag}cleanup (${plan.scope}): ${items.length} item(s)`);
425
493
  for (const i of items)
426
- console.log(` ${i.group === "backups" ? "backup" : i.refused ? "keep " : "remove"}: ${describeItem(i)}`);
494
+ console.log(` ${itemLabel(i)}: ${printable(describeItem(i))}`);
427
495
  for (const n of plan.notes)
428
- console.log(` note: ${n}`);
496
+ console.log(` note: ${printable(n)}`);
497
+ if (!hard)
498
+ return;
499
+ console.log(`${tag}hard cleanup (${hard.scope}): ${hard.items.length} item(s), archive ${printable(hard.archiveRoot)}`);
500
+ for (const i of hard.items)
501
+ console.log(` ${printable(describeHard(i))}`);
502
+ for (const n of hard.notes)
503
+ console.log(` note: ${printable(n)}`);
429
504
  }
430
505
  program
431
506
  .command("cleanup")
432
- .description("Remove leftovers of earlier installs: user = parked upstream claude-mem, its backups, upstream claude-mem hook entries in settings.json (plugin registered), legacy /openspec commands, global @fission-ai/openspec, a global npm freeharness link; project = legacy /openspec commands, project copies of global agents/skills, duplicate hook entries. Fixed list; nothing else is touched (your own skills/agents are never listed)")
507
+ .description("Remove leftovers of earlier installs: user = parked upstream claude-mem, memory backups, upstream claude-mem hook entries in settings.json (free-mem plugin registered), the upstream claude-mem IDE rule files in ~/.agent/rules and ~/.agents/rules and the free-mem one in ~/.agent/rules (moved into the dated archive), upstream claude-mem MCP servers in other clients' configs (Antigravity/Gemini, Claude Desktop, Cursor, OpenCode; an archived copy of each file is made first), the thedotmack marketplace directory and its registrations, legacy /openspec commands, global @fission-ai/openspec, a global npm freeharness link; project = legacy /openspec commands, project copies of global agents/skills, duplicate hook entries. Fixed list; nothing else is touched (your own skills/agents are never listed). --hard also moves stale artifacts into ~/Archive/freeharness-<date>/ and deletes none")
433
508
  .option("--scope <scope>", "project | user (global ~/.claude)", "project")
434
509
  .option("--project <dir>", "project root (project scope only)", process.cwd())
435
510
  .option("--dry-run", "list what would be removed without removing anything", false)
436
- .option("--yes", "remove without interactive confirmation (backups included)", false)
511
+ .option("--yes", "skip every prompt, including the --hard move", false)
512
+ .option("--hard", "also move into ~/Archive/freeharness-<date>/: user = memory logs older than 14 days, all but the newest backup of each settings and plugin-registry file, a dead upstream claude-mem registry entry; project = SDD workspaces of archived changes, .freeharness/parked/, build junk under canonical/", false)
437
513
  .action(async (opts) => {
438
514
  const scope = parseScope(opts.scope);
439
515
  const projectRoot = path.resolve(opts.project);
440
- const plan = await planCleanup(scope, projectRoot, { canonicalDir: resolveCanonicalDir() });
516
+ // One clock read per run, so cleanup and hard moves share one dated archive.
517
+ const now = new Date();
518
+ const plan = await planCleanup(scope, projectRoot, { canonicalDir: resolveCanonicalDir(), archiveRoot: archiveRootFor(os.homedir(), now) });
519
+ const hard = opts.hard ? planHard(scope, projectRoot, { now }) : undefined;
441
520
  const tag = opts.dryRun ? "[dry-run] " : "";
442
- if (plan.items.length === 0) {
443
- console.log(`cleanup (${scope}): nothing to do.`);
444
- for (const n of plan.notes)
445
- console.log(` note: ${n}`);
521
+ if (plan.items.length === 0 && (hard?.items.length ?? 0) === 0) {
522
+ console.log(`cleanup (${scope}): nothing to do${hard ? " (hard list checked)" : ""}.`);
523
+ for (const n of [...plan.notes, ...(hard?.notes ?? [])])
524
+ console.log(` note: ${printable(n)}`);
446
525
  if (!opts.dryRun) {
447
526
  const pruned = pruneManifest(scope, projectRoot);
448
527
  if (pruned > 0)
@@ -450,40 +529,24 @@ program
450
529
  }
451
530
  return;
452
531
  }
453
- printCleanup(plan, tag);
532
+ printCleanup(plan, tag, hard);
454
533
  if (opts.dryRun) {
455
534
  console.log("\n[dry-run] nothing was removed.");
535
+ if (hard || plan.items.some((i) => i.archiveDest !== undefined))
536
+ console.log("[dry-run] nothing was moved.");
456
537
  return;
457
538
  }
458
- const removable = plan.items.filter((i) => !i.refused);
459
- const main = removable.filter((i) => i.group !== "backups");
460
- const backups = removable.filter((i) => i.group === "backups");
461
- let confirmMain = opts.yes;
462
- let confirmBackups = opts.yes;
463
- if (!opts.yes) {
464
- const prompt = new ReadlinePrompt();
465
- if (main.length > 0)
466
- confirmMain = await prompt.confirm(`\nRemove the ${main.length} item(s) above (backups excluded)?`);
467
- if (backups.length > 0) {
468
- const total = backups.reduce((n, b) => n + (b.bytes ?? 0), 0);
469
- confirmBackups = await prompt.confirm(`Also delete the ${backups.length} claude-mem backup dir(s) (${total >= 1048576 ? `${(total / 1048576).toFixed(1)} MB` : `${total} B`})? They cannot be recovered.`);
470
- }
471
- }
472
- if (!confirmMain && !confirmBackups) {
473
- console.log("Aborted; nothing was removed.");
539
+ const outcome = await runCleanupPass2({ plan, hard, yes: opts.yes, prompt: new ReadlinePrompt(), log: (line) => console.log(line) });
540
+ if (!outcome)
474
541
  return;
475
- }
476
- const done = applyCleanup(plan, (i) => (i.group === "backups" ? confirmBackups : confirmMain));
477
- for (const r of done.removed)
478
- console.log(` removed: ${r}`);
479
- for (const k of done.skipped)
480
- console.log(` kept: ${k}`);
481
- for (const w of done.warnings)
482
- console.log(` WARN ${w}`);
483
542
  const pruned = pruneManifest(scope, path.resolve(opts.project));
484
543
  if (pruned > 0)
485
544
  console.log(` manifest: dropped ${pruned} entr${pruned === 1 ? "y" : "ies"} that no longer exist`);
486
- console.log(`\nRemoved ${done.removed.length} item(s).`);
545
+ console.log(`\nRemoved ${outcome.done.removed.length} item(s).`);
546
+ const moved = outcome.done.moved.length + (outcome.archived?.moved.length ?? 0);
547
+ const archiveRoot = hard?.archiveRoot ?? plan.archiveRoot;
548
+ if (archiveRoot && (hard || moved > 0))
549
+ console.log(`Moved ${moved} item(s) to ${printable(archiveRoot)}.`);
487
550
  });
488
551
  program
489
552
  .command("doctor")
@@ -544,6 +607,8 @@ program
544
607
  fs.existsSync(path.join(base, "openspec", "config.yaml")) ? ok("openspec/ present") : warn("openspec/ missing (run freeharness bootstrap)");
545
608
  for (const l of openspecChangeLines(base))
546
609
  console.log(l);
610
+ for (const l of (await import("./cli/doctor.js")).staleTreeLines(base))
611
+ console.log(l);
547
612
  if (fs.existsSync(rulesFile) && hasPlaceholders(fs.readFileSync(rulesFile, "utf8"))) {
548
613
  warn("CLAUDE.md still has <placeholder> fields: run /fh-context in Claude Code");
549
614
  }
@@ -566,13 +631,25 @@ program
566
631
  raw.includes("freeharness-session-start") ? ok("FreeHarness SessionStart hook present") : warn("FreeHarness SessionStart hook not found");
567
632
  raw.includes("freeharness-memory-guard") ? ok("memory-guard hook present") : warn("memory-guard hook not found");
568
633
  raw.includes("freeharness-security-guidance") ? ok("security-guidance hook present") : warn("security-guidance hook not found");
569
- for (const h of ["freeharness-config-guard", "freeharness-repeat-tool", "freeharness-precompact"])
634
+ for (const h of ["freeharness-config-guard", "freeharness-repeat-tool", "freeharness-precompact", "freeharness-reload-context"])
570
635
  raw.includes(h) ? ok(`${h.replace("freeharness-", "")} hook present`) : warn(`${h.replace("freeharness-", "")} hook not found (run init --only rules)`);
571
636
  raw.includes("freeharness-converge") ? ok("converge hook present (armed per project by .freeharness/converge.json)") : warn("converge hook not found (run init --only sdd)");
572
637
  if (raw.includes("freeharness-cost-tracker")) {
573
638
  console.log(costLogLine(costLogPath()));
574
639
  for (const h of ["freeharness-output-pruner", "freeharness-context-monitor"])
575
640
  raw.includes(h) ? ok(`${h.replace("freeharness-", "")} hook present`) : warn(`${h.replace("freeharness-", "")} hook not found (run init --only cost)`);
641
+ // Without it the SDD task state stops moving between dispatches, silently. Checked per
642
+ // event because one surviving entry would satisfy a whole-file search; an unparseable
643
+ // file falls back to that search.
644
+ let sddMissing;
645
+ try {
646
+ const events = JSON.parse(raw).hooks ?? {};
647
+ sddMissing = ["SubagentStart", "SubagentStop"].filter((e) => !(JSON.stringify(events[e] ?? null) ?? "").includes("freeharness-sdd-context"));
648
+ }
649
+ catch {
650
+ sddMissing = raw.includes("freeharness-sdd-context") ? [] : ["SubagentStart", "SubagentStop"];
651
+ }
652
+ sddMissing.length === 0 ? ok("sdd-context hook present") : warn(`freeharness-sdd-context hook not found on ${sddMissing.join(" and ")} (run init --only cost)`);
576
653
  }
577
654
  else {
578
655
  warn("cost-tracker hook not found (the cost capability is part of the default set; run init --only cost)");
@@ -588,6 +665,8 @@ program
588
665
  console.log(l);
589
666
  for (const l of hookScanLines(settingsPath, os.homedir()))
590
667
  console.log(l);
668
+ for (const l of guardRuntimeLines(settingsPath))
669
+ console.log(l);
591
670
  }
592
671
  else {
593
672
  warn(`${settingsPath} not found (run init)`);
@@ -595,6 +674,8 @@ program
595
674
  // v4 P6: the active local-model profile (probe) and the MCP servers with tool counts.
596
675
  for (const l of await profileLines(settingsPath))
597
676
  console.log(l);
677
+ console.log(sessionModelLine(settingsPath));
678
+ console.log(autoMemoryLine(settingsPath, freeMemEnabled(claudeDir)));
598
679
  try {
599
680
  for (const l of mcpLines(os.homedir(), path.resolve(opts.project), mcpToolCounts(resolveCanonicalDir())))
600
681
  console.log(l);
@@ -615,8 +696,6 @@ program
615
696
  catch {
616
697
  /* canonical unresolved: reported above */
617
698
  }
618
- const safetyFile = scope === "user" ? path.join(os.homedir(), ".claude", "freeharness", "safety.md") : path.join(base, ".freeharness", "safety.md");
619
- fs.existsSync(safetyFile) ? ok("safety.md present") : warn("safety.md not found (run init)");
620
699
  for (const l of manifestLines(manifestPath(scope, base)))
621
700
  console.log(l);
622
701
  for (const l of depLines(probeDeps()))
@@ -637,7 +716,9 @@ program
637
716
  for (const i of left.items)
638
717
  warn(`leftover of an earlier install: ${describeItem(i)} — run freeharness cleanup --scope user`);
639
718
  if (left.items.length === 0)
640
- ok("no leftovers of earlier installs (parked claude-mem, backups, upstream claude-mem hooks, legacy /openspec commands, global @fission-ai/openspec, global npm freeharness)");
719
+ ok("no leftovers of earlier installs (parked upstream claude-mem, memory backups, upstream claude-mem hooks, legacy /openspec commands, global @fission-ai/openspec, global npm freeharness)");
720
+ for (const l of reportOnlyLauncherLines(os.homedir()))
721
+ console.log(l);
641
722
  if (opts.fix) {
642
723
  console.log("\n--fix: re-running install (claude-code) to repair missing pieces...");
643
724
  await applyPlan({ target: "claude-code", scope, project: opts.project, dryRun: false, yes: false });
@@ -669,34 +750,43 @@ memory
669
750
  });
670
751
  memory
671
752
  .command("backup")
672
- .description("Copy the claude-mem data dir (SQLite + Chroma) to a timestamped sibling; the original is untouched")
753
+ .description("Copy the memory data dir (the SQLite database and its files) to a timestamped sibling; the original is untouched")
673
754
  .action(() => {
674
755
  const dest = backupMemoryData();
675
756
  console.log(`OK backup written: ${dest}`);
676
757
  });
677
758
  memory
678
759
  .command("activate")
679
- .description("Make the FreeHarness build (free-mem) the only active memory plugin: stop the running worker, move foreign cache versions aside (never deleted), register ours")
760
+ .description("Make the FreeHarness build (free-mem) the only active memory plugin: stop the running worker, move foreign cache versions aside (never deleted), stage a newer vendored build, register ours, restart a worker that was running")
680
761
  .option("--dry-run", "preview without changing anything", false)
681
762
  .option("--yes", "apply without confirmation", false)
682
763
  .action(async (opts) => {
683
764
  try {
684
- const preview = await activateOurs(true);
765
+ const activateOpts = { pluginDir: vendoredPluginDir() };
766
+ const preview = await activateOurs(true, activateOpts);
685
767
  console.log(`${opts.dryRun ? "[dry-run] " : ""}activate plan:`);
686
768
  console.log(` stop running worker: ${preview.stoppedWorker ? "yes" : "none running"}`);
687
769
  for (const d of preview.disabled)
688
770
  console.log(` move aside: ${d}`);
689
771
  if (preview.disabled.length === 0)
690
772
  console.log(" move aside: nothing foreign in the cache");
773
+ for (const n of preview.stageNotes)
774
+ console.log(` stage plugin cache: ${n}`);
691
775
  console.log(" register: installed_plugins.json -> the FreeHarness build");
776
+ console.log(` restart worker: ${preview.restart.planned ? "yes" : `no (${preview.restart.reason})`}`);
692
777
  if (opts.dryRun)
693
778
  return;
694
779
  if (!opts.yes && !(await new ReadlinePrompt().confirm("\nApply? (run `freeharness memory backup` first if you have not)"))) {
695
780
  console.log("Aborted; nothing changed.");
696
781
  return;
697
782
  }
698
- await activateOurs(false);
699
- console.log("OK activated. Open a new Claude Code session: SessionStart starts the FreeHarness worker.");
783
+ const applied = await activateOurs(false, activateOpts);
784
+ for (const n of applied.stage?.staged ? applied.stage.notes : [])
785
+ console.log(`OK ${n}`);
786
+ printRestart(applied.restart);
787
+ console.log(applied.restart.planned && !applied.restart.error
788
+ ? "OK activated."
789
+ : "OK activated. Open a new Claude Code session: SessionStart starts the FreeHarness worker.");
700
790
  }
701
791
  catch (e) {
702
792
  console.error(e instanceof Error ? e.message : String(e));
@@ -718,11 +808,11 @@ function hasBothLocalFlags(argv) {
718
808
  }
719
809
  memory
720
810
  .command("settings")
721
- .description("Preview or apply the FreeHarness-recommended observer settings (model tiers, skip list, concurrency) in the claude-mem settings.json")
811
+ .description("Preview or apply the FreeHarness-recommended observer settings (model tiers, skip list, concurrency) in the memory settings.json")
722
812
  .option("--apply", "write the recommended settings (two-pass: without this, preview only)", false)
723
813
  .option("--yes", "apply without interactive confirmation", false)
724
- .option("--local <values...>", "switch to the local provider: CLAUDE_MEM_PROVIDER plus four CLAUDE_MEM_LOCAL_* keys, plus CLAUDE_MEM_SKIP_TOOLS narrowed so the local model observes Read, Glob and Grep again; two values, <base-url> then <model>")
725
- .option("--no-local", "switch back to the claude provider: CLAUDE_MEM_PROVIDER=claude and CLAUDE_MEM_SKIP_TOOLS restored to the recommended default")
814
+ .option("--local <values...>", "switch to the local provider: FREE_MEM_PROVIDER plus four FREE_MEM_LOCAL_* keys, plus FREE_MEM_SKIP_TOOLS narrowed so the local model observes Read, Glob and Grep again; two values, <base-url> then <model>")
815
+ .option("--no-local", "switch back to the claude provider: FREE_MEM_PROVIDER=claude and FREE_MEM_SKIP_TOOLS restored to the recommended default")
726
816
  .action(async (opts) => {
727
817
  try {
728
818
  // commander resolves --local and --no-local last-wins into one option value, so a
@@ -765,20 +855,48 @@ memory
765
855
  process.exit(1);
766
856
  }
767
857
  });
858
+ memory
859
+ .command("import")
860
+ .description("Move Claude Code's own memory files (auto memory and agent memory) into free-mem once: preview, store each file through the worker without the observer model (private tags stripped, text fenced as untrusted data), then move it into ~/Archive/freeharness-<date>/memory-import/ (never deleted; a failed file stays in place)")
861
+ .option("--project <dir>", "project whose .claude/agent-memory and .claude/agent-memory-local are collected; repeatable (default: the current directory)", (v, prev) => [...prev, v], [])
862
+ .option("--yes", "apply without interactive confirmation", false)
863
+ .option("--dry-run", "preview only: list the files and write nothing", false)
864
+ .action(async (opts) => {
865
+ try {
866
+ const { importMemoryFiles } = await import("./cli/memory-import.js");
867
+ const outcome = await importMemoryFiles({
868
+ projects: opts.project.length > 0 ? opts.project.map((p) => path.resolve(p)) : [process.cwd()],
869
+ now: new Date(),
870
+ dryRun: opts.dryRun,
871
+ confirm: () => (opts.yes ? Promise.resolve(true) : new ReadlinePrompt().confirm("\nImport these files into free-mem and move them into the archive?")),
872
+ log: (line) => console.log(line),
873
+ error: (line) => console.error(line),
874
+ });
875
+ if (outcome.exitCode !== 0)
876
+ process.exit(outcome.exitCode);
877
+ }
878
+ catch (e) {
879
+ console.error(e instanceof Error ? e.message : String(e));
880
+ process.exit(1);
881
+ }
882
+ });
768
883
  const cost = program.command("cost").description("Token/cost usage recorded by the cost-tracker hook (cost capability)");
769
884
  cost
770
885
  .command("report")
771
886
  .description("Aggregate ~/.claude/freeharness/cost/usage.jsonl per project and per model (last snapshot per session); --sinks ranks token sinks (sessions, tools, pruned bytes) per project or per task class")
772
- .option("--days <n>", "only sessions from the last N days", (v) => parseInt(v, 10))
887
+ .option("--days <n>", "only sessions from the last N days (also bounds --changes)", (v) => parseInt(v, 10))
773
888
  .option("--project <name>", "only this project (basename of the session cwd)")
774
889
  .option("--sinks", "rank token sinks: sessions by tokens with tool-output share and pruned bytes, tools by output; untagged sessions counted", false)
775
890
  .option("--by <group>", "with --sinks: group sessions by `task-class` (default: per session)")
776
891
  .option("--top <n>", "with --sinks: rows per project", (v) => parseInt(v, 10), 10)
777
892
  .option("--changes", "per change: session count, median and total tokens per sink, USD and tier mix, from the project telemetry file", false)
778
- .option("--dir <path>", "with --changes: the project root whose .freeharness/telemetry.jsonl to read (default: the current working directory)")
893
+ .option("--dir <path>", "with --changes, --routing or --retro: the project root whose .freeharness/telemetry.jsonl or .freeharness/sdd/ ledgers to read (default: the current working directory)")
779
894
  .option("--agents", "per subagent name: model, dispatch count and tokens, from the cost log's by_agent maps", false)
895
+ .option("--routing", "per implementer configuration (model + effort of a task's first impl dispatch): dispatch counts, escalations and USD per accepted task, from the SDD dispatch ledgers under .freeharness/sdd/ joined with tasks.md acceptance", false)
896
+ .option("--retro", "with --change: print the change's retrospective line (dispatches, cache reads, ceilings, stops, fix rounds, USD, lead sessions, Claude Code versions) from its ledger and the project telemetry", false)
897
+ .option("--change <id>", "with --routing: limit to this change; with --retro: the change to measure")
780
898
  .option("--json", "print the aggregated report as JSON", false)
781
- .action((opts) => {
899
+ .action(async (opts) => {
782
900
  if (opts.days !== undefined && (!Number.isFinite(opts.days) || opts.days < 0)) {
783
901
  console.error("--days must be a non-negative integer");
784
902
  process.exit(1);
@@ -787,6 +905,28 @@ cost
787
905
  console.error("--by accepts only task-class");
788
906
  process.exit(1);
789
907
  }
908
+ if (opts.retro) {
909
+ for (const [set, flag] of [[opts.change === undefined, "--retro requires --change <id>"], [opts.routing, "--retro and --routing are separate reports; pass one"], [opts.changes, "--retro and --changes are separate reports; pass one"]]) {
910
+ if (set) {
911
+ console.error(flag);
912
+ process.exit(1);
913
+ }
914
+ }
915
+ const result = await retroLine(path.resolve(opts.dir ?? process.cwd()), opts.change);
916
+ if (opts.json && result.ledger)
917
+ console.log(escapeJsonControls(JSON.stringify(retroJson(result), null, 2)));
918
+ else
919
+ console.log(result.line);
920
+ return;
921
+ }
922
+ if (opts.change !== undefined && !opts.routing) {
923
+ console.error("--change requires --routing or --retro");
924
+ process.exit(1);
925
+ }
926
+ if (opts.routing && opts.changes) {
927
+ console.error("--routing and --changes are separate reports; pass one");
928
+ process.exit(1);
929
+ }
790
930
  if (opts.changes) {
791
931
  // --changes reads a project directory (--dir, default cwd): unlike --project, which
792
932
  // filters the other reports by the session's project *name*, this is a filesystem root.
@@ -796,11 +936,28 @@ cost
796
936
  return;
797
937
  }
798
938
  const records = readTelemetry(file);
799
- const rows = changes(records);
939
+ const result = changes(records, undefined, undefined, undefined, { days: opts.days });
940
+ const skippedTs = opts.days === undefined ? undefined : result.skippedTs;
800
941
  if (opts.json)
801
- console.log(JSON.stringify({ file, skipped: records.skipped, rows }, null, 2));
942
+ console.log(JSON.stringify({ file, skipped: records.skipped, ...(skippedTs === undefined ? {} : { skipped_ts: skippedTs }), rows: result.rows, pricing: result.pricing, excluded: result.excluded, session_classes: result.classes, untagged_share: untaggedShare(result.classes) }, null, 2));
802
943
  else
803
- for (const l of formatChanges(rows, file, records.skipped))
944
+ for (const l of formatChanges(result.rows, file, records.skipped, result.pricing, result.excluded, result.classes, skippedTs))
945
+ console.log(l);
946
+ return;
947
+ }
948
+ if (opts.routing) {
949
+ const priceFile = costPricesPath();
950
+ const root = path.resolve(opts.dir ?? process.cwd());
951
+ const ledgerRoot = path.join(root, ".freeharness", "sdd");
952
+ if (!opts.json && !fs.existsSync(ledgerRoot)) {
953
+ console.log(`no dispatch ledger directory at ${ledgerRoot}`);
954
+ return;
955
+ }
956
+ const result = routingReportForProject(root, opts.change, loadPriceTable(priceFile), priceFile);
957
+ if (opts.json)
958
+ console.log(escapeJsonControls(JSON.stringify(routingReportJson(result), null, 2)));
959
+ else
960
+ for (const l of formatRoutingReport(result))
804
961
  console.log(l);
805
962
  return;
806
963
  }
@@ -808,7 +965,7 @@ cost
808
965
  const log = costLogPath();
809
966
  const priceFile = costPricesPath();
810
967
  const prices = loadPriceTable(priceFile);
811
- const rows = agents(readUsage(log), prices);
968
+ const { rows, pricing } = agents(readUsage(log), prices, priceFile);
812
969
  if (rows.length === 0) {
813
970
  console.log(`no by_agent data in ${log}`);
814
971
  return;
@@ -816,14 +973,16 @@ cost
816
973
  if (opts.json)
817
974
  console.log(JSON.stringify(rows, null, 2));
818
975
  else
819
- for (const l of formatAgents(rows, { file: priceFile, loaded: Object.keys(prices).length > 0 }))
976
+ for (const l of formatAgents(rows, pricing))
820
977
  console.log(l);
821
978
  return;
822
979
  }
823
980
  const log = costLogPath();
824
981
  const records = readUsage(log);
982
+ const priceFile = costPricesPath();
983
+ const prices = loadPriceTable(priceFile);
825
984
  if (opts.sinks) {
826
- const report = sinks(records, { days: opts.days, project: opts.project, byTaskClass: opts.by === "task-class", top: opts.top }, log);
985
+ const report = sinks(records, { days: opts.days, project: opts.project, byTaskClass: opts.by === "task-class", top: opts.top }, log, prices, priceFile);
827
986
  if (opts.json)
828
987
  console.log(JSON.stringify(report, null, 2));
829
988
  else
@@ -831,7 +990,7 @@ cost
831
990
  console.log(l);
832
991
  return;
833
992
  }
834
- const report = aggregate(records, { days: opts.days, project: opts.project }, log);
993
+ const report = aggregate(records, { days: opts.days, project: opts.project }, log, prices, priceFile);
835
994
  if (opts.json)
836
995
  console.log(JSON.stringify(report, null, 2));
837
996
  else
@@ -861,6 +1020,26 @@ cost
861
1020
  console.log(`over budget by ${est.tokens - est.budget} tokens: trim the largest contributors (rules first: every line rides on every request).`);
862
1021
  console.log(formatInjectionLine(est.injection));
863
1022
  });
1023
+ cost
1024
+ .command("quota")
1025
+ .description("Fit the subscription window percentages the quota tap recorded (~/.claude/freeharness/quota-readings.jsonl) to the transcript tokens of each rising interval: percent per million cache-read and fresh tokens per model family, once a window has three intervals per weight")
1026
+ .option("--days <n>", `only readings from the last N days (default ${DEFAULT_QUOTA_DAYS})`)
1027
+ .option("--json", "print the intervals, their readings and the fit as JSON", false)
1028
+ .option("--write", "store the calibrated windows' weights in ~/.claude/freeharness/quota-calibration.json (nothing else is written)", false)
1029
+ .action((opts) => {
1030
+ if (opts.days !== undefined && !/^[1-9][0-9]{0,5}$/.test(opts.days)) {
1031
+ console.error("--days must be a positive integer");
1032
+ process.exit(1);
1033
+ }
1034
+ try {
1035
+ for (const l of costQuota({ days: opts.days === undefined ? undefined : Number(opts.days), json: opts.json, write: opts.write }))
1036
+ console.log(l);
1037
+ }
1038
+ catch (e) {
1039
+ console.error(e instanceof Error ? e.message : String(e));
1040
+ process.exit(1);
1041
+ }
1042
+ });
864
1043
  cost
865
1044
  .command("prices")
866
1045
  .description("Print the price table path (USD per MTok per model; edit it to your contract)")
@@ -22,11 +22,38 @@ export interface HookEntry {
22
22
  command: string;
23
23
  async?: boolean;
24
24
  timeout?: number;
25
+ onFailure?: "block" | "continue";
25
26
  }[];
26
27
  }
27
- /** Additive, deduped by command; never removes existing entries, so claude-mem stays intact. */
28
- export declare function mergeClaudeHook(settingsPath: string, eventName: string, entry: HookEntry, dryRun: boolean): {
29
- added: boolean;
28
+ /** `replacedCommand` is the first owned command that differed from the new one; absent when only identical duplicates collapsed or only `onFailure` changed. */
29
+ export type HookMergeOutcome = {
30
+ outcome: "added" | "present";
31
+ } | {
32
+ outcome: "replaced";
33
+ replacedCommand?: string;
34
+ onFailureChanged?: true;
35
+ };
36
+ export type HookMergeResult = HookMergeOutcome & {
37
+ /** The owned script also runs in an entry FreeHarness leaves as is: shared with, or chained to, a foreign command. */
38
+ hasSharedCopy: boolean;
39
+ };
40
+ /**
41
+ * Merges one hook entry into a settings event, deduped by command.
42
+ *
43
+ * Without `ownerKey` the merge is additive. With it, entries whose every command
44
+ * runs the owned script with flags only are FreeHarness's own: the first is replaced in
45
+ * place when its command or `onFailure` differs and the rest are dropped, so a flag change
46
+ * never leaves a stale copy running beside the new one and an `onFailure` the shipped entry
47
+ * lacks is dropped. Every other entry, claude-mem's included, stays as is, even one that mixes
48
+ * the owned script with a foreign command or chains one to it; that one is only reported.
49
+ * @param entry Entry to merge; its first command is the dedupe key.
50
+ * @param dryRun Computes the result without writing.
51
+ * @param ownerKey Command prefix that runs the owned script, without flags.
52
+ * @returns What the merge appended, replaced or left running in a shared entry; `collapsed` when
53
+ * several owned copies became one, so the note names a collapse rather than a reset.
54
+ */
55
+ export declare function mergeClaudeHook(settingsPath: string, eventName: string, entry: HookEntry, dryRun: boolean, ownerKey?: string): HookMergeResult & {
56
+ collapsed?: true;
30
57
  };
31
58
  /** A key is written only when absent, so a value the user (or another tool) set is never overwritten; `$comment` keys are never copied. */
32
59
  export declare function mergeSettingsKeys(settingsPath: string, entries: Record<string, unknown>, dryRun: boolean): {