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