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
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
# the free-mem plugin's hooks.
|
|
5
5
|
# Events: SessionStart. Sources: startup, clear, compact, resume, fork.
|
|
6
6
|
# Output: {"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"<text>"}}:
|
|
7
|
-
# the pointer,
|
|
8
|
-
#
|
|
7
|
+
# the fixed pointer, the routing block from FH_ROUTING_FILE, then the volatile tail: the SDD
|
|
8
|
+
# status line and WIP advisory when applicable, then (startup/clear) the project's handoff
|
|
9
|
+
# claimed once from <state>/handoff/, or (compact/resume/fork) the re-orientation block; see
|
|
10
|
+
# buildContext.
|
|
9
11
|
# Disable: FREEHARNESS_HOOK_SESSION_START=off. Never blocks: exit 0 on every path.
|
|
10
12
|
set -u
|
|
11
13
|
if [ "$(printf '%s' "${FREEHARNESS_HOOK_SESSION_START:-}" | tr '[:upper:]' '[:lower:]')" = "off" ]; then exit 0; fi
|
|
@@ -18,16 +20,19 @@ elif [ -r "$FH_HOOK_DIR/../rules/routing.md" ]; then
|
|
|
18
20
|
else
|
|
19
21
|
FH_ROUTING_FILE=""
|
|
20
22
|
fi
|
|
21
|
-
export FH_ROUTING_FILE
|
|
23
|
+
export FH_ROUTING_FILE FH_HOOK_DIR
|
|
22
24
|
FH_PAYLOAD="$(cat 2>/dev/null || true)"
|
|
23
25
|
export FH_PAYLOAD
|
|
24
26
|
|
|
25
27
|
if node --input-type=module - <<'JS'
|
|
28
|
+
import { execFileSync } from "node:child_process";
|
|
29
|
+
import crypto from "node:crypto";
|
|
26
30
|
import fs from "node:fs";
|
|
27
31
|
import os from "node:os";
|
|
28
32
|
import path from "node:path";
|
|
33
|
+
import { pathToFileURL } from "node:url";
|
|
29
34
|
|
|
30
|
-
/** The v3 pointer lines
|
|
35
|
+
/** The v3 pointer lines; byte-stable across sessions, so nothing project-specific goes inside. */
|
|
31
36
|
const POINTER_LINES = [
|
|
32
37
|
"<freeharness>",
|
|
33
38
|
"FreeHarness active. Plan non-trivial work with OpenSpec (/opsx: proposal, specs, design, tasks).",
|
|
@@ -36,7 +41,6 @@ const POINTER_LINES = [
|
|
|
36
41
|
"Memory is the free-mem plugin. The user's CLAUDE.md/AGENTS.md always takes precedence over this pointer.",
|
|
37
42
|
];
|
|
38
43
|
|
|
39
|
-
/** The v3 pointer, unchanged (no openspec/changes in the project). */
|
|
40
44
|
const POINTER = [...POINTER_LINES, "</freeharness>"].join("\n");
|
|
41
45
|
|
|
42
46
|
/** Complete-but-not-archived ids listed in the status line, at most. */
|
|
@@ -63,9 +67,26 @@ const ROUTING_CAP = 6000;
|
|
|
63
67
|
/** Reserve for the `(+N rows truncated)` marker, so a truncated body still fits ROUTING_CAP. */
|
|
64
68
|
const ROUTING_MARKER_RESERVE = 48;
|
|
65
69
|
|
|
66
|
-
/** additionalContext length that starts the shortening cascade
|
|
70
|
+
/** additionalContext length that starts the shortening cascade: Claude Code shows only a 2,000-character preview past 10,000. */
|
|
67
71
|
const SHORTEN_AT = 9800;
|
|
68
72
|
|
|
73
|
+
/** Shortening levels, cut L2 first; L0 is what the closing rule tells the model to re-read. */
|
|
74
|
+
const L2_SECTIONS = ["Files and Code", "Pending Jobs"];
|
|
75
|
+
const L1_SECTIONS = ["Primary Request", "Critical Context", "Corrections"];
|
|
76
|
+
const L0_SECTIONS = ["Current Work", "Next Step", "Errors and Fixes"];
|
|
77
|
+
|
|
78
|
+
const CUT_MARK = "(cut)";
|
|
79
|
+
|
|
80
|
+
/** Sources that claim the project's handoff; compact, resume and fork continue a session that keeps its own state. */
|
|
81
|
+
const CLAIM = new Set(["clear", "startup"]);
|
|
82
|
+
|
|
83
|
+
/** Handoff limits from the data contract: the writer's size cap, the print window and the claimed-file lifetime. */
|
|
84
|
+
const HANDOFF_MAX_BYTES = 16_000;
|
|
85
|
+
const HANDOFF_FRESH_MS = 24 * 3_600_000;
|
|
86
|
+
const CLAIMED_KEEP_MS = 7 * 24 * 3_600_000;
|
|
87
|
+
const HANDOFF_HEAD = /^handoff [0-9a-f]{16} /;
|
|
88
|
+
const CLAIMED_NAME = /^[0-9a-f]{16}\.claimed-\d+\.md$/;
|
|
89
|
+
|
|
69
90
|
/** State dir (FREEHARNESS_STATE_DIR overrides). */
|
|
70
91
|
function stateDir() {
|
|
71
92
|
return process.env.FREEHARNESS_STATE_DIR || path.join(os.homedir(), ".claude", "freeharness", "state");
|
|
@@ -76,8 +97,11 @@ function safeId(id) {
|
|
|
76
97
|
return String(id || "").replace(/[^\w.-]/g, "_") || "unknown";
|
|
77
98
|
}
|
|
78
99
|
|
|
79
|
-
/** A
|
|
80
|
-
const TASK_LINE_PATTERN = /^\s*[
|
|
100
|
+
/** A task line under any list marker (`-`/`*`/`+`/ordered); matches the engine's task-line pattern. */
|
|
101
|
+
const TASK_LINE_PATTERN = /^\s*(?:[-*+]|\d{1,9}[.)])\s*\[(?:\s*([^\]\s])\s*\](?![([])|\](?![([])|\s+\])\s*(.*)/;
|
|
102
|
+
|
|
103
|
+
/** Lines longer than this are skipped unmatched so a hostile tasks.md cannot stall the hook. */
|
|
104
|
+
const MAX_TASK_LINE_LENGTH = 10_000;
|
|
81
105
|
|
|
82
106
|
/** A per-task annotation line, never a task; the trailing `\s*` absorbs a CRLF `\r`. */
|
|
83
107
|
const TASK_ANNOTATION_PATTERN = /^\s*(?:Verify|Interfaces|Boundary|Depends|Disposition):\s*(.*)\s*$/;
|
|
@@ -91,6 +115,12 @@ const DISPOSITION_KINDS = new Set(["superseded", "moved", "external"]);
|
|
|
91
115
|
/** An unclaimed follow-up item line, matching `openspec follow-up list`'s shape. */
|
|
92
116
|
const FOLLOW_UP_ITEM_PATTERN = /^\s*[-*]\s*\[ \]\s*/;
|
|
93
117
|
|
|
118
|
+
/** Follow-up types in display order; a copy of FOLLOW_UP_TYPES in packages/sdd (session-start.test.ts pins the two equal). */
|
|
119
|
+
const FOLLOW_UP_TYPES = ["defect", "hardening", "refactor", "test-gap", "docs", "design", "owner-step", "upstream"];
|
|
120
|
+
|
|
121
|
+
/** A leading `[<type>]` tag on an item body; a tag outside FOLLOW_UP_TYPES reads as untyped. */
|
|
122
|
+
const FOLLOW_UP_TYPE_TAG = /^\[([a-z-]+)\]/;
|
|
123
|
+
|
|
94
124
|
/** `.freeharness/sdd.json`, relative to the project root. */
|
|
95
125
|
const WIP_CAP_FILE = ".freeharness/sdd.json";
|
|
96
126
|
|
|
@@ -102,9 +132,13 @@ function parseTasksWithDisposition(content) {
|
|
|
102
132
|
const tasks = [];
|
|
103
133
|
let current = null;
|
|
104
134
|
for (const line of content.split("\n")) {
|
|
135
|
+
if (line.length > MAX_TASK_LINE_LENGTH) {
|
|
136
|
+
current = null;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
105
139
|
const task = TASK_LINE_PATTERN.exec(line);
|
|
106
140
|
if (task) {
|
|
107
|
-
current = { done: task[1].toLowerCase() === "x", dispositioned: false };
|
|
141
|
+
current = { done: (task[1] ?? "").toLowerCase() === "x", dispositioned: false };
|
|
108
142
|
tasks.push(current);
|
|
109
143
|
continue;
|
|
110
144
|
}
|
|
@@ -183,9 +217,10 @@ function wipAdvisoryLines(project) {
|
|
|
183
217
|
return lines;
|
|
184
218
|
}
|
|
185
219
|
|
|
186
|
-
/** Counts unclaimed follow-up items; item text is
|
|
220
|
+
/** Counts unclaimed follow-up items, in total and per type; beyond the type tag, item text is never read. */
|
|
187
221
|
function followUpCount(project) {
|
|
188
222
|
const changesDir = path.join(project, "openspec", "changes");
|
|
223
|
+
const byType = new Map();
|
|
189
224
|
let total = 0;
|
|
190
225
|
const countFile = (dir) => {
|
|
191
226
|
let text;
|
|
@@ -195,14 +230,18 @@ function followUpCount(project) {
|
|
|
195
230
|
return;
|
|
196
231
|
}
|
|
197
232
|
for (const line of text.split("\n")) {
|
|
198
|
-
if (FOLLOW_UP_ITEM_PATTERN.test(line))
|
|
233
|
+
if (!FOLLOW_UP_ITEM_PATTERN.test(line)) continue;
|
|
234
|
+
total += 1;
|
|
235
|
+
const tag = line.replace(FOLLOW_UP_ITEM_PATTERN, "").match(FOLLOW_UP_TYPE_TAG);
|
|
236
|
+
const type = tag && FOLLOW_UP_TYPES.includes(tag[1]) ? tag[1] : "untyped";
|
|
237
|
+
byType.set(type, (byType.get(type) ?? 0) + 1);
|
|
199
238
|
}
|
|
200
239
|
};
|
|
201
240
|
let entries;
|
|
202
241
|
try {
|
|
203
242
|
entries = fs.readdirSync(changesDir, { withFileTypes: true });
|
|
204
243
|
} catch {
|
|
205
|
-
return 0;
|
|
244
|
+
return { total: 0, byType };
|
|
206
245
|
}
|
|
207
246
|
for (const e of entries) {
|
|
208
247
|
if (!e.isDirectory() || e.name.startsWith(".")) continue;
|
|
@@ -221,7 +260,7 @@ function followUpCount(project) {
|
|
|
221
260
|
}
|
|
222
261
|
countFile(path.join(changesDir, e.name));
|
|
223
262
|
}
|
|
224
|
-
return total;
|
|
263
|
+
return { total, byType };
|
|
225
264
|
}
|
|
226
265
|
|
|
227
266
|
/** Active changes with an unchecked task; path id uses stripControl, not sanitizeTag's 64-char cap, so a long id still points at its real tasks.md (sdd-flow-2#7). */
|
|
@@ -296,14 +335,16 @@ function openspecLine(status) {
|
|
|
296
335
|
const ids = status.complete.length > 0 ? ` (${shown.join(", ")}${status.complete.length > shown.length ? ", ..." : ""})` : "";
|
|
297
336
|
const blocked = status.blocked === null ? "" : `, ${status.blocked} blocked by unresolved headers`;
|
|
298
337
|
const dispositioned = status.dispositioned ? `, ${status.dispositioned} dispositioned` : "";
|
|
299
|
-
const
|
|
338
|
+
const { total, byType } = status.followUps;
|
|
339
|
+
const perType = [...FOLLOW_UP_TYPES, "untyped"].filter((type) => byType.get(type)).map((type) => `${byType.get(type)} ${type}`);
|
|
340
|
+
const followUps = total ? `, ${total} open follow-up(s) (${perType.join(", ")})` : "";
|
|
300
341
|
return `openspec: ${status.inFlight} change(s) in flight, ${status.complete.length} complete but not archived${ids}${blocked}${dispositioned}${followUps}; rule: finish or archive before opening a new change (/opsx:archive)`;
|
|
301
342
|
}
|
|
302
343
|
|
|
303
|
-
/**
|
|
304
|
-
function
|
|
344
|
+
/** Volatile status lines: SDD status when present, WIP advisory when the cap is exceeded. */
|
|
345
|
+
function statusLines(project) {
|
|
305
346
|
const line = openspecLine(openspecStatus(project));
|
|
306
|
-
return [...
|
|
347
|
+
return [...(line ? [line] : []), ...wipAdvisoryLines(project)];
|
|
307
348
|
}
|
|
308
349
|
|
|
309
350
|
/** `<N>k`, non-finite treated as 0. */
|
|
@@ -349,9 +390,9 @@ function routingBlock(file) {
|
|
|
349
390
|
return { block: `<routing>\n${body}\n</routing>`, path: filePath };
|
|
350
391
|
}
|
|
351
392
|
|
|
352
|
-
/** One telemetry line: `<change> <task> <tier>: main Nk, subagents Nk, observer Nk|n/a`. */
|
|
393
|
+
/** One telemetry line: `<change|session_class> <task> <tier>: main Nk, subagents Nk, observer Nk|n/a`. */
|
|
353
394
|
function telemetryLine(rec) {
|
|
354
|
-
const change = sanitizeTag(rec.change) || "untagged";
|
|
395
|
+
const change = sanitizeTag(rec.change) || sanitizeTag(rec.session_class) || "untagged";
|
|
355
396
|
const task = sanitizeTag(rec.task) || "-";
|
|
356
397
|
const tier = sanitizeTag(rec.tier) || "-";
|
|
357
398
|
const sinks = rec && typeof rec.sinks === "object" && rec.sinks ? rec.sinks : {};
|
|
@@ -459,14 +500,17 @@ function fileOps(sessionId, transcript) {
|
|
|
459
500
|
return [...counts.entries()].map(([file, per]) => `${file} (${[...per.entries()].map(([t, n]) => `${t} x${n}`).join(", ")})`);
|
|
460
501
|
}
|
|
461
502
|
|
|
462
|
-
/**
|
|
503
|
+
/** The PreCompact summary's sections; {} when there is none. */
|
|
463
504
|
function precompactSummary(sessionId) {
|
|
464
|
-
let text;
|
|
465
505
|
try {
|
|
466
|
-
|
|
506
|
+
return parseSections(fs.readFileSync(path.join(stateDir(), `${safeId(sessionId)}.precompact.md`), "utf8"));
|
|
467
507
|
} catch {
|
|
468
508
|
return {};
|
|
469
509
|
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Splits text at the eight `## <Section>` headings, body verbatim; text before the first heading is dropped. */
|
|
513
|
+
function parseSections(text) {
|
|
470
514
|
const out = {};
|
|
471
515
|
let current = null;
|
|
472
516
|
const buf = [];
|
|
@@ -488,73 +532,267 @@ function precompactSummary(sessionId) {
|
|
|
488
532
|
return out;
|
|
489
533
|
}
|
|
490
534
|
|
|
491
|
-
/**
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
535
|
+
/**
|
|
536
|
+
* The neutralizer the handoff writer uses, from the PreCompact hook beside this script, so writer
|
|
537
|
+
* and reader share one rule. `shared` is false when that file cannot load; the fallback then
|
|
538
|
+
* escapes every `<`, stricter than the shared rule and never looser.
|
|
539
|
+
*/
|
|
540
|
+
async function loadNeutralize() {
|
|
541
|
+
const dir = process.env.FH_HOOK_DIR || "";
|
|
542
|
+
// A program read from stdin has "-" in argv[1]; the imported hook's entry check would realpath it and throw.
|
|
543
|
+
process.argv[1] = "";
|
|
544
|
+
try {
|
|
545
|
+
if (path.isAbsolute(dir)) {
|
|
546
|
+
const mod = await import(pathToFileURL(path.join(dir, "freeharness-precompact.mjs")).href);
|
|
547
|
+
if (typeof mod.neutralize === "function") return { neutralize: mod.neutralize, shared: true };
|
|
548
|
+
}
|
|
549
|
+
} catch {
|
|
550
|
+
// falls through to the strict fallback
|
|
551
|
+
}
|
|
552
|
+
return { neutralize: (text) => String(text).replace(/</g, "<\\"), shared: false };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** The tracker's project root, so the tail reads the file the tracker writes; `fallback` when that module cannot load or finds no root. */
|
|
556
|
+
async function telemetryRoot(cwd, fallback) {
|
|
557
|
+
const dir = process.env.FH_HOOK_DIR || "";
|
|
558
|
+
process.argv[1] = "";
|
|
559
|
+
try {
|
|
560
|
+
if (path.isAbsolute(dir)) {
|
|
561
|
+
const mod = await import(pathToFileURL(path.join(dir, "freeharness-cost-tracker.mjs")).href);
|
|
562
|
+
const root = typeof mod.projectRootFor === "function" ? mod.projectRootFor(cwd) : null;
|
|
563
|
+
if (typeof root === "string" && root) return root;
|
|
564
|
+
}
|
|
565
|
+
} catch {
|
|
566
|
+
// falls through to the fallback
|
|
567
|
+
}
|
|
568
|
+
return fallback;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** The context monitor's project key: SHA-256 of the real git toplevel, else of the cwd, cut to 16 hex. */
|
|
572
|
+
function projectKey(cwd) {
|
|
573
|
+
let root = cwd;
|
|
574
|
+
try {
|
|
575
|
+
const top = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
576
|
+
if (top) root = top;
|
|
577
|
+
} catch {
|
|
578
|
+
// not a repository, or no git: the cwd is the root
|
|
579
|
+
}
|
|
580
|
+
return crypto.createHash("sha256").update(fs.realpathSync(root)).digest("hex").slice(0, 16);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** A handoff's text and mtime when the path is a regular file within the size cap that starts with the handoff line; null otherwise. */
|
|
584
|
+
function readHandoff(file) {
|
|
585
|
+
let fd;
|
|
586
|
+
try {
|
|
587
|
+
if (!fs.lstatSync(file).isFile()) return null;
|
|
588
|
+
// The lstat above and O_NOFOLLOW both refuse a link; O_NONBLOCK keeps a swapped-in FIFO from hanging the hook.
|
|
589
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
|
|
590
|
+
const st = fs.fstatSync(fd);
|
|
591
|
+
if (!st.isFile() || st.size > HANDOFF_MAX_BYTES) return null;
|
|
592
|
+
const buf = Buffer.alloc(st.size);
|
|
593
|
+
let n = 0;
|
|
594
|
+
for (let r; n < st.size && (r = fs.readSync(fd, buf, n, st.size - n, n)) > 0; ) n += r;
|
|
595
|
+
const text = buf.subarray(0, n).toString("utf8");
|
|
596
|
+
return HANDOFF_HEAD.test(text) ? { text, mtimeMs: st.mtimeMs } : null;
|
|
597
|
+
} catch {
|
|
598
|
+
return null;
|
|
599
|
+
} finally {
|
|
600
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Removes claimed files past their lifetime; only names of the claimed shape are ever touched. */
|
|
605
|
+
function sweepClaimed(dir, now) {
|
|
606
|
+
for (const name of fs.readdirSync(dir)) {
|
|
607
|
+
if (!CLAIMED_NAME.test(name)) continue;
|
|
608
|
+
try {
|
|
609
|
+
const st = fs.lstatSync(path.join(dir, name));
|
|
610
|
+
if (st.isFile() && now - st.mtimeMs > CLAIMED_KEEP_MS) fs.unlinkSync(path.join(dir, name));
|
|
611
|
+
} catch {
|
|
612
|
+
// gone already, or not ours to remove
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Claims the project's live handoff by rename and returns its text when under a day old. The
|
|
619
|
+
* rename is the lock: a session whose rename fails lost the race and prints nothing, and a file
|
|
620
|
+
* outside the contract is left in place.
|
|
621
|
+
*/
|
|
622
|
+
function claimHandoff(dir, cwd) {
|
|
623
|
+
const now = Date.now();
|
|
624
|
+
sweepClaimed(dir, now);
|
|
625
|
+
const key = projectKey(cwd);
|
|
626
|
+
const live = path.join(dir, `${key}.md`);
|
|
627
|
+
if (!readHandoff(live)) return null;
|
|
628
|
+
const claimed = path.join(dir, `${key}.claimed-${now}.md`);
|
|
629
|
+
fs.renameSync(live, claimed);
|
|
630
|
+
// Read again after the rename: a newer handoff may have replaced the one checked above.
|
|
631
|
+
const got = readHandoff(claimed);
|
|
632
|
+
return got && now - got.mtimeMs <= HANDOFF_FRESH_MS ? got.text : null;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** The claimed handoff's sections, neutralized; null when there is nothing to print or any step fails. */
|
|
636
|
+
async function claimedHandoff(payload) {
|
|
637
|
+
try {
|
|
638
|
+
const dir = path.join(stateDir(), "handoff");
|
|
639
|
+
// A symlinked handoff dir would claim and delete wherever the link points.
|
|
640
|
+
if (!fs.lstatSync(dir).isDirectory()) return null;
|
|
641
|
+
const { neutralize, shared } = await loadNeutralize();
|
|
642
|
+
if (!shared) return null;
|
|
643
|
+
const cwd = typeof payload.cwd === "string" && path.isAbsolute(payload.cwd) ? payload.cwd : process.cwd();
|
|
644
|
+
const text = claimHandoff(dir, cwd);
|
|
645
|
+
if (text === null) return null;
|
|
646
|
+
const sections = parseSections(text.slice(text.indexOf("\n") + 1));
|
|
647
|
+
return Object.fromEntries(SECTIONS.map((s) => [s, sections[s] && sections[s] !== "(none)" ? neutralize(sections[s]) : ""]));
|
|
648
|
+
} catch {
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** The first `kept` of `lines`, then `(+N more)` for the rest. */
|
|
654
|
+
function keepLines(lines, kept) {
|
|
655
|
+
return kept >= lines.length ? lines.join("\n") : [...lines.slice(0, kept), `(+${lines.length - kept} more)`].join("\n");
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** Cuts a body to at most `max` characters ending in a `(cut)` line, never splitting a surrogate pair. */
|
|
659
|
+
function cutBody(body, max) {
|
|
660
|
+
if (body.length <= max) return body;
|
|
661
|
+
let keep = max - CUT_MARK.length - 1;
|
|
662
|
+
if (keep <= 0) return CUT_MARK;
|
|
663
|
+
if (/[\ud800-\udbff]/.test(body[keep - 1])) keep -= 1;
|
|
664
|
+
const head = body.slice(0, keep).trimEnd();
|
|
665
|
+
return head ? `${head}\n${CUT_MARK}` : CUT_MARK;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Largest count in [0, total] that `fits`; a binary search is sound because each dropped line outweighs the marker's growth. */
|
|
669
|
+
function largestFitting(total, fits) {
|
|
670
|
+
if (fits(total)) return total;
|
|
671
|
+
if (!fits(0)) return 0;
|
|
672
|
+
let lo = 0;
|
|
673
|
+
let hi = total;
|
|
674
|
+
while (hi - lo > 1) {
|
|
675
|
+
const mid = (lo + hi) >> 1;
|
|
676
|
+
if (fits(mid)) lo = mid;
|
|
677
|
+
else hi = mid;
|
|
678
|
+
}
|
|
679
|
+
return lo;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** Renders the re-orientation block from the shortened view; the eight headings always print. */
|
|
683
|
+
function renderReorientation(view) {
|
|
684
|
+
const lines = [`<freeharness_reorientation source="${view.source}">`, view.activeLine];
|
|
685
|
+
if (view.ctxLine) lines.push(view.ctxLine);
|
|
495
686
|
lines.push("Telemetry (.freeharness/telemetry.jsonl, last 3):");
|
|
496
|
-
|
|
687
|
+
const telemetryDropped = view.telemetry.length - view.telemetryKept;
|
|
688
|
+
if (view.telemetryKept > 0) lines.push(...view.telemetry.slice(0, view.telemetryKept).map((l) => ` ${l}`));
|
|
497
689
|
else if (telemetryDropped === 0) lines.push(" (none)");
|
|
498
690
|
if (telemetryDropped > 0) lines.push(` (+${telemetryDropped} more)`);
|
|
499
691
|
lines.push("Session file operations:");
|
|
500
|
-
if (
|
|
692
|
+
if (view.ops === null) {
|
|
501
693
|
lines.push(" (unavailable)");
|
|
502
694
|
} else {
|
|
503
|
-
|
|
695
|
+
const opsDropped = view.ops.length - view.opsKept;
|
|
696
|
+
if (view.opsKept > 0) lines.push(...view.ops.slice(0, view.opsKept).map((l) => ` - ${l}`));
|
|
504
697
|
else if (opsDropped === 0) lines.push(" (none)");
|
|
505
698
|
if (opsDropped > 0) lines.push(` - (+${opsDropped} more)`);
|
|
506
699
|
}
|
|
507
700
|
for (const s of SECTIONS) {
|
|
508
701
|
lines.push(`## ${s}`);
|
|
509
|
-
lines.push(
|
|
702
|
+
lines.push(view.bodies[s] || "(none)");
|
|
510
703
|
}
|
|
511
704
|
lines.push(RULE);
|
|
512
705
|
lines.push("</freeharness_reorientation>");
|
|
513
706
|
return lines.join("\n");
|
|
514
707
|
}
|
|
515
708
|
|
|
516
|
-
/**
|
|
517
|
-
function
|
|
709
|
+
/** Renders a claimed handoff; the eight headings always print. */
|
|
710
|
+
function renderHandoff(bodies) {
|
|
711
|
+
const lines = ["<freeharness_handoff>"];
|
|
712
|
+
for (const s of SECTIONS) lines.push(`## ${s}`, bodies[s] || "(none)");
|
|
713
|
+
lines.push("</freeharness_handoff>");
|
|
714
|
+
return lines.join("\n");
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** The single assembly point: the stable prefix, then the volatile tail in its fixed order (status lines, a claimed handoff once one exists, the re-orientation). */
|
|
718
|
+
function assembleContext(routingText, volatile) {
|
|
719
|
+
return [POINTER, routingText, ...volatile.filter(Boolean)].join("\n");
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** Shortens `bodies` in place until `assemble()` fits: L2 by whole lines, then the routing collapse and L1, then L0 by equal share. */
|
|
723
|
+
function fitSections(bodies, assemble, collapseRouting) {
|
|
724
|
+
const over = () => assemble().length - SHORTEN_AT;
|
|
725
|
+
const fits = () => over() <= 0;
|
|
726
|
+
for (const s of L2_SECTIONS) {
|
|
727
|
+
const lines = bodies[s] ? bodies[s].split("\n") : [];
|
|
728
|
+
bodies[s] = keepLines(lines, largestFitting(lines.length, (k) => ((bodies[s] = keepLines(lines, k)), fits())));
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (!fits()) collapseRouting();
|
|
732
|
+
for (const s of L1_SECTIONS) {
|
|
733
|
+
if (fits()) break;
|
|
734
|
+
if (bodies[s]) bodies[s] = cutBody(bodies[s], bodies[s].length - over());
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (!fits()) {
|
|
738
|
+
// Water-filling: a section under its share keeps all of it, and what it leaves goes to the longer ones.
|
|
739
|
+
const cut = L0_SECTIONS.filter((s) => bodies[s]).sort((a, b) => bodies[a].length - bodies[b].length);
|
|
740
|
+
let room = SHORTEN_AT - (assemble().length - cut.reduce((n, s) => n + bodies[s].length, 0));
|
|
741
|
+
cut.forEach((s, i) => {
|
|
742
|
+
bodies[s] = cutBody(bodies[s], Math.floor(room / (cut.length - i)));
|
|
743
|
+
room -= bodies[s].length;
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** Builds additionalContext; past SHORTEN_AT the tail shortens by level (see fitSections), never a heading or a pointer line. */
|
|
749
|
+
async function buildContext(payload) {
|
|
518
750
|
const source = typeof payload.source === "string" ? payload.source : "startup";
|
|
519
751
|
const project = process.env.CLAUDE_PROJECT_DIR || (typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd());
|
|
520
|
-
const
|
|
752
|
+
const status = statusLines(project).join("\n");
|
|
521
753
|
const routing = routingBlock(process.env.FH_ROUTING_FILE || "");
|
|
522
754
|
let routingText = routing.block !== null ? routing.block : "Routing table unavailable: run freeharness update --scope user.";
|
|
523
|
-
|
|
755
|
+
const collapseRouting = () => {
|
|
756
|
+
if (routing.block !== null && routing.path) routingText = `Routing table: ${routing.path} (Read it before routing).`;
|
|
757
|
+
};
|
|
758
|
+
if (!REORIENT.has(source)) {
|
|
759
|
+
const handoff = CLAIM.has(source) ? await claimedHandoff(payload) : null;
|
|
760
|
+
if (!handoff) return assembleContext(routingText, [status]);
|
|
761
|
+
const assemble = () => assembleContext(routingText, [status, renderHandoff(handoff)]);
|
|
762
|
+
fitSections(handoff, assemble, collapseRouting);
|
|
763
|
+
return assemble();
|
|
764
|
+
}
|
|
524
765
|
|
|
766
|
+
const { neutralize } = await loadNeutralize();
|
|
525
767
|
const sessionId = typeof payload.session_id === "string" ? payload.session_id : "";
|
|
526
768
|
const transcript = typeof payload.transcript_path === "string" ? payload.transcript_path : "";
|
|
527
769
|
const changes = activeChanges(project);
|
|
528
|
-
const activeLine = `Active change: ${changes.length > 0 ? changes.join("; ") : "(none)"}`;
|
|
529
|
-
const ctxLine = contextLine(sessionId);
|
|
530
770
|
const summary = precompactSummary(sessionId);
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
771
|
+
const cwd = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
|
|
772
|
+
const telemetry = telemetryTail(await telemetryRoot(cwd, project), 3);
|
|
773
|
+
const ops = fileOps(sessionId, transcript);
|
|
774
|
+
const view = {
|
|
775
|
+
source,
|
|
776
|
+
activeLine: `Active change: ${changes.length > 0 ? changes.join("; ") : "(none)"}`,
|
|
777
|
+
ctxLine: contextLine(sessionId),
|
|
778
|
+
telemetry,
|
|
779
|
+
telemetryKept: telemetry.length,
|
|
780
|
+
ops: ops === null ? null : ops.map(neutralize),
|
|
781
|
+
opsKept: ops === null ? 0 : ops.length,
|
|
782
|
+
bodies: Object.fromEntries(SECTIONS.map((s) => [s, summary[s] ? neutralize(summary[s]) : ""])),
|
|
783
|
+
};
|
|
535
784
|
|
|
536
|
-
const assemble = () =>
|
|
785
|
+
const assemble = () => assembleContext(routingText, [status, renderReorientation(view)]);
|
|
786
|
+
const fits = () => assemble().length <= SHORTEN_AT;
|
|
537
787
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
opsDropped += 1;
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
if (assemble().length > SHORTEN_AT) {
|
|
545
|
-
while (telemetryLines.length > 0 && assemble().length > SHORTEN_AT) {
|
|
546
|
-
telemetryLines = telemetryLines.slice(0, -1);
|
|
547
|
-
telemetryDropped += 1;
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
if (assemble().length > SHORTEN_AT && routing.block !== null && routing.path) {
|
|
551
|
-
routingText = `Routing table: ${routing.path} (Read it before routing).`;
|
|
552
|
-
}
|
|
788
|
+
view.telemetryKept = largestFitting(telemetry.length, (k) => ((view.telemetryKept = k), fits()));
|
|
789
|
+
if (view.ops !== null) view.opsKept = largestFitting(view.ops.length, (k) => ((view.opsKept = k), fits()));
|
|
790
|
+
fitSections(view.bodies, assemble, collapseRouting);
|
|
553
791
|
return assemble();
|
|
554
792
|
}
|
|
555
793
|
|
|
556
794
|
/** Entry point: always print the pointer (at least). */
|
|
557
|
-
function main() {
|
|
795
|
+
async function main() {
|
|
558
796
|
if ((process.env.FREEHARNESS_HOOK_SESSION_START || "").toLowerCase() === "off") return;
|
|
559
797
|
let payload = {};
|
|
560
798
|
try {
|
|
@@ -566,14 +804,14 @@ function main() {
|
|
|
566
804
|
if (!payload || typeof payload !== "object") payload = {};
|
|
567
805
|
let context = POINTER;
|
|
568
806
|
try {
|
|
569
|
-
context = buildContext(payload);
|
|
807
|
+
context = await buildContext(payload);
|
|
570
808
|
} catch (e) {
|
|
571
809
|
process.stderr.write(`freeharness-session-start: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
572
810
|
}
|
|
573
811
|
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context } }));
|
|
574
812
|
}
|
|
575
813
|
|
|
576
|
-
main();
|
|
814
|
+
await main();
|
|
577
815
|
JS
|
|
578
816
|
then exit 0; fi
|
|
579
817
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$comment": "FreeHarness SessionStart hook fragment for Claude Code (rules capability). The installer merges this into <project>/.claude/settings.json additively (never removing existing entries, e.g. the upstream claude-mem plugin at user level is untouched). startup|clear: the short pointer. compact|resume|fork: the pointer plus the re-orientation block (active change id, dogfood tail, session file operations, the eight-section checkpoint pre-filled from ~/.claude/freeharness/state/<session>.precompact.md, the re-read rule). Disable: FREEHARNESS_HOOK_SESSION_START=off or remove this entry.",
|
|
2
|
+
"$comment": "FreeHarness SessionStart hook fragment for Claude Code (rules capability). The installer merges this into <project>/.claude/settings.json additively (never removing existing entries, e.g. the upstream claude-mem plugin at user level is untouched). The output puts the stable part first (the pointer, the routing block) and the volatile part last, under 10,000 characters, shortened in levels. startup|clear: the short pointer, then the project's saved handoff, claimed once by renaming it (a second session finds nothing; a handoff over 24 hours old is renamed unprinted). compact|resume|fork: the pointer plus the re-orientation block (active change id, dogfood tail, session file operations, the eight-section checkpoint pre-filled from ~/.claude/freeharness/state/<session>.precompact.md, the re-read rule). Disable: FREEHARNESS_HOOK_SESSION_START=off or remove this entry.",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"SessionStart": [
|
|
5
5
|
{
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$comment": "FreeHarness config guard for Claude Code (rules capability). Merged additively into settings.json. PreToolUse on Edit|Write|MultiEdit|Bash: denies writes to
|
|
2
|
+
"$comment": "FreeHarness config guard for Claude Code (rules capability). Merged additively into settings.json. PreToolUse on Edit|Write|MultiEdit|Bash: denies writes to live harness configuration, which runs code or loads instructions in every session, unless <project>/.freeharness/allow-config-edit (or ~/.claude/freeharness/allow-config-edit) exists and is younger than --ttl minutes (240 minutes; a numeric epoch-ms or ISO body sets an explicit expiry). The live set is judged by location at the written and the real path: settings.json and settings.local.json inside a .claude dir, any .mcp.json, ~/.claude.json, ~/.claude/CLAUDE.md, everything under ~/.claude/plugins and under the hooks dir of a .claude, .freeharness, ~/.claude/freeharness or freeharness-plugin dir, a plugin's hooks/hooks.json, the marker, and the dirs whose removal takes one of them (CLAUDE_CONFIG_DIR gets every ~/.claude rule); a project CLAUDE.md, a settings.json, hooks.json or hook source elsewhere and a copy in the temp root outside the home and project pass. Bash is judged per simple command (split on ;, &&, ||, |, & and newlines) from the payload cwd moved by each literal cd, pushd or popd: a simple command naming a live path is a write, denied by default, unless its command word is on a closed read-only list (cat, grep, jq, ls, ...) and no redirect targets the path; reads and quoted mentions pass, and a command longer than 16,384 characters or one that cannot be split is a write when its raw text names a protected path. The deny reason names at most 10 targets followed by \"and N more\", the marker and the `touch` one-liners for the user; a deny output of 16,384 bytes or more is replaced by a short reason naming no payload text. `freeharness init`/`update` set and remove their own marker. The entry carries onFailure \"block\", so a guard hook that cannot start, crashes or times out blocks the tool call on Claude Code 2.1.295 and later; a missing node then blocks every Edit, Write and Bash call until node is installed or the entry is removed by hand, and the switches below are read inside node, so they cannot help when node is missing. The same hook runs a git guard independent of the marker: history-destroying git commands (reset --hard, --keep or --merge; push with --force, -f or a + refspec that no --force-with-lease covers, and push --mirror, --delete/-d, --prune or a :refspec, which a lease never excuses; branch -D, -M, -C, -f or --force; clean -f; checkout -- <path>, checkout ., checkout <tree> <path>, checkout -f or -B; switch --discard-changes, -f or -C; restore without --staged alone; stash drop/clear; reflog expire; filter-branch; worktree remove --force) and git config that can add an alias or run code (set through -c, --config-env, GIT_CONFIG_* variables or git config: alias.*, include.*, help.autocorrect, clean.requireForce, core.pager, core.fsmonitor and every key outside a safe list) are denied from the model whatever the marker says, and the user runs an intended one with the `!` prefix; nesting deeper than 8 levels or an internal error denies with a reason naming no payload text; a value bound earlier on the line substitutes into git verb, option and operand slots before the check, interpreter code counts as git only with a process-spawning call and a git word, sed, grep and awk scripts are data, and an unknown expansion in the command-word slot or an xargs {} placeholder in one fails closed; every other git command, a git command inside a quoted argument, push --force-with-lease, object-writing plumbing (commit-tree, mktree, hash-object) and worktree remove --force under the temp root pass; FREEHARNESS_HOOK_GIT_GUARD=off disables only the git guard. Deny reasons render invisible and control characters as \\u{XXXX} escapes and cap each echoed fragment at 200 characters with an ellipsis. Disable the whole hook: FREEHARNESS_HOOK_CONFIG_GUARD=off or remove this entry.",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"PreToolUse": [
|
|
5
5
|
{
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
"hooks": [
|
|
8
8
|
{
|
|
9
9
|
"type": "command",
|
|
10
|
-
"command": "node \"${CLAUDE_PROJECT_DIR}/.freeharness/hooks/freeharness-config-guard.mjs\" --ttl
|
|
10
|
+
"command": "node \"${CLAUDE_PROJECT_DIR}/.freeharness/hooks/freeharness-config-guard.mjs\" --ttl 240",
|
|
11
|
+
"onFailure": "block"
|
|
11
12
|
}
|
|
12
13
|
]
|
|
13
14
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$comment": "FreeHarness context monitor for Claude Code (cost capability). Merged additively into settings.json. Stop: reads the session's latest cost snapshot (context_tokens) and prints one systemMessage line to the user when the context crosses 60% and then 85% of --budget tokens (200k), naming the `# Compact instructions` block of CLAUDE.md for /compact. Each threshold once per session (~/.claude/freeharness/state/<session>.context.json). Never a Stop additionalContext (that would force another turn), never blocks. Disable: FREEHARNESS_HOOK_CONTEXT_MONITOR=off or remove this entry.",
|
|
2
|
+
"$comment": "FreeHarness context monitor for Claude Code (cost capability). Merged additively into settings.json. Stop: reads the session's latest cost snapshot (context_tokens) and prints one systemMessage line to the user when the context crosses 60% and then 85% of --budget tokens (200k), naming the `# Compact instructions` block of CLAUDE.md for /compact. At the highest threshold it first writes the project handoff (the eight-section skeleton, atomically, under ~/.claude/freeharness/state/handoff/) that the next startup or clear session claims once, and suggests /clear. Each threshold once per session (~/.claude/freeharness/state/<session>.context.json). Never a Stop additionalContext (that would force another turn), never blocks. Disable: FREEHARNESS_HOOK_CONTEXT_MONITOR=off or remove this entry.",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"Stop": [
|
|
5
5
|
{
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$comment": "FreeHarness converge loop for Claude Code (sdd capability; shape from the anthropics/claude-code ralph-loop plugin, Apache-2.0, fitted to tasks.md). Merged additively into settings.json. Stop: a no-op unless <project>/.freeharness/converge.json exists ({\"change\": \"<id>\", \"max_rounds\": 5, \"promise\": \"<exit condition>\"}). While openspec/changes/<change>/tasks.md has unchecked tasks and rounds < max_rounds it returns {\"decision\":\"block\",\"reason\":\"<next task + its Verify line>\"} so the agent keeps working; every round is appended to .freeharness/converge.log; at the limit or when every task is checked the session ends with a systemMessage. A tasks.md that is missing or has no checkbox lines disables the loop (converge.json gets \"disabled\": true + reason). Disable: FREEHARNESS_HOOK_CONVERGE=off, delete converge.json, or remove this entry.",
|
|
2
|
+
"$comment": "FreeHarness converge loop for Claude Code (sdd capability; shape from the anthropics/claude-code ralph-loop plugin, Apache-2.0, fitted to tasks.md). Merged additively into settings.json. Stop: a no-op unless <project>/.freeharness/converge.json exists ({\"change\": \"<id>\", \"max_rounds\": 5, \"promise\": \"<exit condition>\"}). While openspec/changes/<change>/tasks.md has unchecked tasks and rounds < max_rounds it returns {\"decision\":\"block\",\"reason\":\"<next task + its Verify line>\"} so the agent keeps working; every round is appended to .freeharness/converge.log; a linked or special .freeharness, converge.json or converge.log turns the loop off silently (a symlink, a hard-linked file, a FIFO or any other non-regular file: no block, no round, no log line); at the limit or when every task is checked the session ends with a systemMessage. A tasks.md that is missing or has no checkbox lines disables the loop (converge.json gets \"disabled\": true + reason). Disable: FREEHARNESS_HOOK_CONVERGE=off, delete converge.json, or remove this entry.",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"Stop": [
|
|
5
5
|
{
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$comment": "FreeHarness PreToolUse memory guard for Claude Code. Merged additively into <project>/.claude/settings.json. Denies hand-written memory .md writes (the free-mem plugin owns memory; saves output tokens).",
|
|
2
|
+
"$comment": "FreeHarness PreToolUse memory guard for Claude Code. Merged additively into <project>/.claude/settings.json. Denies hand-written memory .md writes (the free-mem plugin owns memory; saves output tokens). The entry carries onFailure \"block\", so a guard hook that cannot start, crashes or times out blocks the tool call on Claude Code 2.1.295 and later; a missing node then blocks every Write, Edit and MultiEdit call until node is installed or the entry is removed by hand.",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"PreToolUse": [
|
|
5
5
|
{
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
"hooks": [
|
|
8
8
|
{
|
|
9
9
|
"type": "command",
|
|
10
|
-
"command": "node \"${CLAUDE_PROJECT_DIR}/.freeharness/hooks/freeharness-memory-guard.mjs\""
|
|
10
|
+
"command": "node \"${CLAUDE_PROJECT_DIR}/.freeharness/hooks/freeharness-memory-guard.mjs\"",
|
|
11
|
+
"onFailure": "block"
|
|
11
12
|
}
|
|
12
13
|
]
|
|
13
14
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "FreeHarness reload-context recap for Claude Code (rules capability). Merged additively into settings.json. UserPromptSubmit: only the prompt whose trimmed, lower-cased text is exactly `reload context` gets an additionalContext, the newest of the project's unclaimed handoff and the session's PreCompact skeleton rendered as the SessionStart re-orientation under 10,000 characters, or one no-saved-context line; every other prompt exits silently. Reading never claims the handoff. Never blocks. Disable: FREEHARNESS_HOOK_RELOAD_CONTEXT=off or remove the entry.",
|
|
3
|
+
"hooks": {
|
|
4
|
+
"UserPromptSubmit": [
|
|
5
|
+
{
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "node \"${CLAUDE_PROJECT_DIR}/.freeharness/hooks/freeharness-reload-context.mjs\""
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
}
|