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
@@ -2,30 +2,48 @@
2
2
  // discovered by heuristics. User scope: parked upstream claude-mem + data backups, upstream
3
3
  // claude-mem hooks in settings.json (once the plugin is registered), legacy /openspec commands,
4
4
  // the global @fission-ai/openspec package, a global npm `freeharness` link, retired names
5
- // (RETIRED) still on disk after a rename or a fold when the manifest records them. Project
5
+ // (RETIRED in install-facts.ts) still on disk after a rename or a fold when the manifest records them,
6
+ // retired hook scripts and the settings entries that run them. Project
6
7
  // scope: legacy /openspec commands, project copies of canonical agents/skills, duplicate
7
8
  // FreeHarness hook entries. Never listed: the schema override, non-canonical skills/agents,
8
9
  // `openspec-*` skills, `.claude/commands/opsx/` (the user's or ours).
9
10
  //
10
11
  // Two-pass: `planCleanup` computes the list untouched; `applyCleanup` removes the confirmed items.
12
+ // Items under the Claude Code plugin cache are never deleted: they move into the dated archive
13
+ // through the hard-cleanup mover.
11
14
  import fs from "node:fs";
12
15
  import os from "node:os";
13
16
  import path from "node:path";
14
17
  import { spawnSync } from "node:child_process";
15
- import { PLUGIN_KEY, PLUGIN_NAME, UPSTREAM_MARKETPLACE_OWNER, UPSTREAM_PLUGIN_KEY, UPSTREAM_PLUGIN_NAME } from "../adapters/memory/vendored.js";
18
+ import { PLUGIN_KEY, UPSTREAM_MARKETPLACE_OWNER, UPSTREAM_PLUGIN_KEY, UPSTREAM_PLUGIN_NAME } from "../adapters/memory/vendored.js";
16
19
  import { manifestPath, readManifest } from "../infra/manifest.js";
17
- import { configDir, dataDir, diagnoseMemory } from "../adapters/memory/doctor.js";
18
- import { canonicalNames, memoryPluginInstalled } from "./doctor.js";
20
+ import { configDir, diagnoseMemory, foreignWorkerStep } from "../adapters/memory/doctor.js";
21
+ import { FREE_DIR_NAME, resolveMemoryPaths } from "../adapters/memory/paths.js";
22
+ import { canonicalNames, memoryPluginInstalled, RETIRED, retiredFindings, runsRetiredHook } from "./install-facts.js";
19
23
  import { isClaudeMemHook, isOurHook, pruneHooksMatching } from "./uninstall.js";
24
+ import { archiveMove, archiveRootFor, isOrHolds, moveWarning, pluginCacheArchive, pluginCacheOverlap, pluginCacheRefusal } from "./hard-cleanup.js";
25
+ import { removeJsonKey } from "./json-key-edit.js";
26
+ import { configCopyArchive, launcherStillMatches, launcherText, marketplaceRefusal, planUpstreamLaunchers } from "./upstream-launchers.js";
20
27
  /** The vendored `openspec` bin replaces it. */
21
28
  export const LEGACY_OPENSPEC_PACKAGE = "@fission-ai/openspec";
22
29
  /** A global link of it shadows the ~/.local/bin symlink. */
23
30
  export const CLI_PACKAGE = "freeharness";
24
- /** Retired by a rename or a fold, bare names under `~/.claude`. */
25
- export const RETIRED = {
26
- skills: ["local-code-intel"],
27
- agents: ["mle-reviewer", "mle-evaluator"],
28
- };
31
+ /** An entry mixing a retired script with another command is not one we emitted, so it stays. */
32
+ function isRetiredHook(e) {
33
+ const commands = (e.hooks ?? []).map((h) => h.command ?? "");
34
+ return commands.length > 0 && commands.every(runsRetiredHook);
35
+ }
36
+ function hookPredicate(owner) {
37
+ if (owner === UPSTREAM_PLUGIN_NAME)
38
+ return isClaudeMemHook;
39
+ return owner === "retired" ? isRetiredHook : isOurHook;
40
+ }
41
+ /** Upstream IDE rule files under the home dir, relative; fixed list. */
42
+ const IDE_RULE_FILES = [
43
+ path.join(".agent", "rules", `${UPSTREAM_PLUGIN_NAME}-context.md`),
44
+ path.join(".agents", "rules", `${UPSTREAM_PLUGIN_NAME}-context.md`),
45
+ path.join(".agent", "rules", "free-mem-context.md"),
46
+ ];
29
47
  /** Sums the size (bytes) of every file under a directory. */
