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
|
@@ -0,0 +1,970 @@
|
|
|
1
|
+
// SDD context hook: the free-mem worker briefs every subagent at SubagentStart; at SubagentStop an
|
|
2
|
+
// implementer's checkpoint becomes its task state and the worker keeps the task-tagged report.
|
|
3
|
+
// Payload, transcript, report and worker answer are untrusted: in-flight workspace regular files
|
|
4
|
+
// only, bounded loopback calls to a responder whose health answer carries the free-mem marker,
|
|
5
|
+
// never blocks, exit 0.
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import http from "node:http";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
/** A missing or broken tracker silences the hook instead of failing at load. */
|
|
14
|
+
let tracker = null;
|
|
15
|
+
try {
|
|
16
|
+
tracker = await import("./freeharness-cost-tracker.mjs");
|
|
17
|
+
} catch {
|
|
18
|
+
tracker = null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MAX_STDIN_BYTES = 1024 * 1024;
|
|
22
|
+
/** No legitimate report reaches this size. */
|
|
23
|
+
const MAX_FILE_BYTES = 64 * 1024;
|
|
24
|
+
|
|
25
|
+
/** Retired implementer-high stays so a stale emitted agent still saves its state. */
|
|
26
|
+
const STATE_TYPES = new Set(["implementer", "implementer-high"]);
|
|
27
|
+
|
|
28
|
+
/** Anything else could steer the state file name. */
|
|
29
|
+
const TASK_RE = /^(?:\d+(?:\.\d+)*|final)$/;
|
|
30
|
+
|
|
31
|
+
/** A reply names its report early; the bound keeps a flood from spending the hook's timeout. */
|
|
32
|
+
const MESSAGE_SCAN_CHARS = 64 * 1024;
|
|
33
|
+
const REPORT_MARK = ".freeharness/sdd/";
|
|
34
|
+
const REPORT_SUFFIX = ".review.md";
|
|
35
|
+
/** `<` ends a token so a `<change>` placeholder never matches. */
|
|
36
|
+
const TOKEN_STOPS = new Set(['"', "'", "`", "(", ")", "<", ">", "[", "]"]);
|
|
37
|
+
/** Trimmed so a path closing a sentence still names its report. */
|
|
38
|
+
const TOKEN_TRAIL = new Set([".", ",", ";", ":", "!", "?"]);
|
|
39
|
+
|
|
40
|
+
/** Copied from validate-handoff.mjs, emitted elsewhere; a parity test pins the copies. */
|
|
41
|
+
const CHECKPOINT_SECTIONS = ["Intent", "Files and code", "Decisions", "Errors and fixes", "Open work", "Next step"];
|
|
42
|
+
const CHECKPOINT_BOUND = 8000;
|
|
43
|
+
|
|
44
|
+
const MISSING = Symbol("missing");
|
|
45
|
+
const REFUSED = Symbol("refused");
|
|
46
|
+
|
|
47
|
+
/** The rest is never read, so a flood cannot hold the hook past its timeout. */
|
|
48
|
+
function readStdin(max) {
|
|
49
|
+
const buf = Buffer.alloc(max);
|
|
50
|
+
let total = 0;
|
|
51
|
+
try {
|
|
52
|
+
while (total < max) {
|
|
53
|
+
const n = fs.readSync(0, buf, total, max - total, null);
|
|
54
|
+
if (n === 0) break;
|
|
55
|
+
total += n;
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// A closed or non-blocking stdin ends the read with what arrived.
|
|
59
|
+
}
|
|
60
|
+
return buf.toString("utf8", 0, total);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A plugin-scoped type matches by its last segment. */
|
|
64
|
+
function agentName(type) {
|
|
65
|
+
if (typeof type !== "string") return null;
|
|
66
|
+
const scoped = /^plugin:[^:]+:([^:]+)$/.exec(type);
|
|
67
|
+
const name = scoped ? scoped[1] : type;
|
|
68
|
+
return STATE_TYPES.has(name) ? name : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The lead shell may sit anywhere in the project, so the workspace hangs off the root above the cwd. */
|
|
72
|
+
function projectRoot(cwd) {
|
|
73
|
+
try {
|
|
74
|
+
return typeof tracker.projectRootFor === "function" ? tracker.projectRootFor(cwd) : null;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Null unless reached through no symlink, so every read stays inside the project. */
|
|
81
|
+
function workspaceFor(projectDir, change) {
|
|
82
|
+
try {
|
|
83
|
+
const root = fs.realpathSync(projectDir);
|
|
84
|
+
const dir = path.join(root, ".freeharness", "sdd", change);
|
|
85
|
+
if (fs.realpathSync(dir) !== dir || !fs.statSync(dir).isDirectory()) return null;
|
|
86
|
+
return { root, dir };
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Narrows a directory swap during the reads. */
|
|
93
|
+
function stillContained(ws) {
|
|
94
|
+
try {
|
|
95
|
+
return fs.realpathSync(ws.dir) === ws.dir;
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** REFUSED for a symlink leaf, a non-regular file (O_NONBLOCK keeps a FIFO from blocking), an oversize file or an entry swapped after the open. */
|
|
102
|
+
function readWorkspaceFile(dir, name) {
|
|
103
|
+
const file = path.join(dir, name);
|
|
104
|
+
if (path.dirname(file) !== dir) return REFUSED;
|
|
105
|
+
let fd;
|
|
106
|
+
try {
|
|
107
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0));
|
|
108
|
+
} catch (e) {
|
|
109
|
+
return e && e.code === "ENOENT" ? MISSING : REFUSED;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const stat = fs.fstatSync(fd);
|
|
113
|
+
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return REFUSED;
|
|
114
|
+
const buf = Buffer.allocUnsafe(MAX_FILE_BYTES + 1);
|
|
115
|
+
let total = 0;
|
|
116
|
+
while (total < buf.length) {
|
|
117
|
+
const n = fs.readSync(fd, buf, total, buf.length - total, total);
|
|
118
|
+
if (n === 0) break;
|
|
119
|
+
total += n;
|
|
120
|
+
}
|
|
121
|
+
if (total > MAX_FILE_BYTES) return REFUSED;
|
|
122
|
+
// Without O_NOFOLLOW a symlink leaf shows here as a different inode.
|
|
123
|
+
const entry = fs.lstatSync(file);
|
|
124
|
+
if (entry.dev !== stat.dev || entry.ino !== stat.ino) return REFUSED;
|
|
125
|
+
return { text: buf.toString("utf8", 0, total) };
|
|
126
|
+
} catch {
|
|
127
|
+
return REFUSED;
|
|
128
|
+
} finally {
|
|
129
|
+
try {
|
|
130
|
+
fs.closeSync(fd);
|
|
131
|
+
} catch {
|
|
132
|
+
// Nothing to recover: the descriptor is gone either way.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Code points: the unit the checkpoint validator bounds by. */
|
|
138
|
+
function chars(text) {
|
|
139
|
+
let n = 0;
|
|
140
|
+
for (const _ of text) n += 1;
|
|
141
|
+
return n;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const BRIEF_SUFFIX = ".brief.md";
|
|
145
|
+
/** The emitted script cannot import the engine, so TAG_RE, AGENT_ID_RE, the brief caps and BRIEF_UNSAFE_RANGES are copies a test pins. */
|
|
146
|
+
export const TAG_RE = /^(change|task):[A-Za-z0-9._/-]{1,120}$/;
|
|
147
|
+
|
|
148
|
+
/** Only an entry that exists yet fails the containment check is a refusal; an absent workspace is not. */
|
|
149
|
+
function entryPresent(file) {
|
|
150
|
+
try {
|
|
151
|
+
fs.lstatSync(file);
|
|
152
|
+
return true;
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Tags name the brief's change and file, never its Task header, which a stored brief could not verify. */
|
|
159
|
+
function briefTags(named) {
|
|
160
|
+
const task = named.file.slice(0, -BRIEF_SUFFIX.length);
|
|
161
|
+
return [`change:${named.change}`, `task:${named.change}/${task}`].filter((t) => TAG_RE.test(t));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Start has no prompt or transcript yet; tags need one under the projects directory naming a regular brief of an in-flight change. */
|
|
165
|
+
function stopTags(payload) {
|
|
166
|
+
try {
|
|
167
|
+
if (!tracker || typeof tracker.briefPathFromText !== "function" || typeof tracker.underProjectsDir !== "function" || typeof payload.agent_transcript_path !== "string") return [];
|
|
168
|
+
const { cwd } = payload;
|
|
169
|
+
const root = projectRoot(cwd);
|
|
170
|
+
if (!root) return [];
|
|
171
|
+
const file = promptTranscript(payload);
|
|
172
|
+
const head = file === null ? null : readHead(file, TRANSCRIPT_HEAD_BYTES, (stat) => tracker.underProjectsDir(file, stat));
|
|
173
|
+
const text = head === null ? null : firstPromptOf(head);
|
|
174
|
+
const named = typeof text === "string" ? tracker.briefPathFromText(text, root, cwd) : null;
|
|
175
|
+
if (!named) return [];
|
|
176
|
+
const ws = workspaceFor(root, named.change);
|
|
177
|
+
if (!ws || !tracker.inFlight(ws.root, named.change)) return [];
|
|
178
|
+
const brief = path.join(ws.dir, named.file);
|
|
179
|
+
return path.dirname(brief) === ws.dir && regularFile(brief) && stillContained(ws) ? briefTags(named) : [];
|
|
180
|
+
} catch {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The tracker's change-segment rule, checked without a nested quantifier. */
|
|
186
|
+
function kebabSegment(s) {
|
|
187
|
+
return /^[a-z0-9-]+$/.test(s) && !s.startsWith("-") && !s.endsWith("-") && !s.includes("--");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function realOr(p) {
|
|
191
|
+
try {
|
|
192
|
+
return fs.realpathSync(p);
|
|
193
|
+
} catch {
|
|
194
|
+
// Lexical match only; the workspace check refuses it later.
|
|
195
|
+
return p;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The tracker's root rule: the shell may reach the root through a symlinked cwd ancestor, so a lexical ancestor resolving to it counts. */
|
|
200
|
+
function rootAccepted(prefix, root, cwd) {
|
|
201
|
+
if (prefix === "" || prefix === "./") return true;
|
|
202
|
+
if (!path.isAbsolute(prefix) || !prefix.endsWith("/")) return false;
|
|
203
|
+
const withSep = (p) => (p.endsWith("/") ? p : `${p}/`);
|
|
204
|
+
const base = path.resolve(root);
|
|
205
|
+
if (prefix === withSep(base) || prefix === withSep(realOr(base))) return true;
|
|
206
|
+
if (typeof cwd !== "string" || !path.isAbsolute(cwd) || !withSep(path.resolve(cwd)).startsWith(prefix)) return false;
|
|
207
|
+
return realOr(prefix) === realOr(base);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** REFUSED, unlike null: the text names a refused report path, so no other source may supply one. */
|
|
211
|
+
function reportPathFromText(text, root, cwd = root) {
|
|
212
|
+
const found = acceptedReportPath(text, root, cwd);
|
|
213
|
+
if (found !== null) return found;
|
|
214
|
+
return text.includes(REPORT_MARK) || text.includes(REPORT_SUFFIX) ? REFUSED : null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The tracker's prompt-path rule on `.review.md`: only the first match counts, so a message cannot steer past a refused path; the scan stays linear on untrusted text. */
|
|
218
|
+
function acceptedReportPath(text, root, cwd) {
|
|
219
|
+
const truncated = text.length > MESSAGE_SCAN_CHARS;
|
|
220
|
+
const scan = truncated ? text.slice(0, MESSAGE_SCAN_CHARS) : text;
|
|
221
|
+
const isStop = (ch) => TOKEN_STOPS.has(ch) || ch.trim() === "";
|
|
222
|
+
let from = 0;
|
|
223
|
+
for (;;) {
|
|
224
|
+
const at = scan.indexOf(REPORT_MARK, from);
|
|
225
|
+
if (at < 0) return null;
|
|
226
|
+
let start = at;
|
|
227
|
+
while (start > from && !isStop(scan[start - 1])) start -= 1;
|
|
228
|
+
const restStart = at + REPORT_MARK.length;
|
|
229
|
+
let end = restStart;
|
|
230
|
+
while (end < scan.length && !isStop(scan[end])) end += 1;
|
|
231
|
+
// A token cut by the bound has an unknown end.
|
|
232
|
+
if (truncated && end === scan.length) return null;
|
|
233
|
+
let last = end;
|
|
234
|
+
while (last > restStart && TOKEN_TRAIL.has(scan[last - 1])) last -= 1;
|
|
235
|
+
if (last - REPORT_SUFFIX.length >= restStart && scan.endsWith(REPORT_SUFFIX, last)) {
|
|
236
|
+
const token = scan.slice(start, last);
|
|
237
|
+
if (token.includes("..") || token.includes("\\")) return null;
|
|
238
|
+
const parts = scan.slice(restStart, last - REPORT_SUFFIX.length).split("/");
|
|
239
|
+
if (parts.length !== 2 || !kebabSegment(parts[0]) || parts[1].length > 64 || !TASK_RE.test(parts[1])) return null;
|
|
240
|
+
if (!rootAccepted(scan.slice(start, at), root, cwd)) return null;
|
|
241
|
+
return { change: parts[0], task: parts[1] };
|
|
242
|
+
}
|
|
243
|
+
from = end;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Code points, so the source holds no raw separator. */
|
|
248
|
+
const LINE_TERMINATOR_RE = new RegExp(`[${[0x0a, 0x0d, 0x2028, 0x2029].map((c) => String.fromCharCode(c)).join("")}]`);
|
|
249
|
+
|
|
250
|
+
/** `/^### (.+?)\s*$/` without backtracking: the lazy group is quadratic on a long run of spaces. */
|
|
251
|
+
function headingName(line) {
|
|
252
|
+
if (!line.startsWith("### ") || line.length === 4) return null;
|
|
253
|
+
const rest = line.slice(4);
|
|
254
|
+
const name = rest.trimEnd();
|
|
255
|
+
if (name === "") return LINE_TERMINATOR_RE.test(rest[0]) ? null : rest[0];
|
|
256
|
+
return LINE_TERMINATOR_RE.test(name) ? null : name;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Mirrors validate-handoff.mjs `checkpointOf`, which a parity test holds it to.
|
|
261
|
+
* @returns {{ text: string | null, errors: string[], size: number }} The section, its problems and its size in code points.
|
|
262
|
+
*/
|
|
263
|
+
export function checkpointOf(text) {
|
|
264
|
+
const lines = String(text).split("\n");
|
|
265
|
+
let start = -1;
|
|
266
|
+
lines.forEach((l, i) => {
|
|
267
|
+
if (/^## Checkpoint\s*$/.test(l)) start = i;
|
|
268
|
+
});
|
|
269
|
+
if (start === -1) return { text: null, errors: ["checkpoint-missing section"], size: 0 };
|
|
270
|
+
let end = lines.length;
|
|
271
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
272
|
+
if (lines[i].startsWith("## ")) {
|
|
273
|
+
end = i;
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const body = lines.slice(start, end).join("\n").trimEnd();
|
|
278
|
+
const size = chars(body);
|
|
279
|
+
const headings = [];
|
|
280
|
+
for (const line of body.split("\n").slice(1)) {
|
|
281
|
+
const name = headingName(line);
|
|
282
|
+
if (name !== null) headings.push({ name, filled: false });
|
|
283
|
+
else if (headings.length > 0 && line.trim() !== "") headings[headings.length - 1].filled = true;
|
|
284
|
+
}
|
|
285
|
+
const accepted = new Map();
|
|
286
|
+
let lastRank = -1;
|
|
287
|
+
for (const h of headings) {
|
|
288
|
+
const rank = CHECKPOINT_SECTIONS.indexOf(h.name);
|
|
289
|
+
if (rank === -1) continue;
|
|
290
|
+
if (rank <= lastRank) break;
|
|
291
|
+
accepted.set(h.name, h.filled);
|
|
292
|
+
lastRank = rank;
|
|
293
|
+
}
|
|
294
|
+
const errors = CHECKPOINT_SECTIONS.filter((n) => accepted.get(n) !== true).map((n) => `checkpoint-missing ${n}`);
|
|
295
|
+
if (size > CHECKPOINT_BOUND) errors.push(`checkpoint-oversized ${size}`);
|
|
296
|
+
return { text: body, errors, size };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Absent or a single-link regular file, so the write never lands on a symlink, a directory or a shared inode. */
|
|
300
|
+
function stateTargetOk(file) {
|
|
301
|
+
let st;
|
|
302
|
+
try {
|
|
303
|
+
st = fs.lstatSync(file);
|
|
304
|
+
} catch (e) {
|
|
305
|
+
return Boolean(e && e.code === "ENOENT");
|
|
306
|
+
}
|
|
307
|
+
return st.isFile() && st.nlink === 1;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Through a fsynced exclusive temporary file and a rename, so a reader sees the old state or the new one whole.
|
|
312
|
+
* @returns {string | null} Null once saved, else a reason for the parent: a Node error code or `refused`, never a path.
|
|
313
|
+
*/
|
|
314
|
+
function writeState(ws, name, text) {
|
|
315
|
+
const target = path.join(ws.dir, name);
|
|
316
|
+
const tmp = path.join(ws.dir, `.${name}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`);
|
|
317
|
+
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0);
|
|
318
|
+
let fd = null;
|
|
319
|
+
let created = false;
|
|
320
|
+
try {
|
|
321
|
+
fd = fs.openSync(tmp, flags, 0o666);
|
|
322
|
+
created = true;
|
|
323
|
+
const buf = Buffer.from(text, "utf8");
|
|
324
|
+
let off = 0;
|
|
325
|
+
while (off < buf.length) off += fs.writeSync(fd, buf, off, buf.length - off);
|
|
326
|
+
fs.fsyncSync(fd);
|
|
327
|
+
const own = fs.fstatSync(fd);
|
|
328
|
+
fs.closeSync(fd);
|
|
329
|
+
fd = null;
|
|
330
|
+
// Narrows a swap of the directory or the target since the first checks.
|
|
331
|
+
const entry = fs.lstatSync(tmp);
|
|
332
|
+
if (entry.dev !== own.dev || entry.ino !== own.ino || !stillContained(ws) || !stateTargetOk(target)) return "refused";
|
|
333
|
+
fs.renameSync(tmp, target);
|
|
334
|
+
created = false;
|
|
335
|
+
return null;
|
|
336
|
+
} catch (e) {
|
|
337
|
+
const code = e && typeof e.code === "string" ? e.code : "";
|
|
338
|
+
return /^E[A-Z0-9]{1,15}$/.test(code) ? code : "refused";
|
|
339
|
+
} finally {
|
|
340
|
+
if (fd !== null) {
|
|
341
|
+
try {
|
|
342
|
+
fs.closeSync(fd);
|
|
343
|
+
} catch {
|
|
344
|
+
// Nothing to recover: the descriptor is gone either way.
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (created) {
|
|
348
|
+
try {
|
|
349
|
+
fs.unlinkSync(tmp);
|
|
350
|
+
} catch {
|
|
351
|
+
// Already gone; nothing else to clean.
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* A refused path or state target writes nothing, so a planted link cannot redirect the write; an invalid checkpoint leaves the state untouched.
|
|
359
|
+
* A valid checkpoint that fails to save answers `sdd-context: state <task> not saved (<code>)`, so the lead never takes a lost state for a kept one.
|
|
360
|
+
* @returns {string | null} One `sdd-context:` line, or null for silence.
|
|
361
|
+
*/
|
|
362
|
+
export function subagentStopState(payload) {
|
|
363
|
+
return stopOutcome(payload).context;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The state write and the handoff record keep their order and finish before any worker call, which a refusal here forbids.
|
|
368
|
+
* @returns {{ context: string | null, refused: boolean }} The workerless context and whether a path or target was refused.
|
|
369
|
+
*/
|
|
370
|
+
function stopOutcome(payload) {
|
|
371
|
+
const quiet = { context: null, refused: false };
|
|
372
|
+
const refused = { context: null, refused: true };
|
|
373
|
+
if (!tracker || !payload || typeof payload !== "object" || Array.isArray(payload)) return quiet;
|
|
374
|
+
if (payload.hook_event_name !== "SubagentStop") return quiet;
|
|
375
|
+
const agent = agentName(payload.agent_type);
|
|
376
|
+
const { cwd } = payload;
|
|
377
|
+
if (!agent || typeof cwd !== "string" || !path.isAbsolute(cwd)) return quiet;
|
|
378
|
+
const root = projectRoot(cwd);
|
|
379
|
+
if (!root) return quiet;
|
|
380
|
+
const named = reportNaming(payload, root);
|
|
381
|
+
if (named === REFUSED) return refused;
|
|
382
|
+
if (!named) return quiet;
|
|
383
|
+
const ws = workspaceFor(root, named.change);
|
|
384
|
+
if (!ws) return entryPresent(path.join(root, ".freeharness", "sdd", named.change)) ? refused : quiet;
|
|
385
|
+
if (!tracker.inFlight(ws.root, named.change)) return quiet;
|
|
386
|
+
const report = readWorkspaceFile(ws.dir, `${named.task}${REPORT_SUFFIX}`);
|
|
387
|
+
const readable = report !== MISSING && report !== REFUSED;
|
|
388
|
+
recordHandoff(ws, payload, readable ? report.text : null);
|
|
389
|
+
if (!readable) return report === REFUSED ? refused : quiet;
|
|
390
|
+
const stateName = `${named.task}.state.md`;
|
|
391
|
+
if (!stateTargetOk(path.join(ws.dir, stateName))) return refused;
|
|
392
|
+
const cp = checkpointOf(report.text);
|
|
393
|
+
if (cp.errors.length > 0) {
|
|
394
|
+
if (!stillContained(ws)) return refused;
|
|
395
|
+
// Error words are fixed or numeric, so no report text reaches the parent.
|
|
396
|
+
const first = cp.errors[0];
|
|
397
|
+
const space = first.indexOf(" ");
|
|
398
|
+
return { context: `sdd-context: ${first.slice(0, space)} ${named.task} ${first.slice(space + 1)}`, refused: false };
|
|
399
|
+
}
|
|
400
|
+
const failure = writeState(ws, stateName, cp.text);
|
|
401
|
+
const context = failure === null ? `sdd-context: state ${named.task} saved (${cp.size} chars)` : `sdd-context: state ${named.task} not saved (${failure})`;
|
|
402
|
+
return { context, refused: failure === "refused" };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const HANDOFFS_FILE = "handoffs.jsonl";
|
|
406
|
+
const HANDOFF_STATUSES = new Set(["completed", "partial", "blocked"]);
|
|
407
|
+
const STOP_TOKENS = new Set(["too-big", "needs-confirm", "ambiguous", "regressed", "scope"]);
|
|
408
|
+
/** Stricter than the tracker's id shape: the record's readers expect no dot. */
|
|
409
|
+
const RECORD_AGENT_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
410
|
+
const MAX_HANDOFF_LINE_BYTES = 1024;
|
|
411
|
+
/** The dispatch prompt opens the transcript, so its head is enough. */
|
|
412
|
+
const TRANSCRIPT_HEAD_BYTES = 64 * 1024;
|
|
413
|
+
const DISPATCH_LINE_RE = /^Dispatch: (\S+)$/;
|
|
414
|
+
|
|
415
|
+
/** Body of the first fenced json block, or null when none closes. */
|
|
416
|
+
function firstJsonBlock(text) {
|
|
417
|
+
const lines = text.split("\n");
|
|
418
|
+
let open = -1;
|
|
419
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
420
|
+
const line = lines[i].trim();
|
|
421
|
+
if (open < 0) {
|
|
422
|
+
if (line === "```json") open = i;
|
|
423
|
+
} else if (line === "```") {
|
|
424
|
+
return lines.slice(open + 1, i).join("\n");
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** A free-text reason counts only through a known token, so a stop tally never grows new categories. */
|
|
431
|
+
function stopToken(reason) {
|
|
432
|
+
if (typeof reason !== "string") return null;
|
|
433
|
+
const colon = reason.indexOf(":");
|
|
434
|
+
const token = (colon < 0 ? reason : reason.slice(0, colon)).trim();
|
|
435
|
+
return STOP_TOKENS.has(token) ? token : null;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function handoffOutcome(reportText) {
|
|
439
|
+
const unreadable = { status: "unreadable", stop: null };
|
|
440
|
+
if (typeof reportText !== "string") return unreadable;
|
|
441
|
+
const block = firstJsonBlock(reportText);
|
|
442
|
+
if (block === null) return unreadable;
|
|
443
|
+
let handoff;
|
|
444
|
+
try {
|
|
445
|
+
handoff = JSON.parse(block);
|
|
446
|
+
} catch {
|
|
447
|
+
return unreadable;
|
|
448
|
+
}
|
|
449
|
+
if (!handoff || typeof handoff !== "object" || Array.isArray(handoff) || !HANDOFF_STATUSES.has(handoff.status)) return unreadable;
|
|
450
|
+
return { status: handoff.status, stop: handoff.status === "blocked" ? stopToken(handoff.blocked_reason) : null };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Never follows a symlink leaf or blocks on a FIFO; a larger file yields only its head.
|
|
455
|
+
* @param {((stat: fs.Stats) => boolean) | null} [accept] Must pass on the opened file before any byte is read.
|
|
456
|
+
*/
|
|
457
|
+
function readHead(file, max, accept = null) {
|
|
458
|
+
let fd;
|
|
459
|
+
try {
|
|
460
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0));
|
|
461
|
+
} catch {
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
try {
|
|
465
|
+
const stat = fs.fstatSync(fd);
|
|
466
|
+
if (!stat.isFile() || (accept !== null && !accept(stat))) return null;
|
|
467
|
+
const buf = Buffer.allocUnsafe(max);
|
|
468
|
+
let total = 0;
|
|
469
|
+
while (total < max) {
|
|
470
|
+
const n = fs.readSync(fd, buf, total, max - total, total);
|
|
471
|
+
if (n === 0) break;
|
|
472
|
+
total += n;
|
|
473
|
+
}
|
|
474
|
+
return buf.toString("utf8", 0, total);
|
|
475
|
+
} catch {
|
|
476
|
+
return null;
|
|
477
|
+
} finally {
|
|
478
|
+
try {
|
|
479
|
+
fs.closeSync(fd);
|
|
480
|
+
} catch {
|
|
481
|
+
// Nothing to recover: the descriptor is gone either way.
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** The tracker's reading of a prompt's text content, so both sides name the same dispatch. */
|
|
487
|
+
function messageText(msg) {
|
|
488
|
+
const content = msg && typeof msg === "object" ? msg.content : null;
|
|
489
|
+
if (typeof content === "string") return content;
|
|
490
|
+
if (!Array.isArray(content)) return "";
|
|
491
|
+
return content
|
|
492
|
+
.filter((c) => c && c.type === "text" && typeof c.text === "string")
|
|
493
|
+
.map((c) => c.text)
|
|
494
|
+
.join("\n");
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** The tracker's resolution, so both hooks read the same dispatch. */
|
|
498
|
+
function promptTranscript(payload) {
|
|
499
|
+
if (typeof tracker.subagentTranscriptFor !== "function") return null;
|
|
500
|
+
const { transcript_path: transcriptPath, agent_id: agentId } = payload;
|
|
501
|
+
if (typeof transcriptPath !== "string" || typeof agentId !== "string") return null;
|
|
502
|
+
const file = tracker.subagentTranscriptFor(transcriptPath, agentId, payload);
|
|
503
|
+
return typeof file === "string" ? file : null;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function firstPromptText(file) {
|
|
507
|
+
const head = readHead(file, TRANSCRIPT_HEAD_BYTES);
|
|
508
|
+
return head === null ? null : firstPromptOf(head);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Only the first prompt counts: a later user turn could claim any round, report or brief. */
|
|
512
|
+
function firstPromptOf(head) {
|
|
513
|
+
for (const raw of head.split("\n")) {
|
|
514
|
+
let rec;
|
|
515
|
+
try {
|
|
516
|
+
rec = JSON.parse(raw);
|
|
517
|
+
} catch {
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
if (!rec || typeof rec !== "object" || Array.isArray(rec) || rec.type !== "user") continue;
|
|
521
|
+
return messageText(rec.message);
|
|
522
|
+
}
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function dispatchRound(payload) {
|
|
527
|
+
if (typeof tracker.parseDispatchName !== "function") return null;
|
|
528
|
+
const file = promptTranscript(payload);
|
|
529
|
+
const text = file === null ? null : firstPromptText(file);
|
|
530
|
+
if (text === null) return null;
|
|
531
|
+
const m = DISPATCH_LINE_RE.exec(text.split(/\r?\n/, 1)[0]);
|
|
532
|
+
const round = m ? tracker.parseDispatchName(m[1]).round : null;
|
|
533
|
+
return Number.isSafeInteger(round) && round >= 1 ? round : null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const REPORT_LINE = "Report: ";
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* A dispatch handing back through a background tool names no report in its reply; the lead's prompt does.
|
|
540
|
+
* The prompt is trusted no more than the reply: only its first `Report: ` line counts, under the reply's path and containment rules.
|
|
541
|
+
* @param {string} root Project root the report must sit under.
|
|
542
|
+
* @param {string} [cwd] Payload cwd, whose lexical ancestors may spell the root.
|
|
543
|
+
* @returns {{ change: string, task: string } | null} The report's change and task, or null for silence.
|
|
544
|
+
*/
|
|
545
|
+
export function reportFromPrompt(transcriptPath, root, cwd = root) {
|
|
546
|
+
const named = promptReport(transcriptPath, root, cwd);
|
|
547
|
+
return named === REFUSED ? null : named;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** REFUSED keeps a refused `Report: ` line apart from a missing one, so the worker call can be withheld. */
|
|
551
|
+
function promptReport(transcriptPath, root, cwd) {
|
|
552
|
+
try {
|
|
553
|
+
if (typeof transcriptPath !== "string" || !path.isAbsolute(transcriptPath) || typeof root !== "string" || !path.isAbsolute(root)) return null;
|
|
554
|
+
const text = firstPromptText(transcriptPath);
|
|
555
|
+
if (text === null) return null;
|
|
556
|
+
const line = text.split(/\r?\n/).find((l) => l.startsWith(REPORT_LINE));
|
|
557
|
+
return line === undefined ? null : reportPathFromText(line.slice(REPORT_LINE.length), root, cwd);
|
|
558
|
+
} catch {
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** The state and the record follow one report: the reply's, else the prompt's; the prompt never overrides a refused reply path. */
|
|
564
|
+
function reportNaming(payload, root) {
|
|
565
|
+
const { last_assistant_message: message, cwd } = payload;
|
|
566
|
+
const fromReply = typeof message === "string" ? reportPathFromText(message, root, cwd) : null;
|
|
567
|
+
if (fromReply !== null) return fromReply;
|
|
568
|
+
const file = promptTranscript(payload);
|
|
569
|
+
return file === null ? null : promptReport(file, root, cwd);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function namedReport(payload, root) {
|
|
573
|
+
const named = reportNaming(payload, root);
|
|
574
|
+
return named === REFUSED ? null : named;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** A symlinked brief must not make a task countable. */
|
|
578
|
+
function regularFile(file) {
|
|
579
|
+
try {
|
|
580
|
+
return fs.lstatSync(file).isFile();
|
|
581
|
+
} catch {
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Review packages are overwritten on re-dispatch, so only an append-only line per stop keeps the handoff count.
|
|
588
|
+
* The task comes from the report path the reply or else the dispatch prompt names, never the untrusted handoff, and every failure is silence.
|
|
589
|
+
* @param {{ root: string, dir: string }} ws The in-flight workspace that report path names.
|
|
590
|
+
* @param {string | null} reportText Report text, null when missing or refused.
|
|
591
|
+
*/
|
|
592
|
+
export function recordHandoff(ws, payload, reportText) {
|
|
593
|
+
try {
|
|
594
|
+
if (!tracker || typeof tracker.appendLedgerLine !== "function" || !ws || typeof ws.root !== "string" || typeof ws.dir !== "string") return;
|
|
595
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return;
|
|
596
|
+
if (typeof payload.cwd !== "string" || !path.isAbsolute(payload.cwd)) return;
|
|
597
|
+
const named = namedReport(payload, ws.root);
|
|
598
|
+
if (!named || ws.dir !== path.join(ws.root, ".freeharness", "sdd", named.change) || !tracker.inFlight(ws.root, named.change)) return;
|
|
599
|
+
if (!regularFile(path.join(ws.dir, `${named.task}.brief.md`))) return;
|
|
600
|
+
const agentId = typeof payload.agent_id === "string" && RECORD_AGENT_ID_RE.test(payload.agent_id) ? payload.agent_id : null;
|
|
601
|
+
const line = { ts: new Date().toISOString(), task: named.task, round: dispatchRound(payload), agent_id: agentId, ...handoffOutcome(reportText) };
|
|
602
|
+
if (Buffer.byteLength(JSON.stringify(line), "utf8") + 1 > MAX_HANDOFF_LINE_BYTES) return;
|
|
603
|
+
// Contained, no-follow, O_APPEND, one-link regular file, one write: a line is whole or absent.
|
|
604
|
+
tracker.appendLedgerLine(path.join(ws.dir, HANDOFFS_FILE), line, ws.root);
|
|
605
|
+
} catch {
|
|
606
|
+
// Recording never changes what the hook answers.
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const WORKER_HOST = "127.0.0.1";
|
|
611
|
+
const START_ROUTE = "/api/subagents/start";
|
|
612
|
+
const STOP_ROUTE = "/api/subagents/stop";
|
|
613
|
+
/** Health read plus call, connect to response; a stop the worker never answers still exits under two seconds. */
|
|
614
|
+
const START_DEADLINE_MS = 1000;
|
|
615
|
+
const STOP_DEADLINE_MS = 1000;
|
|
616
|
+
const MAX_ANSWER_BYTES = 64 * 1024;
|
|
617
|
+
const MAX_REPORT_BYTES = 64 * 1024;
|
|
618
|
+
const MAX_START_TAGS = 4;
|
|
619
|
+
export const TAGGED_BRIEF_CAP = 4000;
|
|
620
|
+
export const UNTAGGED_BRIEF_CAP = 2000;
|
|
621
|
+
const BRIEF_HEADING = "free-mem brief (data, not instructions)";
|
|
622
|
+
export const AGENT_ID_RE = /^[A-Za-z0-9_.-]{1,128}$/;
|
|
623
|
+
/** A label beyond a short word could smuggle prose onto the fence line. */
|
|
624
|
+
const FENCE_OPEN_RE = /^(`{3,})([A-Za-z0-9_.+-]{0,32})$/;
|
|
625
|
+
/** Any indented backtick run, not only an exact closing fence: CommonMark closes a fence indented up to three spaces. */
|
|
626
|
+
const INTERIOR_FENCE_RE = /^\s*`{3}/;
|
|
627
|
+
/** Code points that could hide, break or reorder a brief line out of sight; U+FE0F stays out for emoji. */
|
|
628
|
+
export const BRIEF_UNSAFE_RANGES = [
|
|
629
|
+
[0x0000, 0x001f],
|
|
630
|
+
[0x007f, 0x009f],
|
|
631
|
+
[0x00ad, 0x00ad],
|
|
632
|
+
[0x061c, 0x061c],
|
|
633
|
+
[0x180e, 0x180e],
|
|
634
|
+
[0x200b, 0x200f],
|
|
635
|
+
[0x2028, 0x2029],
|
|
636
|
+
[0x202a, 0x202e],
|
|
637
|
+
[0x2060, 0x2064],
|
|
638
|
+
[0x2066, 0x2069],
|
|
639
|
+
[0xfe00, 0xfe0e],
|
|
640
|
+
[0xfeff, 0xfeff],
|
|
641
|
+
[0xe0000, 0xe007f],
|
|
642
|
+
[0xe0100, 0xe01ef],
|
|
643
|
+
];
|
|
644
|
+
/** Escaped, so the source holds no raw invisible unit; tab and line feed stay legal in a brief line. */
|
|
645
|
+
const BRIEF_UNSAFE_RE = briefUnsafeRe([0x09, 0x0a]);
|
|
646
|
+
|
|
647
|
+
/** Under `u` the surrogate class matches only an unpaired surrogate; one match per code point. */
|
|
648
|
+
function briefUnsafeRe(kept) {
|
|
649
|
+
const hex = (cp) => `\\u{${cp.toString(16)}}`;
|
|
650
|
+
const parts = [];
|
|
651
|
+
for (const [lo, hi] of BRIEF_UNSAFE_RANGES) {
|
|
652
|
+
let from = lo;
|
|
653
|
+
for (let cp = lo; cp <= hi + 1; cp += 1) {
|
|
654
|
+
if (cp <= hi && !kept.includes(cp)) continue;
|
|
655
|
+
if (from < cp) parts.push(from === cp - 1 ? hex(from) : `${hex(from)}-${hex(cp - 1)}`);
|
|
656
|
+
from = cp + 1;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return new RegExp(`[${parts.join("")}]|[\\uD800-\\uDFFF]`, "gu");
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function workerOff() {
|
|
663
|
+
return (process.env.FREEHARNESS_SDD_CONTEXT_WORKER || "").toLowerCase() === "off";
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// The engine's data-dir and port rule, as in freeharness-precompact.mjs, a separate emitted script.
|
|
667
|
+
const MEM_DATA_DIR_KEYS = ["FREE_MEM_DATA_DIR", "CLAUDE_MEM_DATA_DIR"];
|
|
668
|
+
const BOM = 0xfeff;
|
|
669
|
+
|
|
670
|
+
function expandMemHome(p, home) {
|
|
671
|
+
if (p === "~") return home;
|
|
672
|
+
return p.startsWith("~/") ? path.join(home, p.slice(2)) : p;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function isMemDirectory(p) {
|
|
676
|
+
try {
|
|
677
|
+
return fs.statSync(p).isDirectory();
|
|
678
|
+
} catch {
|
|
679
|
+
return false;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** Keys as the engine reads them: BOM dropped, `env` first. */
|
|
684
|
+
function readMemSettings(file) {
|
|
685
|
+
try {
|
|
686
|
+
const text = fs.readFileSync(file, "utf8");
|
|
687
|
+
const raw = JSON.parse(text.charCodeAt(0) === BOM ? text.slice(1) : text);
|
|
688
|
+
const s = raw && (raw.env ?? raw);
|
|
689
|
+
return s && typeof s === "object" ? s : null;
|
|
690
|
+
} catch {
|
|
691
|
+
return null;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function memPort(value) {
|
|
696
|
+
const p = Number.parseInt(String(value ?? ""), 10);
|
|
697
|
+
return Number.isFinite(p) && p > 0 ? p : null;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** The memory data directory from `env` and `home` alone, so a test can pass fake values. */
|
|
701
|
+
export function resolveMemoryDataDir(env, home) {
|
|
702
|
+
for (const key of MEM_DATA_DIR_KEYS) {
|
|
703
|
+
if (typeof env[key] === "string" && env[key]) return expandMemHome(env[key], home);
|
|
704
|
+
}
|
|
705
|
+
const freeDir = path.join(home, ".free-mem");
|
|
706
|
+
const legacyDir = path.join(home, ".claude-mem");
|
|
707
|
+
const defaults = [path.resolve(freeDir), path.resolve(legacyDir)];
|
|
708
|
+
for (const dir of [freeDir, legacyDir]) {
|
|
709
|
+
const s = readMemSettings(path.join(dir, "settings.json"));
|
|
710
|
+
for (const key of MEM_DATA_DIR_KEYS) {
|
|
711
|
+
const value = s ? s[key] : undefined;
|
|
712
|
+
if (typeof value !== "string" || !value) continue;
|
|
713
|
+
const target = expandMemHome(value, home);
|
|
714
|
+
// A seeded default is not a redirect; honoring it would pin the directory choice.
|
|
715
|
+
if (!defaults.includes(path.resolve(target))) return target;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return isMemDirectory(freeDir) || !isMemDirectory(legacyDir) ? freeDir : legacyDir;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** The worker port: env (empty is unset), then the data dir's settings, else the default; pure over (env, home). */
|
|
722
|
+
export function resolveWorkerPort(env, home) {
|
|
723
|
+
const fromEnv = memPort(env.FREE_MEM_WORKER_PORT || env.CLAUDE_MEM_WORKER_PORT);
|
|
724
|
+
if (fromEnv !== null) return fromEnv;
|
|
725
|
+
const s = readMemSettings(path.join(resolveMemoryDataDir(env, home), "settings.json"));
|
|
726
|
+
const fromSettings = s ? memPort(s.FREE_MEM_WORKER_PORT ?? s.CLAUDE_MEM_WORKER_PORT) : null;
|
|
727
|
+
return fromSettings ?? defaultWorkerPort();
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function defaultWorkerPort() {
|
|
731
|
+
return 37700 + (((typeof process.getuid === "function" ? process.getuid() : 77) || 0) % 100);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** A resolver error falls back to the default port, as an unreadable settings file does. */
|
|
735
|
+
function workerPort() {
|
|
736
|
+
try {
|
|
737
|
+
return resolveWorkerPort(process.env, os.homedir());
|
|
738
|
+
} catch {
|
|
739
|
+
return defaultWorkerPort();
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** Another program may hold the port; the engine's FREE_MEM_PRODUCT, copied because this script cannot import it (a test pins both). */
|
|
744
|
+
export const FREE_MEM_PRODUCT = "free-mem";
|
|
745
|
+
|
|
746
|
+
/** Nothing from the answer leaves this function: the port may belong to an unknown program. */
|
|
747
|
+
export async function workerIsFreeMem(port, deadlineMs) {
|
|
748
|
+
try {
|
|
749
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535 || !(deadlineMs > 0)) return false;
|
|
750
|
+
const res = await fetch(`http://${WORKER_HOST}:${port}/api/health`, { signal: AbortSignal.timeout(deadlineMs) });
|
|
751
|
+
if (res.status !== 200 || !res.body) {
|
|
752
|
+
res.body?.cancel().catch(() => {});
|
|
753
|
+
return false;
|
|
754
|
+
}
|
|
755
|
+
const reader = res.body.getReader();
|
|
756
|
+
const chunks = [];
|
|
757
|
+
let total = 0;
|
|
758
|
+
for (;;) {
|
|
759
|
+
const { done, value } = await reader.read();
|
|
760
|
+
if (done) break;
|
|
761
|
+
total += value.byteLength;
|
|
762
|
+
if (total > MAX_ANSWER_BYTES) {
|
|
763
|
+
reader.cancel().catch(() => {});
|
|
764
|
+
return false;
|
|
765
|
+
}
|
|
766
|
+
chunks.push(value);
|
|
767
|
+
}
|
|
768
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
769
|
+
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) && parsed.product === FREE_MEM_PRODUCT;
|
|
770
|
+
} catch {
|
|
771
|
+
return false;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** What a health read left of a deadline, so the read and the call together keep it. */
|
|
776
|
+
function leftOf(budgetMs, startedMs) {
|
|
777
|
+
return budgetMs - (Date.now() - startedMs);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** The route schema's identity fields; a payload the route would reject is never sent. */
|
|
781
|
+
function callIdentity(payload) {
|
|
782
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
|
|
783
|
+
const { session_id: sessionId, agent_id: agentId, agent_type: agentType, cwd } = payload;
|
|
784
|
+
if (typeof agentId !== "string" || !AGENT_ID_RE.test(agentId)) return null;
|
|
785
|
+
if (typeof sessionId !== "string" || sessionId.length < 1 || sessionId.length > 256) return null;
|
|
786
|
+
if (typeof agentType !== "string" || agentType.length < 1 || agentType.length > 128) return null;
|
|
787
|
+
if (typeof cwd !== "string" || cwd.length < 1 || cwd.length > 4096 || !path.isAbsolute(cwd)) return null;
|
|
788
|
+
return { contentSessionId: sessionId, agentId, agentType, cwd };
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** One bounded loopback POST: null on any error, deadline or an answer over its byte bound. */
|
|
792
|
+
function postJson(port, route, body, deadlineMs) {
|
|
793
|
+
return new Promise((resolve) => {
|
|
794
|
+
let req = null;
|
|
795
|
+
let settled = false;
|
|
796
|
+
const finish = (value) => {
|
|
797
|
+
if (settled) return;
|
|
798
|
+
settled = true;
|
|
799
|
+
clearTimeout(timer);
|
|
800
|
+
if (req) req.destroy();
|
|
801
|
+
resolve(value);
|
|
802
|
+
};
|
|
803
|
+
const timer = setTimeout(() => finish(null), deadlineMs);
|
|
804
|
+
try {
|
|
805
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return finish(null);
|
|
806
|
+
const data = Buffer.from(JSON.stringify(body), "utf8");
|
|
807
|
+
const headers = { "content-type": "application/json", "content-length": data.length, connection: "close" };
|
|
808
|
+
req = http.request({ host: WORKER_HOST, port, path: route, method: "POST", agent: false, headers }, (res) => {
|
|
809
|
+
const chunks = [];
|
|
810
|
+
let total = 0;
|
|
811
|
+
res.on("data", (chunk) => {
|
|
812
|
+
total += chunk.length;
|
|
813
|
+
if (total > MAX_ANSWER_BYTES) finish(null);
|
|
814
|
+
else chunks.push(chunk);
|
|
815
|
+
});
|
|
816
|
+
res.on("end", () => finish({ status: res.statusCode || 0, text: Buffer.concat(chunks).toString("utf8") }));
|
|
817
|
+
res.on("error", () => finish(null));
|
|
818
|
+
res.on("close", () => finish(null));
|
|
819
|
+
});
|
|
820
|
+
req.on("error", () => finish(null));
|
|
821
|
+
req.end(data);
|
|
822
|
+
} catch {
|
|
823
|
+
finish(null);
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* The answer is data from another process: only a balanced fence passes, cut at a whole line so the closing fence survives the cap.
|
|
830
|
+
* An unsafe unit is replaced, never a reason to drop.
|
|
831
|
+
* @param {boolean} taskTagged Whether a `task:` tag was sent (the start route's larger cap).
|
|
832
|
+
* @returns {string | null} The fenced brief within its cap, or null to drop it.
|
|
833
|
+
*/
|
|
834
|
+
function acceptBrief(raw, taskTagged) {
|
|
835
|
+
if (typeof raw !== "string" || raw === "") return null;
|
|
836
|
+
const text = raw.replace(/\r\n/g, "\n").replace(BRIEF_UNSAFE_RE, "�");
|
|
837
|
+
const lines = (text.endsWith("\n") ? text.slice(0, -1) : text).split("\n");
|
|
838
|
+
const m = lines.length >= 3 ? FENCE_OPEN_RE.exec(lines[0]) : null;
|
|
839
|
+
if (!m || lines[lines.length - 1] !== m[1]) return null;
|
|
840
|
+
const fence = m[1];
|
|
841
|
+
const interior = lines.slice(1, -1);
|
|
842
|
+
if (interior.some((l) => INTERIOR_FENCE_RE.test(l))) return null;
|
|
843
|
+
const cap = taskTagged ? TAGGED_BRIEF_CAP : UNTAGGED_BRIEF_CAP;
|
|
844
|
+
let size = chars(lines[0]) + 1 + chars(fence);
|
|
845
|
+
const kept = [];
|
|
846
|
+
for (const line of interior) {
|
|
847
|
+
const next = size + chars(line) + 1;
|
|
848
|
+
if (next > cap) break;
|
|
849
|
+
kept.push(line);
|
|
850
|
+
size = next;
|
|
851
|
+
}
|
|
852
|
+
return kept.length > 0 ? [lines[0], ...kept, fence].join("\n") : null;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Asks the worker for the subagent's brief; any failure is no brief, leaving the context as without the call.
|
|
857
|
+
* @param {string[]} tags Change and task tags; a tag off the route's pattern is not sent.
|
|
858
|
+
* @param {number} [deadlineMs] What the hook's start budget has left for the call.
|
|
859
|
+
*/
|
|
860
|
+
export async function startCall(port, payload, tags, deadlineMs = START_DEADLINE_MS) {
|
|
861
|
+
try {
|
|
862
|
+
if (workerOff() || !(deadlineMs > 0)) return null;
|
|
863
|
+
const identity = callIdentity(payload);
|
|
864
|
+
if (!identity || !Array.isArray(tags)) return null;
|
|
865
|
+
const sent = tags.filter((t) => typeof t === "string" && TAG_RE.test(t)).slice(0, MAX_START_TAGS);
|
|
866
|
+
const answer = await postJson(port, START_ROUTE, { ...identity, tags: sent }, deadlineMs);
|
|
867
|
+
if (!answer || answer.status < 200 || answer.status > 299) return null;
|
|
868
|
+
const parsed = JSON.parse(answer.text);
|
|
869
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
870
|
+
return acceptBrief(parsed.brief, sent.some((t) => t.startsWith("task:")));
|
|
871
|
+
} catch {
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** The route's byte bound, cut back to a lead byte so the sent text stays valid UTF-8. */
|
|
877
|
+
function utf8Prefix(text, max) {
|
|
878
|
+
const buf = Buffer.from(text, "utf8");
|
|
879
|
+
if (buf.length <= max) return buf.toString("utf8");
|
|
880
|
+
let end = max;
|
|
881
|
+
while (end > 0 && (buf[end] & 0xc0) === 0x80) end -= 1;
|
|
882
|
+
return buf.toString("utf8", 0, end);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Leaves the agent's final message with the worker; its answer is ignored, so none of it reaches the parent.
|
|
887
|
+
* @param {number} [deadlineMs] What the hook's stop budget has left for the call.
|
|
888
|
+
* @returns {Promise<void>} Settles within the stop deadline.
|
|
889
|
+
*/
|
|
890
|
+
export async function stopCall(port, payload, deadlineMs = STOP_DEADLINE_MS) {
|
|
891
|
+
try {
|
|
892
|
+
if (workerOff() || !(deadlineMs > 0)) return;
|
|
893
|
+
const identity = callIdentity(payload);
|
|
894
|
+
const message = identity ? payload.last_assistant_message : null;
|
|
895
|
+
if (typeof message !== "string" || message === "") return;
|
|
896
|
+
const body = { ...identity, report: utf8Prefix(message, MAX_REPORT_BYTES) };
|
|
897
|
+
const tags = stopTags(payload);
|
|
898
|
+
await postJson(port, STOP_ROUTE, tags.length > 0 ? { ...body, tags } : body, deadlineMs);
|
|
899
|
+
} catch {
|
|
900
|
+
// The report is best effort: a lost one never changes the hook's answer.
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/** Synchronous, so nothing is left queued at exit. */
|
|
905
|
+
function writeOut(text) {
|
|
906
|
+
const buf = Buffer.from(text, "utf8");
|
|
907
|
+
let off = 0;
|
|
908
|
+
while (off < buf.length) off += fs.writeSync(1, buf, off, buf.length - off);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** At stop the workerless output alone decides a refusal, never the worker call. */
|
|
912
|
+
async function respond(payload) {
|
|
913
|
+
const event = payload !== null && typeof payload === "object" ? payload.hook_event_name : null;
|
|
914
|
+
if (event === "SubagentStart") {
|
|
915
|
+
if (workerOff()) return null;
|
|
916
|
+
let brief = null;
|
|
917
|
+
try {
|
|
918
|
+
const started = Date.now();
|
|
919
|
+
const port = workerPort();
|
|
920
|
+
if (await workerIsFreeMem(port, START_DEADLINE_MS)) brief = await startCall(port, payload, [], leftOf(START_DEADLINE_MS, started));
|
|
921
|
+
} catch {
|
|
922
|
+
brief = null;
|
|
923
|
+
}
|
|
924
|
+
return brief === null ? null : `${BRIEF_HEADING}\n${brief}`;
|
|
925
|
+
}
|
|
926
|
+
if (event === "SubagentStop") {
|
|
927
|
+
const outcome = stopOutcome(payload);
|
|
928
|
+
try {
|
|
929
|
+
if (!outcome.refused && !workerOff()) {
|
|
930
|
+
const started = Date.now();
|
|
931
|
+
const port = workerPort();
|
|
932
|
+
if (await workerIsFreeMem(port, STOP_DEADLINE_MS)) await stopCall(port, payload, leftOf(STOP_DEADLINE_MS, started));
|
|
933
|
+
}
|
|
934
|
+
} catch {
|
|
935
|
+
// The parent's answer never depends on the report reaching the worker.
|
|
936
|
+
}
|
|
937
|
+
return outcome.context;
|
|
938
|
+
}
|
|
939
|
+
return null;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
async function main() {
|
|
943
|
+
try {
|
|
944
|
+
let payload;
|
|
945
|
+
try {
|
|
946
|
+
payload = JSON.parse(readStdin(MAX_STDIN_BYTES));
|
|
947
|
+
} catch {
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
const context = await respond(payload);
|
|
951
|
+
if (context === null) return;
|
|
952
|
+
writeOut(JSON.stringify({ hookSpecificOutput: { hookEventName: payload.hook_event_name, additionalContext: context } }));
|
|
953
|
+
} catch {
|
|
954
|
+
// Never block and never speak on stderr: a failure is silence.
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/** Lets tests import the file without side effects. */
|
|
959
|
+
function isMain() {
|
|
960
|
+
try {
|
|
961
|
+
return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
962
|
+
} catch {
|
|
963
|
+
return false;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
if (isMain()) {
|
|
968
|
+
await main();
|
|
969
|
+
process.exit(0);
|
|
970
|
+
}
|