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,14 +1,9 @@
|
|
|
1
|
-
// FreeHarness PreToolUse memory guard
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// source, and otherwise a .md file under packages/memory/ or vendor/ is allowed as a
|
|
8
|
-
// repository source tree.
|
|
9
|
-
// Reads the hook payload from stdin; emits a deny decision or nothing.
|
|
10
|
-
// The path is normalized (`.`/`..` segments collapsed) before any exemption or deny
|
|
11
|
-
// regex runs, so a traversal segment cannot smuggle a denied path through an exempt one.
|
|
1
|
+
// FreeHarness PreToolUse memory guard: free-mem is the only memory owner, so hand-written
|
|
2
|
+
// memory .md files are denied (duplicates also waste output tokens). Exempt: free-mem data,
|
|
3
|
+
// openspec, docs, node_modules. A name that only contains the word (ai-memory.md) is an
|
|
4
|
+
// ordinary document; packages/memory/ and vendor/ are source, but never under .claude/.
|
|
5
|
+
// The deny reason is constant: an echoed path could carry bidi characters.
|
|
6
|
+
// The path is normalized first, so a `..` segment is judged where it lands.
|
|
12
7
|
import { posix } from "node:path";
|
|
13
8
|
|
|
14
9
|
let data = "";
|
|
@@ -18,26 +13,29 @@ process.stdin.on("end", () => {
|
|
|
18
13
|
try {
|
|
19
14
|
j = JSON.parse(data);
|
|
20
15
|
} catch {
|
|
21
|
-
|
|
16
|
+
return;
|
|
22
17
|
}
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
if (j === null || typeof j !== "object" || Array.isArray(j)) return;
|
|
19
|
+
const tool = typeof j.tool_name === "string" ? j.tool_name : "";
|
|
20
|
+
if (!/^(Write|Edit|MultiEdit)$/.test(tool)) return;
|
|
25
21
|
|
|
26
22
|
const ti = j.tool_input || {};
|
|
27
|
-
const
|
|
28
|
-
|
|
23
|
+
const field = ti.file_path || ti.path || "";
|
|
24
|
+
let raw = "";
|
|
25
|
+
if (typeof field === "string") raw = field;
|
|
26
|
+
else if (Array.isArray(field) && field.every((e) => typeof e === "string")) raw = field.join(",");
|
|
27
|
+
if (!raw) return;
|
|
29
28
|
|
|
30
|
-
const p = posix.normalize(
|
|
29
|
+
const p = posix.normalize(raw.replace(/\\/g, "/"));
|
|
31
30
|
const base = p.split("/").pop() || "";
|
|
32
31
|
|
|
33
|
-
if (/\/\.claude-mem(\/|$)/.test(p))
|
|
34
|
-
if (/\/(openspec|docs|node_modules)\//.test(p))
|
|
35
|
-
if (/\/\.claude\/agent-memory(-local)?\//.test(p)) process.exit(0);
|
|
32
|
+
if (/\/\.(free|claude)-mem(\/|$)/.test(p)) return;
|
|
33
|
+
if (/\/(openspec|docs|node_modules)\//.test(p)) return;
|
|
36
34
|
|
|
37
35
|
const md = /\.md$/i.test(base);
|
|
38
36
|
const noext = base.replace(/\.md$/i, "");
|
|
39
|
-
const memName = md &&
|
|
40
|
-
const memDir = md && /\/
|
|
37
|
+
const memName = md && /^(memory|memoria|memories|memorias)$/i.test(noext);
|
|
38
|
+
const memDir = md && /\/(memory|memories|memoria|memorias|agent-memory|agent-memory-local)\//i.test(p);
|
|
41
39
|
const claudeDataTree = /\/\.claude\//.test(p);
|
|
42
40
|
const sourceTree = !claudeDataTree && /\/(packages\/memory|vendor)\//.test(p);
|
|
43
41
|
|
|
@@ -53,5 +51,4 @@ process.stdin.on("end", () => {
|
|
|
53
51
|
}),
|
|
54
52
|
);
|
|
55
53
|
}
|
|
56
|
-
process.exit(0);
|
|
57
54
|
});
|
|
@@ -1,18 +1,10 @@
|
|
|
1
|
-
// FreeHarness PreCompact hook (Claude Code).
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// ~/.claude/freeharness/state/<session>.fileops.json — [{path, tool, count}] from the
|
|
9
|
-
// Read/Edit/Write/MultiEdit/NotebookEdit tool_use blocks of the transcript.
|
|
10
|
-
// It also asks the running memory worker to store a session summary
|
|
11
|
-
// (POST /api/sessions/summarize, the same route the Stop hook uses; it queues, it does not
|
|
12
|
-
// return text) so the compaction point lands in memory. The worker being down is normal:
|
|
13
|
-
// the request is skipped, the local skeleton is still written. Never blocks, never prints,
|
|
14
|
-
// exit 0 on every path (a PreCompact hook cannot inject context anyway).
|
|
15
|
-
// Disable: FREEHARNESS_HOOK_PRECOMPACT=off. Worker call off: FREEHARNESS_PRECOMPACT_WORKER=off.
|
|
1
|
+
// FreeHarness PreCompact hook (Claude Code). Before compaction it writes, for the SessionStart
|
|
2
|
+
// re-orientation, `<state>/<session>.precompact.md` (the eight-section skeleton built from the
|
|
3
|
+
// transcript, paths and errors verbatim) and `<session>.fileops.json`, then asks a running
|
|
4
|
+
// free-mem worker for a session summary so the compaction point lands in memory; a down worker
|
|
5
|
+
// is normal, and another program on the port gets a health read only. The context monitor imports the same builder for its handoff. Never blocks,
|
|
6
|
+
// never prints, exit 0 on every path. Disable: FREEHARNESS_HOOK_PRECOMPACT=off; worker call
|
|
7
|
+
// off: FREEHARNESS_PRECOMPACT_WORKER=off.
|
|
16
8
|
import fs from "node:fs";
|
|
17
9
|
import os from "node:os";
|
|
18
10
|
import path from "node:path";
|
|
@@ -35,55 +27,37 @@ const MAX_BODY_CHARS = 1500;
|
|
|
35
27
|
/** Worker request timeout (ms): a PreCompact hook must never hold the compaction. */
|
|
36
28
|
const WORKER_TIMEOUT_MS = 2000;
|
|
37
29
|
|
|
30
|
+
/** Bounds on the file operations sent to the worker, so a long session cannot inflate the request. */
|
|
31
|
+
const MAX_SENT_FILE_OPS = 50;
|
|
32
|
+
const MAX_SENT_PATH_CHARS = 1024;
|
|
33
|
+
const MAX_SENT_TOOL_CHARS = 64;
|
|
34
|
+
|
|
38
35
|
/** Error markers (case-insensitive) that make a tool result an "Errors and Fixes" line. */
|
|
39
36
|
const ERROR_RE = /\b(error|ENOENT|EACCES|EEXIST|failed|failure|Traceback|exception|not found|permission denied|cannot|exit code [1-9])\b/i;
|
|
40
37
|
|
|
41
38
|
/** User turns that read as a correction of the assistant. */
|
|
42
39
|
const CORRECTION_RE = /^(no[,.! ]|not (that|this|what)|wrong|don'?t |do not |stop |instead[, ]|that'?s (not|wrong)|actually[, ]|undo|revert|never )/i;
|
|
43
40
|
|
|
44
|
-
/**
|
|
45
|
-
* State directory (FREEHARNESS_STATE_DIR overrides).
|
|
46
|
-
* @returns {string} Absolute path.
|
|
47
|
-
*/
|
|
41
|
+
/** @returns {string} The state dir (FREEHARNESS_STATE_DIR overrides). */
|
|
48
42
|
export function stateDir() {
|
|
49
43
|
return process.env.FREEHARNESS_STATE_DIR || path.join(os.homedir(), ".claude", "freeharness", "state");
|
|
50
44
|
}
|
|
51
45
|
|
|
52
|
-
/**
|
|
53
|
-
* Sanitizes a session id for use as a file name.
|
|
54
|
-
* @param {string} id Session id.
|
|
55
|
-
* @returns {string} Safe name.
|
|
56
|
-
*/
|
|
57
46
|
function safeId(id) {
|
|
58
47
|
return String(id || "").replace(/[^\w.-]/g, "_") || "unknown";
|
|
59
48
|
}
|
|
60
49
|
|
|
61
|
-
/**
|
|
62
|
-
* Trims a string to one line of at most MAX_LINE_CHARS, marking the cut.
|
|
63
|
-
* @param {string} s Text.
|
|
64
|
-
* @param {number} [max] Cap.
|
|
65
|
-
* @returns {string} One line.
|
|
66
|
-
*/
|
|
67
50
|
function oneLine(s, max = MAX_LINE_CHARS) {
|
|
68
51
|
const t = String(s).replace(/\s+/g, " ").trim();
|
|
69
52
|
return t.length > max ? `${t.slice(0, max)} …[+${t.length - max} chars]` : t;
|
|
70
53
|
}
|
|
71
54
|
|
|
72
|
-
/**
|
|
73
|
-
* Caps a multi-line body, keeping the head.
|
|
74
|
-
* @param {string} s Text.
|
|
75
|
-
* @returns {string} Body.
|
|
76
|
-
*/
|
|
77
55
|
function capBody(s) {
|
|
78
56
|
const t = String(s).trim();
|
|
79
57
|
return t.length > MAX_BODY_CHARS ? `${t.slice(0, MAX_BODY_CHARS)}\n…[+${t.length - MAX_BODY_CHARS} chars]` : t;
|
|
80
58
|
}
|
|
81
59
|
|
|
82
|
-
/**
|
|
83
|
-
* Text of a message content (string or text blocks), tool results excluded.
|
|
84
|
-
* @param {unknown} content Message content.
|
|
85
|
-
* @returns {string} Joined text.
|
|
86
|
-
*/
|
|
60
|
+
/** Text blocks of a message content only: tool results are not prose. */
|
|
87
61
|
function textOf(content) {
|
|
88
62
|
if (typeof content === "string") return content;
|
|
89
63
|
if (!Array.isArray(content)) return "";
|
|
@@ -93,22 +67,12 @@ function textOf(content) {
|
|
|
93
67
|
.join("\n");
|
|
94
68
|
}
|
|
95
69
|
|
|
96
|
-
/**
|
|
97
|
-
* True for user turns that are not real prompts (system reminders, slash-command echoes,
|
|
98
|
-
* tool results, interruption notices).
|
|
99
|
-
* @param {string} text User text.
|
|
100
|
-
* @returns {boolean} Skip it.
|
|
101
|
-
*/
|
|
70
|
+
/** User turns that are not prompts: system reminders, command echoes, interruption notices. */
|
|
102
71
|
function isMetaUserText(text) {
|
|
103
72
|
const t = text.trim();
|
|
104
73
|
return !t || t.startsWith("<") || t.startsWith("[Request interrupted") || /^Caveat: The messages below/.test(t);
|
|
105
74
|
}
|
|
106
75
|
|
|
107
|
-
/**
|
|
108
|
-
* Text of a tool_result block (string or nested text blocks).
|
|
109
|
-
* @param {any} block tool_result block.
|
|
110
|
-
* @returns {string} Text.
|
|
111
|
-
*/
|
|
112
76
|
function toolResultText(block) {
|
|
113
77
|
if (typeof block.content === "string") return block.content;
|
|
114
78
|
if (Array.isArray(block.content)) return textOf(block.content);
|
|
@@ -149,7 +113,8 @@ export function parseTranscript(transcript) {
|
|
|
149
113
|
if (typeof rec.gitBranch === "string" && rec.gitBranch) branches.add(rec.gitBranch);
|
|
150
114
|
const msg = rec.message;
|
|
151
115
|
if (!msg || typeof msg !== "object") continue;
|
|
152
|
-
|
|
116
|
+
if (rec.type !== "user" && rec.type !== "assistant") continue;
|
|
117
|
+
const role = rec.type;
|
|
153
118
|
const content = Array.isArray(msg.content) ? msg.content : [];
|
|
154
119
|
if (role === "assistant") {
|
|
155
120
|
const t = textOf(msg.content);
|
|
@@ -183,13 +148,8 @@ export function parseTranscript(transcript) {
|
|
|
183
148
|
return { users, assistants, fileOps, errors, cwds, branches };
|
|
184
149
|
}
|
|
185
150
|
|
|
186
|
-
/**
|
|
187
|
-
|
|
188
|
-
* @param {ReturnType<typeof parseTranscript>} facts Facts (null = unreadable transcript).
|
|
189
|
-
* @param {Record<string, any>} payload Hook payload.
|
|
190
|
-
* @returns {string} Markdown with one `## <Section>` heading per section.
|
|
191
|
-
*/
|
|
192
|
-
export function renderSkeleton(facts, payload) {
|
|
151
|
+
/** Body per section name, "" when empty; null facts (unreadable transcript) leave all empty. */
|
|
152
|
+
function sectionBodies(facts, payload) {
|
|
193
153
|
const body = {};
|
|
194
154
|
const f = facts || { users: [], assistants: [], fileOps: [], errors: [], cwds: new Set(), branches: new Set() };
|
|
195
155
|
const dedupe = (arr) => [...new Set(arr)];
|
|
@@ -212,8 +172,11 @@ export function renderSkeleton(facts, payload) {
|
|
|
212
172
|
if (typeof payload.custom_instructions === "string" && payload.custom_instructions.trim()) critical.push(`compact instructions: ${oneLine(payload.custom_instructions)}`);
|
|
213
173
|
body["Critical Context"] = critical.join("\n");
|
|
214
174
|
body["Corrections"] = dedupe(f.users.filter((u) => CORRECTION_RE.test(u.trim())).map((u) => `- ${oneLine(u)}`)).slice(-MAX_ITEMS).join("\n");
|
|
175
|
+
return body;
|
|
176
|
+
}
|
|
215
177
|
|
|
216
|
-
|
|
178
|
+
function renderSections(body) {
|
|
179
|
+
const lines = [];
|
|
217
180
|
for (const s of SECTIONS) {
|
|
218
181
|
lines.push(`## ${s}`);
|
|
219
182
|
lines.push(body[s] && body[s].trim() ? body[s] : "(none)");
|
|
@@ -222,56 +185,223 @@ export function renderSkeleton(facts, payload) {
|
|
|
222
185
|
return `${lines.join("\n").trimEnd()}\n`;
|
|
223
186
|
}
|
|
224
187
|
|
|
188
|
+
/** The PreCompact file: a stamp line, then the eight sections. */
|
|
189
|
+
export function renderSkeleton(facts, payload) {
|
|
190
|
+
return `<!-- freeharness-precompact ${new Date().toISOString()} session=${safeId(payload.session_id)} -->\n${renderSections(sectionBodies(facts, payload))}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Transcript text must not reach a session as an envelope or system tag: `<a` and `</` become `<\a` and `<\/`. */
|
|
194
|
+
export function neutralize(text) {
|
|
195
|
+
return String(text).replace(/<(?=[\p{L}/])/gu, "<\\");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** L2 is cut first; L0 holds what the re-read rule names, so it goes last. */
|
|
199
|
+
const CUT_LEVELS = [
|
|
200
|
+
["Files and Code", "Pending Jobs"],
|
|
201
|
+
["Primary Request", "Critical Context", "Corrections"],
|
|
202
|
+
["Current Work", "Errors and Fixes", "Next Step"],
|
|
203
|
+
];
|
|
204
|
+
const LIST_SECTIONS = new Set(["Files and Code", "Pending Jobs", "Errors and Fixes", "Corrections"]);
|
|
205
|
+
|
|
206
|
+
/** Lists keep whole lines behind `(+N more)`; prose, or a list too long in its first line, keeps its head behind `(cut)`. */
|
|
207
|
+
function cutBody(body, room, list) {
|
|
208
|
+
const lines = body.split("\n");
|
|
209
|
+
if (list && lines.length > 1) {
|
|
210
|
+
for (let keep = lines.length - 1; keep >= 1; keep--) {
|
|
211
|
+
const out = `${lines.slice(0, keep).join("\n")}\n(+${lines.length - keep} more)`;
|
|
212
|
+
if (Buffer.byteLength(out) <= room) return out;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const marker = "(cut)";
|
|
216
|
+
let head = "";
|
|
217
|
+
let used = Buffer.byteLength(` ${marker}`);
|
|
218
|
+
for (const ch of body) {
|
|
219
|
+
const n = Buffer.byteLength(ch);
|
|
220
|
+
if (used + n > room) break;
|
|
221
|
+
head += ch;
|
|
222
|
+
used += n;
|
|
223
|
+
}
|
|
224
|
+
return head.trim() ? `${head.trimEnd()} ${marker}` : marker;
|
|
225
|
+
}
|
|
226
|
+
|
|
225
227
|
/**
|
|
226
|
-
*
|
|
227
|
-
* @
|
|
228
|
+
* The eight sections of a transcript without the stamp line; null when it is unreadable.
|
|
229
|
+
* @param {{mapBody?: (s: string) => string, maxBytes?: number}} [opts] `mapBody` rewrites each
|
|
230
|
+
* body; `maxBytes` caps the result in the L2, L1, L0 cut order.
|
|
228
231
|
*/
|
|
229
|
-
export function
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
232
|
+
export function buildSkeleton(transcriptPath, payload = {}, opts = {}) {
|
|
233
|
+
const facts = parseTranscript(transcriptPath);
|
|
234
|
+
return facts ? skeletonFromFacts(facts, payload, opts) : null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** buildSkeleton over parsed facts, for a caller that needs the facts too and parses once. */
|
|
238
|
+
export function skeletonFromFacts(facts, payload, opts = {}) {
|
|
239
|
+
const body = sectionBodies(facts, payload);
|
|
240
|
+
if (typeof opts.mapBody === "function") for (const s of SECTIONS) body[s] = opts.mapBody(body[s]);
|
|
241
|
+
const max = opts.maxBytes;
|
|
242
|
+
if (!Number.isFinite(max)) return renderSections(body);
|
|
243
|
+
for (const level of CUT_LEVELS) {
|
|
244
|
+
for (const s of level) {
|
|
245
|
+
const over = Buffer.byteLength(renderSections(body)) - max;
|
|
246
|
+
if (over <= 0) return renderSections(body);
|
|
247
|
+
if (!body[s] || !body[s].trim()) continue;
|
|
248
|
+
body[s] = cutBody(body[s], Math.max(0, Buffer.byteLength(body[s]) - over), LIST_SECTIONS.has(s));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return renderSections(body);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// The engine's data-dir and port rule; an emitted hook cannot import the engine.
|
|
255
|
+
const MEM_DATA_DIR_KEYS = ["FREE_MEM_DATA_DIR", "CLAUDE_MEM_DATA_DIR"];
|
|
256
|
+
const BOM = 0xfeff;
|
|
257
|
+
|
|
258
|
+
function expandMemHome(p, home) {
|
|
259
|
+
if (p === "~") return home;
|
|
260
|
+
return p.startsWith("~/") ? path.join(home, p.slice(2)) : p;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function isMemDirectory(p) {
|
|
233
264
|
try {
|
|
234
|
-
|
|
235
|
-
const s = (raw && raw.env) || raw || {};
|
|
236
|
-
if (typeof s.CLAUDE_MEM_DATA_DIR === "string") return expand(s.CLAUDE_MEM_DATA_DIR);
|
|
265
|
+
return fs.statSync(p).isDirectory();
|
|
237
266
|
} catch {
|
|
238
|
-
|
|
267
|
+
return false;
|
|
239
268
|
}
|
|
240
|
-
return def;
|
|
241
269
|
}
|
|
242
270
|
|
|
243
|
-
/**
|
|
244
|
-
|
|
245
|
-
* @returns {number} Port.
|
|
246
|
-
*/
|
|
247
|
-
export function workerPort() {
|
|
248
|
-
const fromEnv = Number.parseInt(process.env.CLAUDE_MEM_WORKER_PORT || "", 10);
|
|
249
|
-
if (Number.isFinite(fromEnv) && fromEnv > 0) return fromEnv;
|
|
271
|
+
/** Keys as the engine reads them: BOM dropped, `env` first. */
|
|
272
|
+
function readMemSettings(file) {
|
|
250
273
|
try {
|
|
251
|
-
const
|
|
252
|
-
const
|
|
253
|
-
const
|
|
254
|
-
|
|
274
|
+
const text = fs.readFileSync(file, "utf8");
|
|
275
|
+
const raw = JSON.parse(text.charCodeAt(0) === BOM ? text.slice(1) : text);
|
|
276
|
+
const s = raw && (raw.env ?? raw);
|
|
277
|
+
return s && typeof s === "object" ? s : null;
|
|
255
278
|
} catch {
|
|
256
|
-
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function memPort(value) {
|
|
284
|
+
const p = Number.parseInt(String(value ?? ""), 10);
|
|
285
|
+
return Number.isFinite(p) && p > 0 ? p : null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The memory data directory from `env` and `home` alone, so a test can pass fake values. */
|
|
289
|
+
export function resolveMemoryDataDir(env, home) {
|
|
290
|
+
for (const key of MEM_DATA_DIR_KEYS) {
|
|
291
|
+
if (typeof env[key] === "string" && env[key]) return expandMemHome(env[key], home);
|
|
257
292
|
}
|
|
293
|
+
const freeDir = path.join(home, ".free-mem");
|
|
294
|
+
const legacyDir = path.join(home, ".claude-mem");
|
|
295
|
+
const defaults = [path.resolve(freeDir), path.resolve(legacyDir)];
|
|
296
|
+
for (const dir of [freeDir, legacyDir]) {
|
|
297
|
+
const s = readMemSettings(path.join(dir, "settings.json"));
|
|
298
|
+
for (const key of MEM_DATA_DIR_KEYS) {
|
|
299
|
+
const value = s ? s[key] : undefined;
|
|
300
|
+
if (typeof value !== "string" || !value) continue;
|
|
301
|
+
const target = expandMemHome(value, home);
|
|
302
|
+
// A seeded default is not a redirect; honoring it would pin the directory choice.
|
|
303
|
+
if (!defaults.includes(path.resolve(target))) return target;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return isMemDirectory(freeDir) || !isMemDirectory(legacyDir) ? freeDir : legacyDir;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** The worker port: env (empty is unset), then the data dir's settings, else the default; pure over (env, home). */
|
|
310
|
+
export function resolveWorkerPort(env, home) {
|
|
311
|
+
const fromEnv = memPort(env.FREE_MEM_WORKER_PORT || env.CLAUDE_MEM_WORKER_PORT);
|
|
312
|
+
if (fromEnv !== null) return fromEnv;
|
|
313
|
+
const s = readMemSettings(path.join(resolveMemoryDataDir(env, home), "settings.json"));
|
|
314
|
+
const fromSettings = s ? memPort(s.FREE_MEM_WORKER_PORT ?? s.CLAUDE_MEM_WORKER_PORT) : null;
|
|
315
|
+
return fromSettings ?? defaultWorkerPort();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function defaultWorkerPort() {
|
|
258
319
|
return 37700 + (((typeof process.getuid === "function" ? process.getuid() : 77) || 0) % 100);
|
|
259
320
|
}
|
|
260
321
|
|
|
322
|
+
/** A resolver error falls back to the default port, as an unreadable settings file does. */
|
|
323
|
+
export function workerPort() {
|
|
324
|
+
try {
|
|
325
|
+
return resolveWorkerPort(process.env, os.homedir());
|
|
326
|
+
} catch {
|
|
327
|
+
return defaultWorkerPort();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Another program may hold the port; the engine's FREE_MEM_PRODUCT, copied because this script cannot import it (a test pins both). */
|
|
332
|
+
export const FREE_MEM_PRODUCT = "free-mem";
|
|
333
|
+
const MAX_HEALTH_BYTES = 64 * 1024;
|
|
334
|
+
|
|
335
|
+
/** Nothing from the answer leaves this function: the port may belong to an unknown program. */
|
|
336
|
+
export async function workerIsFreeMem(port, deadlineMs) {
|
|
337
|
+
try {
|
|
338
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535 || !(deadlineMs > 0)) return false;
|
|
339
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(deadlineMs) });
|
|
340
|
+
if (res.status !== 200 || !res.body) {
|
|
341
|
+
res.body?.cancel().catch(() => {});
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
const reader = res.body.getReader();
|
|
345
|
+
const chunks = [];
|
|
346
|
+
let total = 0;
|
|
347
|
+
for (;;) {
|
|
348
|
+
const { done, value } = await reader.read();
|
|
349
|
+
if (done) break;
|
|
350
|
+
total += value.byteLength;
|
|
351
|
+
if (total > MAX_HEALTH_BYTES) {
|
|
352
|
+
reader.cancel().catch(() => {});
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
chunks.push(value);
|
|
356
|
+
}
|
|
357
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
358
|
+
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) && parsed.product === FREE_MEM_PRODUCT;
|
|
359
|
+
} catch {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const TAG_OPENER_RE = /<[A-Za-z/]/;
|
|
365
|
+
|
|
366
|
+
function sendable(s, max) {
|
|
367
|
+
return typeof s === "string" && s.length > 0 && s.length <= max && !TAG_OPENER_RE.test(s);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The first file operations, bounded for the request. An over-long or tagged entry is
|
|
372
|
+
* dropped whole: a cut could split a <private> span and leave an opener the worker keeps.
|
|
373
|
+
* @param {unknown[]} fileOps The parsed list.
|
|
374
|
+
* @returns {{path:string,tool:string,count:number}[]} At most MAX_SENT_FILE_OPS entries.
|
|
375
|
+
*/
|
|
376
|
+
function boundedFileOps(fileOps) {
|
|
377
|
+
const sent = [];
|
|
378
|
+
for (const op of fileOps) {
|
|
379
|
+
if (sent.length === MAX_SENT_FILE_OPS) break;
|
|
380
|
+
if (!op || !sendable(op.path, MAX_SENT_PATH_CHARS) || !sendable(op.tool, MAX_SENT_TOOL_CHARS) || !Number.isSafeInteger(op.count)) continue;
|
|
381
|
+
sent.push({ path: op.path, tool: op.tool, count: op.count });
|
|
382
|
+
}
|
|
383
|
+
return sent;
|
|
384
|
+
}
|
|
385
|
+
|
|
261
386
|
/**
|
|
262
387
|
* Asks the worker to queue a session summary (the Stop hook's route). Best effort.
|
|
263
388
|
* @param {string} sessionId Content session id.
|
|
264
389
|
* @param {string} lastAssistant Last assistant text (may be empty).
|
|
390
|
+
* @param {unknown[]} [fileOps] The session file operations; without a list the body has no file_ops.
|
|
265
391
|
* @returns {Promise<"queued"|"skipped"|"down">} Outcome.
|
|
266
392
|
*/
|
|
267
|
-
export async function requestSummary(sessionId, lastAssistant) {
|
|
393
|
+
export async function requestSummary(sessionId, lastAssistant, fileOps) {
|
|
268
394
|
if ((process.env.FREEHARNESS_PRECOMPACT_WORKER || "").toLowerCase() === "off") return "skipped";
|
|
269
|
-
const
|
|
395
|
+
const port = workerPort();
|
|
396
|
+
if (!(await workerIsFreeMem(port, WORKER_TIMEOUT_MS))) return "down";
|
|
397
|
+
const url = `http://127.0.0.1:${port}/api/sessions/summarize`;
|
|
398
|
+
const body = { contentSessionId: sessionId, last_assistant_message: lastAssistant, platformSource: "claude-code" };
|
|
399
|
+
if (Array.isArray(fileOps)) body.file_ops = boundedFileOps(fileOps);
|
|
270
400
|
try {
|
|
271
401
|
const res = await fetch(url, {
|
|
272
402
|
method: "POST",
|
|
273
403
|
headers: { "content-type": "application/json" },
|
|
274
|
-
body: JSON.stringify(
|
|
404
|
+
body: JSON.stringify(body),
|
|
275
405
|
signal: AbortSignal.timeout(WORKER_TIMEOUT_MS),
|
|
276
406
|
});
|
|
277
407
|
return res.ok ? "queued" : "down";
|
|
@@ -295,7 +425,7 @@ export async function respond(payload) {
|
|
|
295
425
|
fs.writeFileSync(summary, renderSkeleton(facts, payload), "utf8");
|
|
296
426
|
fs.writeFileSync(fileops, `${JSON.stringify(facts ? facts.fileOps : [])}\n`, "utf8");
|
|
297
427
|
const lastAssistant = facts && facts.assistants.length ? facts.assistants[facts.assistants.length - 1] : "";
|
|
298
|
-
const worker = await requestSummary(sessionId, lastAssistant);
|
|
428
|
+
const worker = await requestSummary(sessionId, lastAssistant, facts ? facts.fileOps : []);
|
|
299
429
|
return { summary, fileops, worker };
|
|
300
430
|
}
|
|
301
431
|
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// FreeHarness quota tap, fed by the owner's status line command (the only documented source of
|
|
2
|
+
// the subscription window percentages; FreeHarness never writes `statusLine`). Untrusted input:
|
|
3
|
+
// only validated numbers and a sanitized model id reach the file. Silent, exit 0 on every path.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
export const READINGS_FILE = "quota-readings.jsonl";
|
|
10
|
+
export const MAX_INPUT_BYTES = 64 * 1024;
|
|
11
|
+
export const MAX_MODEL_CHARS = 128;
|
|
12
|
+
export const MAX_LINE_BYTES = 4096;
|
|
13
|
+
export const TAIL_BYTES = 8 * 1024;
|
|
14
|
+
export const ROTATE_BYTES = 2 * 1024 * 1024;
|
|
15
|
+
/** Counted from process start: Node start-up alone spends tens of the status line's 200 ms. */
|
|
16
|
+
const DEADLINE_MS = 150;
|
|
17
|
+
/** Lets one poll pass read input already in the pipe when start-up overran the deadline. */
|
|
18
|
+
const MIN_WAIT_MS = 5;
|
|
19
|
+
/** A consumer printing the model id must never meet controls, bidi or line separators. */
|
|
20
|
+
const UNSAFE_CHARS = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/u;
|
|
21
|
+
|
|
22
|
+
function own(obj, key) {
|
|
23
|
+
if (obj === null || typeof obj !== "object" || Array.isArray(obj)) return undefined;
|
|
24
|
+
return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** resets_at must be a safe integer so the comparison with the last line stays exact. */
|
|
28
|
+
export function windowOf(value) {
|
|
29
|
+
const pct = own(value, "used_percentage");
|
|
30
|
+
const resets = own(value, "resets_at");
|
|
31
|
+
if (typeof pct !== "number" || !Number.isFinite(pct) || pct < 0 || pct > 100) return null;
|
|
32
|
+
if (typeof resets !== "number" || !Number.isSafeInteger(resets) || resets <= 0) return null;
|
|
33
|
+
return { used_percentage: pct === 0 ? 0 : pct, resets_at: resets };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function readingOf(payload) {
|
|
37
|
+
const limits = own(payload, "rate_limits");
|
|
38
|
+
const fiveHour = windowOf(own(limits, "five_hour"));
|
|
39
|
+
const sevenDay = windowOf(own(limits, "seven_day"));
|
|
40
|
+
if (fiveHour === null && sevenDay === null) return null;
|
|
41
|
+
const id = own(own(payload, "model"), "id");
|
|
42
|
+
const model = typeof id === "string" && id.length > 0 && id.length <= MAX_MODEL_CHARS && !UNSAFE_CHARS.test(id) ? id : null;
|
|
43
|
+
return { model, five_hour: fiveHour, seven_day: sevenDay };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sameWindow(current, previous) {
|
|
47
|
+
if (current === null) return previous === null;
|
|
48
|
+
return own(previous, "used_percentage") === current.used_percentage && own(previous, "resets_at") === current.resets_at;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Reads through the open descriptor, so the file checked by fstat is the one compared. */
|
|
52
|
+
function tailOf(fd, size) {
|
|
53
|
+
if (size === 0) return { last: null, endsWithNewline: true };
|
|
54
|
+
const want = Math.min(size, TAIL_BYTES);
|
|
55
|
+
const buf = Buffer.alloc(want);
|
|
56
|
+
let got = 0;
|
|
57
|
+
while (got < want) {
|
|
58
|
+
const n = fs.readSync(fd, buf, got, want - got, size - want + got);
|
|
59
|
+
if (n === 0) break;
|
|
60
|
+
got += n;
|
|
61
|
+
}
|
|
62
|
+
const text = buf.toString("utf8", 0, got);
|
|
63
|
+
const endsWithNewline = text.endsWith("\n");
|
|
64
|
+
// A trailing fragment is a torn write; the line before it is the last complete one.
|
|
65
|
+
const complete = endsWithNewline ? text.slice(0, -1) : text.slice(0, Math.max(text.lastIndexOf("\n"), 0));
|
|
66
|
+
const start = complete.lastIndexOf("\n");
|
|
67
|
+
// Without a newline before it, the line may begin before the tail window.
|
|
68
|
+
if (start === -1 && size > want) return { last: null, endsWithNewline };
|
|
69
|
+
const last = complete.slice(start + 1);
|
|
70
|
+
return { last: last === "" ? null : last, endsWithNewline };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function unchanged(reading, last) {
|
|
74
|
+
if (last === null) return false;
|
|
75
|
+
let prev;
|
|
76
|
+
try {
|
|
77
|
+
prev = JSON.parse(last);
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
return sameWindow(reading.five_hour, own(prev, "five_hour")) && sameWindow(reading.seven_day, own(prev, "seven_day"));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function lstatOrNull(p, io) {
|
|
85
|
+
try {
|
|
86
|
+
return io.lstatSync(p);
|
|
87
|
+
} catch (e) {
|
|
88
|
+
if (e && e.code === "ENOENT") return null;
|
|
89
|
+
throw e;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Refusals keep the append out of any file the tap did not create. `io` supplies the lstat and
|
|
95
|
+
* rename calls, so a test can place another tap's rotation between this tap's two lstats.
|
|
96
|
+
*/
|
|
97
|
+
export function record(text, home = os.homedir(), now = new Date(), io = fs) {
|
|
98
|
+
let payload;
|
|
99
|
+
try {
|
|
100
|
+
payload = JSON.parse(text);
|
|
101
|
+
} catch {
|
|
102
|
+
return "invalid";
|
|
103
|
+
}
|
|
104
|
+
const reading = readingOf(payload);
|
|
105
|
+
if (reading === null) return "invalid";
|
|
106
|
+
const dir = path.join(home, ".claude", "freeharness");
|
|
107
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
108
|
+
const file = path.join(dir, READINGS_FILE);
|
|
109
|
+
const before = lstatOrNull(file, io);
|
|
110
|
+
if (before !== null && (!before.isFile() || before.nlink > 1)) return "refused";
|
|
111
|
+
if (before !== null && before.size > ROTATE_BYTES) {
|
|
112
|
+
const rotated = lstatOrNull(`${file}.1`, io);
|
|
113
|
+
if (rotated !== null && !rotated.isFile()) return "refused";
|
|
114
|
+
// Only the file the first lstat judged over the bound is renamed: a file another tap rotated,
|
|
115
|
+
// removed or grew since then is left for the append, so .1 always holds a full history. The
|
|
116
|
+
// rename itself never writes through a link.
|
|
117
|
+
const again = lstatOrNull(file, io);
|
|
118
|
+
const same = again !== null && again.dev === before.dev && again.ino === before.ino && again.size === before.size;
|
|
119
|
+
if (same && again.size > ROTATE_BYTES) io.renameSync(file, `${file}.1`);
|
|
120
|
+
}
|
|
121
|
+
const flags = fs.constants.O_RDWR | fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW | (fs.constants.O_NONBLOCK || 0);
|
|
122
|
+
const fd = fs.openSync(file, flags, 0o600);
|
|
123
|
+
try {
|
|
124
|
+
// The lstat above can race a swap; the fstat on the open descriptor decides.
|
|
125
|
+
const st = fs.fstatSync(fd);
|
|
126
|
+
if (!st.isFile() || st.nlink > 1) return "refused";
|
|
127
|
+
const tail = tailOf(fd, st.size);
|
|
128
|
+
if (unchanged(reading, tail.last)) return "unchanged";
|
|
129
|
+
const line = `${tail.endsWithNewline ? "" : "\n"}${JSON.stringify({ ts: now.toISOString(), ...reading })}\n`;
|
|
130
|
+
const bytes = Buffer.from(line, "utf8");
|
|
131
|
+
if (bytes.length >= MAX_LINE_BYTES) return "invalid";
|
|
132
|
+
// One write under 4 KiB on an O_APPEND descriptor stays whole against concurrent taps.
|
|
133
|
+
fs.writeSync(fd, bytes, 0, bytes.length);
|
|
134
|
+
return "written";
|
|
135
|
+
} finally {
|
|
136
|
+
fs.closeSync(fd);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function main() {
|
|
141
|
+
process.on("uncaughtException", () => process.exit(0));
|
|
142
|
+
const chunks = [];
|
|
143
|
+
let got = 0;
|
|
144
|
+
let done = false;
|
|
145
|
+
const finish = () => {
|
|
146
|
+
if (done) return;
|
|
147
|
+
done = true;
|
|
148
|
+
try {
|
|
149
|
+
record(Buffer.concat(chunks, got).toString("utf8"));
|
|
150
|
+
} catch {
|
|
151
|
+
// Silent by contract: a status line must never show tap errors.
|
|
152
|
+
}
|
|
153
|
+
process.exit(0);
|
|
154
|
+
};
|
|
155
|
+
// A writer that never closes stdin must not hold the status line: what arrived is all there is.
|
|
156
|
+
setTimeout(finish, Math.max(MIN_WAIT_MS, DEADLINE_MS - performance.now()));
|
|
157
|
+
process.stdin.on("data", (chunk) => {
|
|
158
|
+
const part = chunk.subarray(0, MAX_INPUT_BYTES - got);
|
|
159
|
+
chunks.push(part);
|
|
160
|
+
got += part.length;
|
|
161
|
+
if (got >= MAX_INPUT_BYTES) finish();
|
|
162
|
+
});
|
|
163
|
+
process.stdin.on("end", finish);
|
|
164
|
+
process.stdin.on("error", finish);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let direct = false;
|
|
168
|
+
try {
|
|
169
|
+
direct = Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
170
|
+
} catch {
|
|
171
|
+
direct = false;
|
|
172
|
+
}
|
|
173
|
+
if (direct) main();
|