30
48
  export function dirBytes(dir) {
31
49
  let total = 0;
@@ -93,18 +111,26 @@ export function globalNpmFreeharnessBin() {
93
111
  }
94
112
  return bin;
95
113
  }
96
- /** Claude-mem data backups written by `freeharness memory backup` (`<dataDir>-backup-<stamp>`), oldest first. */
97
- export function memoryBackupDirs() {
98
- const data = dataDir();
99
- const parent = path.dirname(data);
100
- const prefix = `${path.basename(data)}-backup-`;
101
- if (!fs.existsSync(parent))
102
- return [];
103
- return fs
104
- .readdirSync(parent, { withFileTypes: true })
105
- .filter((e) => e.isDirectory() && e.name.startsWith(prefix))
106
- .map((e) => path.join(parent, e.name))
107
- .sort();
114
+ /**
115
+ * Memory data backups written by `freeharness memory backup` (`<dataDir>-backup-<stamp>`), sorted.
116
+ * Backups of the default directory count even while a redirected directory is in use.
117
+ */
118
+ export function memoryBackupDirs(env = process.env, home = os.homedir()) {
119
+ const live = resolveMemoryPaths(env, home).dataDir;
120
+ const dirs = [live, path.join(home, FREE_DIR_NAME)];
121
+ const found = new Set();
122
+ for (const data of dirs) {
123
+ const parent = path.dirname(data);
124
+ const prefix = `${path.basename(data)}-backup-`;
125
+ if (!fs.existsSync(parent))
126
+ continue;
127
+ for (const e of fs.readdirSync(parent, { withFileTypes: true })) {
128
+ const candidate = path.join(parent, e.name);
129
+ if (e.isDirectory() && e.name.startsWith(prefix) && !isOrHolds(candidate, live))
130
+ found.add(candidate);
131
+ }
132
+ }
133
+ return [...found].sort();
108
134
  }
109
135
  /** Plugin keys registered in Claude Code's installed_plugins.json (e.g. "name@marketplace"); empty when absent or unreadable. */
110
136
  function installedPluginKeys(cfg) {
@@ -128,29 +154,28 @@ export function pruneManifest(scope, projectRoot) {
128
154
  fs.writeFileSync(file, JSON.stringify({ ...m, files: kept, updatedAt: new Date().toISOString() }, null, 2) + "\n");
129
155
  return dropped;
130
156
  }
131
- /** Retired skill/agent names still present under a `.claude` dir: `update`'s additive merge never deletes them; an absent path is skipped. */
132
- export function retiredFindings(claudeDir, manifest) {
133
- const groups = [
134
- { kind: "skill", dir: "skills", names: RETIRED.skills },
135
- { kind: "agent", dir: "agents", names: RETIRED.agents },
136
- ];
137
- const found = [];
138
- for (const { kind, dir, names } of groups) {
139
- for (const name of names) {
140
- const fileName = kind === "agent" ? `${name}.md` : name;
141
- const p = path.join(claudeDir, dir, fileName);
142
- if (!fs.existsSync(p))
143
- continue;
144
- const under = `${p}${path.sep}`;
145
- const recorded = manifest?.files.some((f) => f.path === p || f.path.startsWith(under)) ?? false;
146
- found.push({ kind, name, path: p, recorded });
147
- }
148
- }
149
- return found;
157
+ /** Routes a plugin-cache item into the dated archive; an earlier refusal wins over the containment one. */
158
+ function toArchive(item, cfg, archiveRoot) {
159
+ const archive = pluginCacheArchive(item.target, cfg, archiveRoot);
160
+ return { ...item, archiveDest: archive.destination, refused: item.refused ?? archive.refused };
161
+ }
162
+ const UPSTREAM_ENABLED_REFUSAL = `the ${UPSTREAM_PLUGIN_KEY} key is still enabled — disable it first: \`claude plugin disable ${UPSTREAM_PLUGIN_KEY}\``;
163
+ /**
164
+ * Refusal while the build is not the sole active plugin. `memory activate` cannot clear a foreign
165
+ * worker beside our build, so that case names the doctor FAIL line's steps, as the mixed VERDICT does.
166
+ */
167
+ function notOursRefusal(d) {
168
+ const verdictLine = d.lines.find((l) => l.includes("VERDICT")) ?? `verdict ${d.verdict}`;
169
+ const head = `the FreeHarness build is not the only active memory plugin yet (${verdictLine.replace(/^(OK|WARN)\s+/, "")})`;
170
+ const step = d.verdict === "mixed" ? foreignWorkerStep(d.worker) : null;
171
+ if (step === null)
172
+ return `${head}; run \`freeharness memory activate\` first`;
173
+ return `${head}; stop the foreign worker first (${step})`;
150
174
  }
151
175
  /** Computes the user-scope cleanup list; nothing is touched. */
152
176
  async function planUser(deps) {
153
- const plan = { scope: "user", items: [], notes: [] };
177
+ const archiveRoot = deps.archiveRoot ?? archiveRootFor(os.homedir(), new Date());
178
+ const plan = { scope: "user", items: [], notes: [], archiveRoot };
154
179
  const cfg = configDir();
155
180
  // `memory activate` parks foreign versions under the upstream root (doctor.ts's
156
181
  // disabledRoot): an upstream install or a pre-rename FreeHarness build, never ours.
@@ -158,11 +183,9 @@ async function planUser(deps) {
158
183
  if (fs.existsSync(disabled)) {
159
184
  const d = deps.memory ?? (await diagnoseMemory());
160
185
  const item = { kind: "dir", target: disabled, reason: "upstream claude-mem parked by `memory activate`", group: "memory", bytes: dirBytes(disabled) };
161
- if (d.verdict !== "ours") {
162
- const verdictLine = d.lines.find((l) => l.includes("VERDICT")) ?? `verdict ${d.verdict}`;
163
- item.refused = `the FreeHarness build is not the only active memory plugin yet (${verdictLine.replace(/^(OK|WARN)\s+/, "")}); run \`freeharness memory activate\` first`;
164
- }
165
- plan.items.push(item);
186
+ if (d.verdict !== "ours")
187
+ item.refused = notOursRefusal(d);
188
+ plan.items.push(toArchive(item, cfg, archiveRoot));
166
189
  }
167
190
  // Superseded FreeHarness builds: our own cache versions (.install-version present) that are
168
191
  // not the registered installPath, left behind by a version bump. A legacy-root build that
@@ -171,7 +194,7 @@ async function planUser(deps) {
171
194
  const d = deps.memory ?? (await diagnoseMemory());
172
195
  if (d.activeInstallPath) {
173
196
  for (const c of d.cache.filter((c) => c.ours && !c.legacy && path.resolve(c.dir) !== path.resolve(d.activeInstallPath))) {
174
- plan.items.push({ kind: "dir", target: c.dir, reason: `superseded FreeHarness memory build ${c.version} (registered: ${d.activeVersion ?? "?"})`, group: "memory", bytes: dirBytes(c.dir) });
197
+ plan.items.push(toArchive({ kind: "dir", target: c.dir, reason: `superseded FreeHarness memory build ${c.version} (registered: ${d.activeVersion ?? "?"})`, group: "memory", bytes: dirBytes(c.dir) }, cfg, archiveRoot));
175
198
  }
176
199
  }
177
200
  }
@@ -184,7 +207,21 @@ async function planUser(deps) {
184
207
  if (fs.existsSync(userSettings) && memoryPluginInstalled(cfg) && pruneHooksMatching(userSettings, isClaudeMemHook, true) > 0) {
185
208
  plan.items.push({ kind: "hooks", target: userSettings, reason: `upstream claude-mem hook entries (the ${PLUGIN_KEY} plugin ships its own hooks.json; these run every hook twice)`, group: "memory", hookOwner: UPSTREAM_PLUGIN_NAME });
186
209
  }
187
- // Leftovers of a `registerHooks` migration: the pre-rename cache dir (gated on its own
210
+ // Rule stubs the upstream Gemini/Antigravity installer wrote into IDE rule dirs; no engine
211
+ // updates them. Fixed list, no glob; a directory at the path is not ours to move.
212
+ for (const rel of IDE_RULE_FILES) {
213
+ const file = path.join(os.homedir(), rel);
214
+ try {
215
+ const st = fs.lstatSync(file);
216
+ if (!st.isFile() && !st.isSymbolicLink())
217
+ continue;
218
+ }
219
+ catch {
220
+ continue;
221
+ }
222
+ plan.items.push({ kind: "file", target: file, reason: "upstream IDE rule file (no engine updates it)", group: "memory", ideRule: true, archiveDest: path.join(archiveRoot, "ide-rules", rel) });
223
+ }
224
+ // Leftovers of the old key: the pre-rename cache dir (gated on its own
188
225
  // existence, not the key's) and the disabled key (listed only while present). Both stay
189
226
  // refused while the old key is enabled, or (same VERDICT check as above) while the build is
190
227
  // not verified as sole active plugin — installed_plugins.json alone can register the
@@ -202,18 +239,22 @@ async function planUser(deps) {
202
239
  }
203
240
  let refused;
204
241
  if (oldKeyValue === true) {
205
- refused = `the ${UPSTREAM_PLUGIN_KEY} key is still enabled — migrate first: \`freeharness init --scope user --only memory\``;
242
+ refused = UPSTREAM_ENABLED_REFUSAL;
206
243
  }
207
244
  else if (d.verdict !== "ours") {
208
- const verdictLine = d.lines.find((l) => l.includes("VERDICT")) ?? `verdict ${d.verdict}`;
209
- refused = `the FreeHarness build is not the only active memory plugin yet (${verdictLine.replace(/^(OK|WARN)\s+/, "")}); run \`freeharness memory activate\` first`;
245
+ refused = notOursRefusal(d);
210
246
  }
211
247
  if (fs.existsSync(oldCacheDir)) {
212
- plan.items.push({ kind: "dir", target: oldCacheDir, reason: `previous registration, migrated to ${PLUGIN_NAME}`, group: "memory", bytes: dirBytes(oldCacheDir), refused });
248
+ plan.items.push(toArchive({ kind: "dir", target: oldCacheDir, reason: "previous registration, left in the plugin cache", group: "memory", bytes: dirBytes(oldCacheDir), refused }, cfg, archiveRoot));
213
249
  }
214
250
  if (oldKeyValue !== undefined) {
215
- plan.items.push({ kind: "settings-key", target: userSettings, reason: `disabled ${UPSTREAM_PLUGIN_KEY} key left in enabledPlugins after migrating to ${PLUGIN_KEY}`, group: "memory", settingsKey: UPSTREAM_PLUGIN_KEY, refused });
251
+ const copy = configCopyArchive(userSettings, os.homedir(), archiveRoot);
252
+ plan.items.push({ kind: "json-key", target: userSettings, reason: `disabled ${UPSTREAM_PLUGIN_KEY} key left in enabledPlugins`, group: "memory", keyPath: ["enabledPlugins", UPSTREAM_PLUGIN_KEY], archiveDest: copy.destination, refused: refused ?? copy.refused });
216
253
  }
254
+ // An enabled upstream plugin still installs from its marketplace; other clients' launchers are not that plugin.
255
+ const launchers = planUpstreamLaunchers({ home: os.homedir(), cfg, archiveRoot, marketplaceRefusal: oldKeyValue === true ? UPSTREAM_ENABLED_REFUSAL : undefined });
256
+ plan.items.push(...launchers.items);
257
+ plan.notes.push(...launchers.notes);
217
258
  }
218
259
  const legacy = path.join(cfg, "commands", "openspec");
219
260
  if (fs.existsSync(legacy)) {
@@ -250,20 +291,30 @@ async function planUser(deps) {
250
291
  plan.items.push({ kind: "npm-global", target: CLI_PACKAGE, reason: `npm -g ${CLI_PACKAGE} (old npm link; shadows ~/.local/bin)${linkedBin ? ` at ${linkedBin}` : ""}`, group: "npm", unlink: true });
251
292
  }
252
293
  }
253
- // Retired names: listed only when the manifest records the path, so a same-named
254
- // user-owned skill/agent stays untouched (doctor reports that case as INFO, never here).
294
+ // Retired names: a skill, agent or file is listed only when the manifest records the path,
295
+ // so a same-named user-owned one stays untouched (doctor reports that case as INFO, never here).
296
+ // A hook script lives in FreeHarness's own hooks dir, so its presence is enough.
255
297
  const manifest = readManifest(manifestPath("user", ""));
298
+ const retiredReason = {
299
+ hook: (name) => `retired hook ${name} (removed from FreeHarness; update never deletes the installed copy)`,
300
+ file: () => "retired rules file (FreeHarness no longer emits it; update never deletes the installed copy)",
301
+ skill: () => "retired skill (renamed or folded elsewhere; kept on disk by update's additive manifest merge)",
302
+ agent: () => "retired agent (renamed or folded elsewhere; kept on disk by update's additive manifest merge)",
303
+ };
256
304
  for (const f of retiredFindings(path.join(os.homedir(), ".claude"), manifest)) {
257
- if (!f.recorded)
305
+ if (!f.recorded && f.kind !== "hook")
258
306
  continue;
259
307
  plan.items.push({
260
308
  kind: f.kind === "skill" ? "dir" : "file",
261
309
  target: f.path,
262
- reason: `retired ${f.kind} (renamed or folded elsewhere; kept on disk by update's additive manifest merge)`,
310
+ reason: retiredReason[f.kind](f.name),
263
311
  group: "retired",
264
312
  bytes: f.kind === "skill" ? dirBytes(f.path) : undefined,
265
313
  });
266
314
  }
315
+ if (fs.existsSync(userSettings) && pruneHooksMatching(userSettings, isRetiredHook, true) > 0) {
316
+ plan.items.push({ kind: "hooks", target: userSettings, reason: `settings hook entries running a retired hook script (${RETIRED.hooks.join(", ")})`, group: "retired", hookOwner: "retired" });
317
+ }
267
318
  plan.notes.push(`kept: ${path.join(os.homedir(), ".local", "share", "openspec", "schemas", "spec-driven")} (the FreeHarness schema override)`);
268
319
  return plan;
269
320
  }
@@ -311,7 +362,7 @@ function planProject(projectRoot, deps) {
311
362
  }
312
363
  const oldSafety = path.join(fhDir, "safety.md");
313
364
  if (fs.existsSync(oldSafety))
314
- plan.items.push({ kind: "file", target: oldSafety, reason: "old project-scope safety.md (global since v0.3.0)", group: "duplicates" });
365
+ plan.items.push({ kind: "file", target: oldSafety, reason: "old project-scope safety.md (FreeHarness emits no safety.md)", group: "duplicates" });
315
366
  plan.notes.push(`kept: ${path.join(claude, "commands", "opsx")} and ${path.join(claude, "skills", "openspec-*")} (ours)`);
316
367
  return plan;
317
368
  }
@@ -319,9 +370,61 @@ function planProject(projectRoot, deps) {
319
370
  export async function planCleanup(scope, projectRoot, deps = {}) {
320
371
  return scope === "user" ? planUser(deps) : planProject(projectRoot, deps);
321
372
  }
373
+ function inInbox(dest, inbox) {
374
+ const rel = path.relative(inbox, dest);
375
+ return !(rel === "" || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel));
376
+ }
377
+ /**
378
+ * The destination stays in the `ide-rules` inbox so no plan item lands over other archive content;
379
+ * a source that became a directory since pass 1 is no longer the stub it listed, and moving it would take what the user put there.
380
+ */
381
+ function ideRuleRefusal(source, dest, archiveRoot) {
382
+ if (archiveRoot === undefined)
383
+ return "no archive root in the plan";
384
+ const inbox = path.join(archiveRoot, "ide-rules");
385
+ if (!inInbox(dest, inbox))
386
+ return `destination ${dest} is outside ${inbox}`;
387
+ try {
388
+ if (fs.lstatSync(source).isDirectory())
389
+ return "is a directory now";
390
+ }
391
+ catch {
392
+ // gone: the move reports it
393
+ }
394
+ return undefined;
395
+ }
396
+ /** A pre-edit copy outside the `configs` inbox could land over other archive content, so pass 2 refuses it. */
397
+ function jsonKeyRefusal(item, archiveRoot) {
398
+ if (archiveRoot === undefined)
399
+ return "no archive root in the plan";
400
+ if (!item.keyPath?.length || item.archiveDest === undefined)
401
+ return "no key path or copy destination in the plan";
402
+ const inbox = path.join(archiveRoot, "configs");
403
+ return inInbox(item.archiveDest, inbox) ? undefined : `destination ${item.archiveDest} is outside ${inbox}`;
404
+ }
405
+ /** Moving rather than deleting keeps a wrong match recoverable; each kind has its own pass-2 check, as what makes its move unsafe differs. */
406
+ function archiveItem(item, dest, cfg, archiveRoot, out) {
407
+ const refused = item.ideRule
408
+ ? ideRuleRefusal(item.target, dest, archiveRoot)
409
+ : item.launcher === "marketplace"
410
+ ? marketplaceRefusal(item.target, dest, archiveRoot)
411
+ : pluginCacheRefusal(item.target, dest, cfg, archiveRoot);
412
+ if (refused) {
413
+ out.skipped.push(item.target);
414
+ out.warnings.push(`${item.target}: refused (${refused})`);
415
+ return;
416
+ }
417
+ try {
418
+ out.moved.push(`${item.target} -> ${archiveMove(item.target, dest)}`);
419
+ }
420
+ catch (e) {
421
+ out.warnings.push(moveWarning(item.target, e));
422
+ }
423
+ }
322
424
  /** Removes the confirmed items of a plan (`confirmed` defaults to all); refused items are always skipped. */
323
425
  export function applyCleanup(plan, confirmed = () => true) {
324
- const out = { removed: [], skipped: [], warnings: [] };
426
+ const out = { removed: [], moved: [], skipped: [], warnings: [] };
427
+ const cfg = configDir();
325
428
  for (const item of plan.items) {
326
429
  if (item.refused || !confirmed(item)) {
327
430
  out.skipped.push(item.target);
@@ -329,14 +432,26 @@ export function applyCleanup(plan, confirmed = () => true) {
329
432
  }
330
433
  switch (item.kind) {
331
434
  case "dir":
332
- case "file":
435
+ case "file": {
436
+ if (item.archiveDest !== undefined) {
437
+ archiveItem(item, item.archiveDest, cfg, plan.archiveRoot, out);
438
+ break;
439
+ }
440
+ // Nothing in, at or above the plugin cache is ever deleted; a link to it is unlinked only.
441
+ const cached = pluginCacheOverlap(item.target, cfg);
442
+ if (cached) {
443
+ out.skipped.push(item.target);
444
+ out.warnings.push(`${item.target}: refused (${cached})`);
445
+ break;
446
+ }
333
447
  fs.rmSync(item.target, { recursive: true, force: true });
334
448
  out.removed.push(item.target);
335
449
  break;
450
+ }
336
451
  case "hooks": {
337
- const claudeMem = item.hookOwner === UPSTREAM_PLUGIN_NAME;
338
- pruneHooksMatching(item.target, claudeMem ? isClaudeMemHook : isOurHook, false);
339
- out.removed.push(`${item.target} (${claudeMem ? UPSTREAM_PLUGIN_NAME : "FreeHarness"} hook entries)`);
452
+ pruneHooksMatching(item.target, hookPredicate(item.hookOwner), false);
453
+ const whose = item.hookOwner === UPSTREAM_PLUGIN_NAME ? `upstream ${UPSTREAM_PLUGIN_NAME}` : item.hookOwner === "retired" ? "retired FreeHarness" : "FreeHarness";
454
+ out.removed.push(`${item.target} (${whose} hook entries)`);
340
455
  break;
341
456
  }
342
457
  case "npm-global": {
@@ -358,12 +473,16 @@ export function applyCleanup(plan, confirmed = () => true) {
358
473
  out.warnings.push(`npm ${verb} -g ${item.target} failed (exit ${r.status ?? "?"}): ${(r.stderr || r.stdout || "").trim().split("\n")[0]}`);
359
474
  break;
360
475
  }
361
- case "settings-key": {
362
- const settings = JSON.parse(fs.readFileSync(item.target, "utf8"));
363
- if (item.settingsKey && settings.enabledPlugins)
364
- delete settings.enabledPlugins[item.settingsKey];
365
- fs.writeFileSync(item.target, JSON.stringify(settings, null, 2) + "\n", "utf8");
366
- out.removed.push(`${item.target} (removed ${item.settingsKey} from enabledPlugins)`);
476
+ case "json-key": {
477
+ const refused = jsonKeyRefusal(item, plan.archiveRoot);
478
+ const r = refused ? { ok: false, reason: refused } : removeJsonKey(item.target, item.keyPath ?? [], item.archiveDest ?? "", { stillMatches: launcherStillMatches(item) });
479
+ if (r.ok) {
480
+ out.removed.push(`${item.target} (removed ${keyLabel(item)}; copy at ${r.copy})`);
481
+ }
482
+ else {
483
+ out.skipped.push(item.target);
484
+ out.warnings.push(`${item.target}: refused (${r.reason})`);
485
+ }
367
486
  break;
368
487
  }
369
488
  }
@@ -373,10 +492,20 @@ export function applyCleanup(plan, confirmed = () => true) {
373
492
  /** Renders a plan item for the preview / doctor output, one line, without the OK/WARN prefix. */
374
493
  export function describeItem(item) {
375
494
  const size = item.bytes !== undefined ? ` (${humanBytes(item.bytes)})` : "";
376
- const what = item.kind === "npm-global"
377
- ? `npm ${item.unlink ? "unlink" : "uninstall"} -g ${item.target}`
378
- : item.kind === "hooks" || item.kind === "settings-key"
379
- ? `edit: ${item.target}`
380
- : `rm: ${item.target}`;
381
- return `${what}${size} — ${item.reason}${item.refused ? ` — REFUSED: ${item.refused}` : ""}`;
495
+ return `${itemAction(item)}${size} — ${item.reason}${item.refused ? ` — REFUSED: ${item.refused}` : ""}`;
496
+ }
497
+ /** Doctor prints these lines unescaped, and a launcher's key is a server name another program wrote. */
498
+ function keyLabel(item) {
499
+ return item.keyPath?.map(launcherText).join(".") ?? "?";
500
+ }
501
+ function itemAction(item) {
502
+ if (item.kind === "npm-global")
503
+ return `npm ${item.unlink ? "unlink" : "uninstall"} -g ${item.target}`;
504
+ if (item.kind === "json-key")
505
+ return `edit: ${item.target} (remove ${keyLabel(item)}; original to ${item.archiveDest ?? "?"})`;
506
+ if (item.kind === "hooks")
507
+ return `edit: ${item.target}`;
508
+ if (item.archiveDest !== undefined)
509
+ return `mv: ${item.target} -> ${item.archiveDest}`;
510
+ return `rm: ${item.target}`;
382
511
  }
@@ -0,0 +1,158 @@
1
+ import type { ModelFamily, TokenGroup } from "./cost.js";
2
+ export declare const QUOTA_WINDOWS: readonly ["five_hour", "seven_day"];
3
+ export type QuotaWindow = (typeof QUOTA_WINDOWS)[number];
4
+ export declare const DEFAULT_QUOTA_DAYS = 14;
5
+ export interface WindowReading {
6
+ used_percentage: number;
7
+ resets_at: number;
8
+ }
9
+ /** One validated readings line; `ts` re-rendered from its parsed time so untrusted text never prints. */
10
+ export interface QuotaReading {
11
+ ts: string;
12
+ time: number;
13
+ model: string | null;
14
+ five_hour: WindowReading | null;
15
+ seven_day: WindowReading | null;
16
+ }
17
+ /** The fit's input row: a percentage rise and the tokens per family recorded while it rose. */
18
+ export interface QuotaInterval {
19
+ delta: number;
20
+ tokens: Partial<Record<ModelFamily, TokenGroup>>;
21
+ }
22
+ /** A rising interval as reported, traceable to its two readings. */
23
+ export interface ReportedInterval extends QuotaInterval {
24
+ window: QuotaWindow;
25
+ resets_at: number;
26
+ start: {
27
+ ts: string;
28
+ model: string | null;
29
+ used_percentage: number;
30
+ };
31
+ end: {
32
+ ts: string;
33
+ model: string | null;
34
+ used_percentage: number;
35
+ };
36
+ tokens_per_pct: Partial<Record<ModelFamily, TokenGroup>>;
37
+ }
38
+ export type FamilyWeights = Partial<Record<ModelFamily, TokenGroup>>;
39
+ export type FitResult = {
40
+ weights: FamilyWeights;
41
+ residual: number;
42
+ intervals: number;
43
+ } | {
44
+ needed: number;
45
+ features: number;
46
+ };
47
+ export interface WindowReport {
48
+ calibrated: boolean;
49
+ interval_count: number;
50
+ features: number;
51
+ needed: number;
52
+ weights?: FamilyWeights;
53
+ residual?: number;
54
+ intervals: ReportedInterval[];
55
+ }
56
+ export interface QuotaReport {
57
+ days: number;
58
+ readings: {
59
+ files: string[];
60
+ lines: number;
61
+ malformed: number;
62
+ out_of_range: number;
63
+ skipped: string[];
64
+ };
65
+ transcripts: {
66
+ files: number;
67
+ oversize: number;
68
+ skipped: number;
69
+ messages: number;
70
+ };
71
+ windows: Record<QuotaWindow, WindowReport>;
72
+ note: string;
73
+ written: string | null;
74
+ }
75
+ /** quota-calibration.json: calibrated windows only. */
76
+ export interface QuotaCalibration {
77
+ written: string;
78
+ days: number;
79
+ windows: Partial<Record<QuotaWindow, {
80
+ weights: FamilyWeights;
81
+ intervals: number;
82
+ residual: number;
83
+ }>>;
84
+ }
85
+ export declare function quotaDir(home?: string): string;
86
+ export declare function quotaCalibrationPath(home?: string): string;
87
+ /** A readings line in the sink schema, else null (malformed). */
88
+ export declare function parseReading(raw: string): QuotaReading | null;
89
+ /** Readings from the rotation then the live file, within the last `days`, in time order. */
90
+ export declare function readReadings(home: string, days: number, now: Date): {
91
+ readings: QuotaReading[];
92
+ report: QuotaReport["readings"];
93
+ };
94
+ /**
95
+ * Rising intervals per window. A reading repeating the window's previous percentage and reset is
96
+ * no new reading for that window (the other window changed), so the interval starts at the first
97
+ * reading of the value and keeps the tokens spent while the meter had not yet moved.
98
+ */
99
+ export declare function formIntervals(readings: QuotaReading[]): Record<QuotaWindow, Omit<ReportedInterval, "tokens" | "tokens_per_pct">[]>;
100
+ interface Message {
101
+ time: number;
102
+ family: ModelFamily;
103
+ cache_read: number;
104
+ fresh: number;
105
+ }
106
+ /**
107
+ * Assistant messages with usage, one per `message.id` (else per file and line); the last line of
108
+ * an id wins, as in the cost tracker, since a streamed message repeats its usage on every line.
109
+ * A file last written before `since` cannot hold a message inside any interval and is not read.
110
+ */
111
+ export declare function readMessages(home: string, since: number): {
112
+ messages: Message[];
113
+ report: QuotaReport["transcripts"];
114
+ };
115
+ /** Non-negative least squares, Lawson-Hanson (1974): min ||Ax - b|| subject to x >= 0. */
116
+ export declare function nnls(A: number[][], b: number[]): number[];
117
+ /**
118
+ * Fits percent per million tokens per family and group to the intervals' percentage rises.
119
+ * @param intervals Rows of (delta percent, tokens per family); tokens are raw counts.
120
+ * @returns The weights of every family with tokens, the interval count and the median absolute
121
+ * residual in percentage points; or, below three intervals per feature, the count needed (a
122
+ * window without tokens needs at least one family's two features).
123
+ */
124
+ export declare function fitWeights(intervals: QuotaInterval[]): FitResult;
125
+ /** Reads, forms intervals, sums tokens and fits each window; writes nothing. */
126
+ export declare function quotaReport(home: string, days: number, now?: Date): QuotaReport;
127
+ export declare function formatQuota(report: QuotaReport): string[];
128
+ /** The calibration file's content: calibrated windows only, so a window without a fit never overwrites one. */
129
+ export declare function calibrationOf(report: QuotaReport, now: Date): QuotaCalibration;
130
+ /**
131
+ * Replaces the calibration file whole: a temporary file in the same directory, fsync, rename, so
132
+ * the routing report never reads a half-written file; an existing symlink or non-regular target
133
+ * is refused rather than replaced.
134
+ */
135
+ export declare function writeCalibration(file: string, calibration: QuotaCalibration): void;
136
+ /**
137
+ * Reads the calibration for the routing report's window estimate.
138
+ * @returns The shape-checked calibration; null without a warning when the file is absent; null
139
+ * with one `quota calibration ignored: <reason>` line when it is unreadable or off-contract.
140
+ */
141
+ export declare function readCalibration(file?: string): {
142
+ calibration: QuotaCalibration | null;
143
+ warning: string | null;
144
+ };
145
+ export interface CostQuotaOptions {
146
+ days?: number;
147
+ json?: boolean;
148
+ write?: boolean;
149
+ /** Tests and callers pin these; the CLI uses the user's HOME and the wall clock. */
150
+ home?: string;
151
+ now?: Date;
152
+ }
153
+ /**
154
+ * Runs the calibration and, with `write`, stores the calibrated windows.
155
+ * @returns The lines to print: the text report, or one JSON document with `--json`.
156
+ */
157
+ export declare function costQuota(opts?: CostQuotaOptions): string[];
158
+ export {};