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.
- package/NOTICE +2 -1
- package/README.md +24 -19
- package/canonical/HARVEST.yaml +27 -16
- package/canonical/PROVENANCE.yaml +3 -3
- 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 +14 -9
- package/canonical/agents/test-writer.md +5 -2
- package/canonical/hooks/cost-prices.json +11 -9
- package/canonical/hooks/freeharness-config-guard.mjs +4155 -93
- package/canonical/hooks/freeharness-context-monitor.mjs +88 -54
- package/canonical/hooks/freeharness-converge.mjs +78 -23
- package/canonical/hooks/freeharness-cost-tracker.mjs +1116 -218
- package/canonical/hooks/freeharness-memory-guard.mjs +20 -23
- package/canonical/hooks/freeharness-precompact.mjs +206 -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 +959 -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 +2 -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 +1 -1
- 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 +10 -3
- 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 +3625 -19
- package/canonical/skills/subagent-driven-development/reference/brief.md +187 -35
- package/canonical/skills/subagent-driven-development/reference/handoff.md +96 -2
- package/canonical/skills/subagent-driven-development/reference/loop.md +56 -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 +4035 -327
- package/dist/adapters/harness/claude-code.d.ts +26 -3
- package/dist/adapters/harness/claude-code.js +214 -43
- package/dist/adapters/harness/opencode.js +17 -3
- package/dist/adapters/harness/plugin.js +28 -17
- package/dist/adapters/memory/doctor.d.ts +172 -33
- package/dist/adapters/memory/doctor.js +750 -139
- package/dist/adapters/memory/migrate.d.ts +95 -0
- package/dist/adapters/memory/migrate.js +497 -0
- package/dist/adapters/memory/paths.d.ts +15 -0
- package/dist/adapters/memory/paths.js +47 -0
- package/dist/adapters/memory/vendored.d.ts +52 -30
- package/dist/adapters/memory/vendored.js +161 -169
- package/dist/adapters/memory/worker-control.d.ts +9 -0
- package/dist/adapters/memory/worker-control.js +30 -0
- 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 +204 -75
- 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 +199 -45
- package/dist/cli/hard-cleanup.d.ts +93 -0
- package/dist/cli/hard-cleanup.js +552 -0
- package/dist/cli/install-facts.d.ts +29 -0
- package/dist/cli/install-facts.js +68 -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/profile.d.ts +1 -1
- package/dist/cli/profile.js +10 -10
- 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 +252 -73
- package/dist/infra/fsutil.d.ts +30 -3
- package/dist/infra/fsutil.js +68 -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/registry.d.ts +1 -1
- package/node_modules/@freeharness/core/dist/registry.js +3 -3
- 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 +3 -3
- package/provenance/memory.PROVENANCE.yaml +472 -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
- package/canonical/openspec/config.base.yaml +0 -89
- package/canonical/rules/safety.md +0 -41
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// FreeHarness context monitor (Claude Code Stop hook).
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
1
|
+
// FreeHarness context monitor (Claude Code Stop hook). When the session's latest cost snapshot
|
|
2
|
+
// crosses 60% and then 85% of its token window, it prints one advisory `systemMessage` (a Stop
|
|
3
|
+
// `additionalContext` would force another turn). A hook cannot run /clear, so at the highest
|
|
4
|
+
// threshold it first saves a handoff the project's next session claims. Never blocks; exit 0
|
|
5
|
+
// on every path. Disable: FREEHARNESS_HOOK_CONTEXT_MONITOR=off.
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import crypto from "node:crypto";
|
|
7
8
|
import fs from "node:fs";
|
|
8
9
|
import os from "node:os";
|
|
9
10
|
import path from "node:path";
|
|
@@ -12,11 +13,7 @@ import { fileURLToPath } from "node:url";
|
|
|
12
13
|
/** Defaults; the fragment overrides them with --budget / --thresholds. */
|
|
13
14
|
export const DEFAULTS = Object.freeze({ budget: 200_000, thresholds: [60, 85] });
|
|
14
15
|
|
|
15
|
-
/**
|
|
16
|
-
* Parses --budget and --thresholds (percentages, ascending).
|
|
17
|
-
* @param {string[]} argv Arguments after the script path.
|
|
18
|
-
* @returns {{budget:number, thresholds:number[]}} Options.
|
|
19
|
-
*/
|
|
16
|
+
/** --budget and --thresholds (percentages, sorted ascending). */
|
|
20
17
|
export function parseOptions(argv) {
|
|
21
18
|
const opts = { budget: DEFAULTS.budget, thresholds: [...DEFAULTS.thresholds] };
|
|
22
19
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -34,22 +31,15 @@ export function parseOptions(argv) {
|
|
|
34
31
|
return opts;
|
|
35
32
|
}
|
|
36
33
|
|
|
37
|
-
/**
|
|
38
|
-
* Resolves the state file of a session.
|
|
39
|
-
* @param {string} sessionId Session id.
|
|
40
|
-
* @returns {string} Absolute path.
|
|
41
|
-
*/
|
|
42
34
|
function stateFile(sessionId) {
|
|
43
|
-
|
|
44
|
-
return path.join(dir, `${sessionId.replace(/[^\w.-]/g, "_") || "unknown"}.context.json`);
|
|
35
|
+
return path.join(stateDir(), `${sessionId.replace(/[^\w.-]/g, "_") || "unknown"}.context.json`);
|
|
45
36
|
}
|
|
46
37
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
*/
|
|
38
|
+
function stateDir() {
|
|
39
|
+
return process.env.FREEHARNESS_STATE_DIR || path.join(os.homedir(), ".claude", "freeharness", "state");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Latest `{tokens, window}` of the session in the cost log (window 200,000 when unrecorded), or null. */
|
|
53
43
|
export function contextFromSnapshot(sessionId) {
|
|
54
44
|
const file = process.env.FREEHARNESS_COST_LOG || path.join(os.homedir(), ".claude", "freeharness", "cost", "usage.jsonl");
|
|
55
45
|
let text;
|
|
@@ -74,11 +64,7 @@ export function contextFromSnapshot(sessionId) {
|
|
|
74
64
|
return best;
|
|
75
65
|
}
|
|
76
66
|
|
|
77
|
-
/**
|
|
78
|
-
* Context size from the transcript: the last assistant message's input + cache tokens.
|
|
79
|
-
* @param {string} transcript Transcript path.
|
|
80
|
-
* @returns {number | null} Tokens, or null when unreadable.
|
|
81
|
-
*/
|
|
67
|
+
/** Input plus cache tokens of the transcript's last usage record, or null. */
|
|
82
68
|
export function contextFromTranscript(transcript) {
|
|
83
69
|
let text;
|
|
84
70
|
try {
|
|
@@ -101,15 +87,8 @@ export function contextFromTranscript(transcript) {
|
|
|
101
87
|
}
|
|
102
88
|
|
|
103
89
|
/**
|
|
104
|
-
*
|
|
105
|
-
* re-arms (clears the
|
|
106
|
-
* lowest threshold, so a later climb announces both thresholds again.
|
|
107
|
-
* @param {number} tokens Current context tokens.
|
|
108
|
-
* @param {number} window Token window the percentage is computed against.
|
|
109
|
-
* @param {{budget:number, thresholds:number[]}} opts Options (thresholds only; budget is the
|
|
110
|
-
* caller's fallback window, not used here).
|
|
111
|
-
* @param {string} file State file.
|
|
112
|
-
* @returns {number | null} The percentage to announce, or null.
|
|
90
|
+
* The threshold to announce, or null; each is announced once per epoch. A drop below the lowest
|
|
91
|
+
* threshold re-arms (clears the set, bumps the epoch) so a later climb announces again.
|
|
113
92
|
*/
|
|
114
93
|
export function crossed(tokens, window, opts, file) {
|
|
115
94
|
let state = { announced: [], epoch: 0 };
|
|
@@ -136,21 +115,69 @@ export function crossed(tokens, window, opts, file) {
|
|
|
136
115
|
}
|
|
137
116
|
if (changed) {
|
|
138
117
|
state.updated = new Date().toISOString();
|
|
139
|
-
|
|
140
|
-
|
|
118
|
+
try {
|
|
119
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
120
|
+
fs.writeFileSync(file, JSON.stringify(state), "utf8");
|
|
121
|
+
} catch {
|
|
122
|
+
// An unwritable state dir still gets the advisory; it repeats until the state persists.
|
|
123
|
+
}
|
|
141
124
|
}
|
|
142
125
|
return top;
|
|
143
126
|
}
|
|
144
127
|
|
|
128
|
+
const HANDOFF_MAX_BYTES = 16_000;
|
|
129
|
+
|
|
130
|
+
/** SessionStart derives the same key to claim the handoff: SHA-256 of the real git toplevel, else of the cwd. */
|
|
131
|
+
export function projectKey(cwd) {
|
|
132
|
+
let root = cwd;
|
|
133
|
+
try {
|
|
134
|
+
const top = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
135
|
+
if (top) root = top;
|
|
136
|
+
} catch {
|
|
137
|
+
// not a repository, or no git: the cwd is the root
|
|
138
|
+
}
|
|
139
|
+
return crypto.createHash("sha256").update(fs.realpathSync(root)).digest("hex").slice(0, 16);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The rename makes the write atomic and replaces an older unclaimed handoff; resolves to the path, or null. */
|
|
143
|
+
export async function writeHandoff(payload) {
|
|
144
|
+
if (typeof payload.transcript_path !== "string" || !payload.transcript_path) return null;
|
|
145
|
+
// Imported lazily: the PreCompact script is emitted with another hook group and may be absent.
|
|
146
|
+
const pc = await import(new URL("./freeharness-precompact.mjs", import.meta.url).href);
|
|
147
|
+
const facts = pc.parseTranscript(payload.transcript_path);
|
|
148
|
+
if (!facts) return null;
|
|
149
|
+
const cwd = typeof payload.cwd === "string" && path.isAbsolute(payload.cwd) ? payload.cwd : process.cwd();
|
|
150
|
+
const key = projectKey(cwd);
|
|
151
|
+
const session = String(typeof payload.session_id === "string" ? payload.session_id : "").replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 64) || "unknown";
|
|
152
|
+
const head = `handoff ${key} ${session}\n`;
|
|
153
|
+
const body = pc.skeletonFromFacts(facts, payload, { mapBody: pc.neutralize, maxBytes: HANDOFF_MAX_BYTES - Buffer.byteLength(head) });
|
|
154
|
+
const dir = path.join(stateDir(), "handoff");
|
|
155
|
+
const file = path.join(dir, `${key}.md`);
|
|
156
|
+
const tmp = path.join(dir, `.${key}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`);
|
|
157
|
+
let written = null;
|
|
158
|
+
try {
|
|
159
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
160
|
+
// A symlinked handoff dir would put the file wherever the link points.
|
|
161
|
+
if (fs.lstatSync(dir).isDirectory()) {
|
|
162
|
+
fs.writeFileSync(tmp, head + body, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
163
|
+
fs.renameSync(tmp, file);
|
|
164
|
+
written = file;
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
fs.rmSync(tmp, { force: true });
|
|
168
|
+
} finally {
|
|
169
|
+
// The engine summary does not depend on the local file, so it is asked for either way.
|
|
170
|
+
const lastAssistant = facts.assistants.length ? facts.assistants[facts.assistants.length - 1] : "";
|
|
171
|
+
await pc.requestSummary(typeof payload.session_id === "string" && payload.session_id ? payload.session_id : "unknown", lastAssistant);
|
|
172
|
+
}
|
|
173
|
+
return written;
|
|
174
|
+
}
|
|
175
|
+
|
|
145
176
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* --budget stands in for it there.
|
|
149
|
-
* @param {Record<string, any>} payload Parsed hook payload.
|
|
150
|
-
* @param {{budget:number, thresholds:number[]}} opts Options.
|
|
151
|
-
* @returns {Record<string, unknown> | null} JSON to print, or null.
|
|
177
|
+
* The advisory JSON for one Stop payload, or null. The transcript fallback carries no window of
|
|
178
|
+
* its own, so --budget stands in for it. A failed handoff leaves the plain advisory.
|
|
152
179
|
*/
|
|
153
|
-
export function respond(payload, opts) {
|
|
180
|
+
export async function respond(payload, opts) {
|
|
154
181
|
const sessionId = typeof payload.session_id === "string" && payload.session_id ? payload.session_id : "unknown";
|
|
155
182
|
const snap = contextFromSnapshot(sessionId);
|
|
156
183
|
let tokens = null;
|
|
@@ -168,13 +195,21 @@ export function respond(payload, opts) {
|
|
|
168
195
|
const usedPct = Math.round((tokens / window) * 100);
|
|
169
196
|
const tokensK = Math.round(tokens / 1000);
|
|
170
197
|
const windowK = Math.round(window / 1000);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
198
|
+
const usage = `context ${tokensK}k of ${windowK}k (${usedPct}%)`;
|
|
199
|
+
if (threshold === opts.thresholds[opts.thresholds.length - 1]) {
|
|
200
|
+
let handoff = null;
|
|
201
|
+
try {
|
|
202
|
+
handoff = await writeHandoff(payload);
|
|
203
|
+
} catch {
|
|
204
|
+
// a failed handoff leaves the plain advisory
|
|
205
|
+
}
|
|
206
|
+
if (handoff) return { systemMessage: `${usage}: handoff saved to ${handoff}; /clear now and the next session in this project picks it up` };
|
|
207
|
+
}
|
|
208
|
+
return { systemMessage: `${usage}: compact with the project instructions, or /clear before unrelated work` };
|
|
174
209
|
}
|
|
175
210
|
|
|
176
211
|
/** Entry point: read stdin, respond or stay silent, exit 0. */
|
|
177
|
-
function main() {
|
|
212
|
+
async function main() {
|
|
178
213
|
if ((process.env.FREEHARNESS_HOOK_CONTEXT_MONITOR || "").toLowerCase() === "off") return;
|
|
179
214
|
let payload = {};
|
|
180
215
|
try {
|
|
@@ -185,7 +220,7 @@ function main() {
|
|
|
185
220
|
}
|
|
186
221
|
if (!payload || typeof payload !== "object") return;
|
|
187
222
|
try {
|
|
188
|
-
const out = respond(payload, parseOptions(process.argv.slice(2)));
|
|
223
|
+
const out = await respond(payload, parseOptions(process.argv.slice(2)));
|
|
189
224
|
if (out) process.stdout.write(JSON.stringify(out));
|
|
190
225
|
} catch (e) {
|
|
191
226
|
process.stderr.write(`freeharness-context-monitor: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
@@ -193,6 +228,5 @@ function main() {
|
|
|
193
228
|
}
|
|
194
229
|
|
|
195
230
|
if (process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url))) {
|
|
196
|
-
main();
|
|
197
|
-
process.exit(0);
|
|
231
|
+
main().finally(() => process.exit(0));
|
|
198
232
|
}
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
// is below max_rounds it answers {"decision":"block","reason":"<next task + its Verify
|
|
8
8
|
// line>"} (the Claude Code Stop contract: `block` keeps the agent working, `reason` is what
|
|
9
9
|
// it reads next). When every task is checked, the limit is reached, or the loop is disabled,
|
|
10
|
-
// the session ends normally. Each round is appended to .freeharness/converge.log
|
|
10
|
+
// the session ends normally. Each round is appended to .freeharness/converge.log (task text
|
|
11
|
+
// JSON-encoded); a linked or special .freeharness, config or log turns the loop off. A tasks.md
|
|
11
12
|
// that cannot be read as a task list (missing, empty, or no checkbox lines) disables the loop
|
|
12
13
|
// for good: converge.json is rewritten with "disabled": true and the reason, so a broken
|
|
13
14
|
// plan never traps a session. The promise text is echoed in the block reason so the agent
|
|
@@ -21,13 +22,12 @@ import { fileURLToPath } from "node:url";
|
|
|
21
22
|
/** Defaults applied to a converge.json that omits them. */
|
|
22
23
|
export const DEFAULTS = Object.freeze({ maxRounds: 5, promise: "every task in tasks.md is checked and its Verify line passed" });
|
|
23
24
|
|
|
24
|
-
const TASK_LINE = /^\s*[
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
const DISPOSITION_LINE = /^\s*Disposition:\s*(superseded|moved|external)\b\s*(.*?)\s*$/;
|
|
25
|
+
const TASK_LINE = /^\s*(?:[-*+]|\d{1,9}[.)])\s*\[(?:\s*([^\]\s])\s*\](?![([])|\](?![([])|\s+\])\s*(.*)/;
|
|
26
|
+
// Values run to end of line and are trimmed in code; a group before `\s*$` is quadratic.
|
|
27
|
+
const VERIFY_LINE = /^\s*Verify:(.*)$/s;
|
|
28
|
+
const DISPOSITION_LINE = /^\s*Disposition:\s*(superseded|moved|external)\b(.*)$/s;
|
|
29
29
|
/** The annotation lines a task may carry; any other line ends its block. */
|
|
30
|
-
const ANNOTATION_LINE = /^\s*(?:Verify|Interfaces|Boundary|Depends|Disposition)
|
|
30
|
+
const ANNOTATION_LINE = /^\s*(?:Verify|Interfaces|Boundary|Depends|Disposition):/;
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* Resolves the project root of a Stop payload.
|
|
@@ -47,11 +47,16 @@ export function projectDir(payload) {
|
|
|
47
47
|
export function readConfig(project) {
|
|
48
48
|
const file = path.join(project, ".freeharness", "converge.json");
|
|
49
49
|
if (!fs.existsSync(file)) return { file, config: null };
|
|
50
|
+
if (!contained(project)) return { file, config: null };
|
|
51
|
+
const fd = openRegular(file, fs.constants.O_RDONLY);
|
|
52
|
+
if (fd === null) return { file, config: null };
|
|
50
53
|
let parsed;
|
|
51
54
|
try {
|
|
52
|
-
parsed = JSON.parse(fs.readFileSync(
|
|
55
|
+
parsed = JSON.parse(fs.readFileSync(fd, "utf8"));
|
|
53
56
|
} catch (e) {
|
|
54
57
|
return { file, config: null, error: `converge.json is not valid JSON (${e instanceof Error ? e.message : String(e)}); loop off` };
|
|
58
|
+
} finally {
|
|
59
|
+
fs.closeSync(fd);
|
|
55
60
|
}
|
|
56
61
|
if (!parsed || typeof parsed !== "object" || typeof parsed.change !== "string" || !/^[A-Za-z0-9._-]+$/.test(parsed.change)) {
|
|
57
62
|
return { file, config: null, error: "converge.json needs a \"change\" id (letters, digits, . _ -); loop off" };
|
|
@@ -79,7 +84,7 @@ export function parseTasks(content) {
|
|
|
79
84
|
for (const line of content.split("\n")) {
|
|
80
85
|
const t = line.match(TASK_LINE);
|
|
81
86
|
if (t) {
|
|
82
|
-
current = { done: t[1].toLowerCase() === "x", text: t[2].trim(), verify: null, disposition: null };
|
|
87
|
+
current = { done: (t[1] ?? "").toLowerCase() === "x", text: t[2].trim(), verify: null, disposition: null };
|
|
83
88
|
tasks.push(current);
|
|
84
89
|
continue;
|
|
85
90
|
}
|
|
@@ -110,15 +115,56 @@ export function parseTasks(content) {
|
|
|
110
115
|
return tasks;
|
|
111
116
|
}
|
|
112
117
|
|
|
118
|
+
const NO_FOLLOW = (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0);
|
|
119
|
+
|
|
120
|
+
/** True when `file` is absent or a single-link regular file (never a symlink). */
|
|
121
|
+
function plainOrAbsent(file) {
|
|
122
|
+
try {
|
|
123
|
+
const st = fs.lstatSync(file);
|
|
124
|
+
return st.isFile() && st.nlink === 1;
|
|
125
|
+
} catch (e) {
|
|
126
|
+
return e?.code === "ENOENT";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** True when .freeharness is a real directory holding a plain config and log: a cloned repo can link either to a shell rc file. */
|
|
131
|
+
function contained(project) {
|
|
132
|
+
const dir = path.join(project, ".freeharness");
|
|
133
|
+
try {
|
|
134
|
+
if (!fs.lstatSync(dir).isDirectory()) return false;
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
return plainOrAbsent(path.join(dir, "converge.json")) && plainOrAbsent(path.join(dir, "converge.log"));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Descriptor of `file` unless it is a symlink, special or hard-linked; checked on the fd, so a swap after `contained` fails too. */
|
|
142
|
+
function openRegular(file, flags) {
|
|
143
|
+
let fd;
|
|
144
|
+
try {
|
|
145
|
+
fd = fs.openSync(file, flags | NO_FOLLOW, 0o644);
|
|
146
|
+
const st = fs.fstatSync(fd);
|
|
147
|
+
if (st.isFile() && st.nlink === 1) return fd;
|
|
148
|
+
} catch {
|
|
149
|
+
// refused below
|
|
150
|
+
}
|
|
151
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
113
155
|
/**
|
|
114
156
|
* Appends one line to the round log.
|
|
115
157
|
* @param {string} project Project dir.
|
|
116
158
|
* @param {string} line Text.
|
|
117
159
|
*/
|
|
118
160
|
function log(project, line) {
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
161
|
+
const fd = openRegular(path.join(project, ".freeharness", "converge.log"), fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT);
|
|
162
|
+
if (fd === null) return;
|
|
163
|
+
try {
|
|
164
|
+
fs.writeSync(fd, `${new Date().toISOString()} ${line}\n`);
|
|
165
|
+
} finally {
|
|
166
|
+
fs.closeSync(fd);
|
|
167
|
+
}
|
|
122
168
|
}
|
|
123
169
|
|
|
124
170
|
/**
|
|
@@ -127,13 +173,20 @@ function log(project, line) {
|
|
|
127
173
|
* @param {Record<string, unknown>} patch Fields to merge over the current file.
|
|
128
174
|
*/
|
|
129
175
|
function writeConfig(file, patch) {
|
|
130
|
-
|
|
176
|
+
const fd = openRegular(file, fs.constants.O_RDWR);
|
|
177
|
+
if (fd === null) return;
|
|
131
178
|
try {
|
|
132
|
-
current =
|
|
133
|
-
|
|
134
|
-
|
|
179
|
+
let current = {};
|
|
180
|
+
try {
|
|
181
|
+
current = JSON.parse(fs.readFileSync(fd, "utf8"));
|
|
182
|
+
} catch {
|
|
183
|
+
// unreadable: the patch alone is written (the reader already validated the shape)
|
|
184
|
+
}
|
|
185
|
+
fs.ftruncateSync(fd, 0);
|
|
186
|
+
fs.writeSync(fd, `${JSON.stringify({ ...current, ...patch }, null, 2)}\n`, 0);
|
|
187
|
+
} finally {
|
|
188
|
+
fs.closeSync(fd);
|
|
135
189
|
}
|
|
136
|
-
fs.writeFileSync(file, `${JSON.stringify({ ...current, ...patch }, null, 2)}\n`, "utf8");
|
|
137
190
|
}
|
|
138
191
|
|
|
139
192
|
/**
|
|
@@ -157,13 +210,13 @@ export function respond(payload) {
|
|
|
157
210
|
} catch (e) {
|
|
158
211
|
const reason = `tasks.md unreadable at ${tasksFile}: ${e instanceof Error ? e.message : String(e)}`;
|
|
159
212
|
writeConfig(file, { disabled: true, reason });
|
|
160
|
-
log(project, `disabled change=${config.change} reason
|
|
213
|
+
log(project, `disabled change=${config.change} reason=${JSON.stringify(reason)}`);
|
|
161
214
|
return { systemMessage: `freeharness-converge: disabled (${reason}). Fix the change id or the file and remove "disabled" from .freeharness/converge.json to re-arm.` };
|
|
162
215
|
}
|
|
163
216
|
if (tasks.length === 0) {
|
|
164
217
|
const reason = `tasks.md has no checkbox task lines (${tasksFile})`;
|
|
165
218
|
writeConfig(file, { disabled: true, reason });
|
|
166
|
-
log(project, `disabled change=${config.change} reason
|
|
219
|
+
log(project, `disabled change=${config.change} reason=${JSON.stringify(reason)}`);
|
|
167
220
|
return { systemMessage: `freeharness-converge: disabled (${reason}). Write the tasks as "- [ ] N.M ..." lines and remove "disabled" from .freeharness/converge.json to re-arm.` };
|
|
168
221
|
}
|
|
169
222
|
|
|
@@ -181,10 +234,10 @@ export function respond(payload) {
|
|
|
181
234
|
}
|
|
182
235
|
if (config.rounds >= config.maxRounds) {
|
|
183
236
|
const remaining = open.map((t) => t.text).join(" | ");
|
|
184
|
-
log(project, `limit change=${config.change} rounds=${config.rounds}/${config.maxRounds} remaining=${open.length} tasks
|
|
237
|
+
log(project, `limit change=${config.change} rounds=${config.rounds}/${config.maxRounds} remaining=${open.length} tasks=${JSON.stringify(remaining)}`);
|
|
185
238
|
if (dispositioned.length > 0) {
|
|
186
239
|
const skipped = dispositioned.map((t) => `${t.text} (${t.disposition.kind})`).join(" | ");
|
|
187
|
-
log(project, `dispositioned change=${config.change} tasks
|
|
240
|
+
log(project, `dispositioned change=${config.change} tasks=${JSON.stringify(skipped)}`);
|
|
188
241
|
}
|
|
189
242
|
return { systemMessage: `freeharness-converge: round limit ${config.maxRounds} reached for ${config.change} with ${open.length} task(s) open: ${remaining}. Raise max_rounds or reset rounds in .freeharness/converge.json to continue.` };
|
|
190
243
|
}
|
|
@@ -192,7 +245,7 @@ export function respond(payload) {
|
|
|
192
245
|
const next = open[0];
|
|
193
246
|
const round = config.rounds + 1;
|
|
194
247
|
writeConfig(file, { rounds: round });
|
|
195
|
-
log(project, `round=${round}/${config.maxRounds} change=${config.change} open=${open.length} next
|
|
248
|
+
log(project, `round=${round}/${config.maxRounds} change=${config.change} open=${open.length} next=${JSON.stringify(next.text)}`);
|
|
196
249
|
const reason = [
|
|
197
250
|
`freeharness-converge round ${round}/${config.maxRounds} for change ${config.change}: ${open.length} task(s) still unchecked.`,
|
|
198
251
|
`Next task: ${next.text}`,
|
|
@@ -222,6 +275,8 @@ function main() {
|
|
|
222
275
|
}
|
|
223
276
|
|
|
224
277
|
if (process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url))) {
|
|
278
|
+
// No exit(): it truncates a reason past the pipe buffer. EPIPE on either stream is ignored.
|
|
279
|
+
process.stdout.on("error", () => {});
|
|
280
|
+
process.stderr.on("error", () => {});
|
|
225
281
|
main();
|
|
226
|
-
process.exit(0);
|
|
227
282
|
}
|