harnery 0.31.6 → 0.32.0
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/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +2 -0
- package/dist/commands/agents.d.ts +0 -9
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +70 -95
- package/dist/commands/browse-session.d.ts +21 -0
- package/dist/commands/browse-session.d.ts.map +1 -0
- package/dist/commands/browse-session.js +157 -0
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +205 -37
- package/dist/commands/checkpoint.js +1 -1
- package/dist/commands/deinit.d.ts.map +1 -1
- package/dist/commands/deinit.js +4 -0
- package/dist/commands/docs.d.ts.map +1 -1
- package/dist/commands/docs.js +40 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +100 -17
- package/dist/commands/init.d.ts +1 -2
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +16 -5
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +2 -3
- package/dist/core/agents/canonical-emit.d.ts +1 -2
- package/dist/core/agents/canonical-emit.d.ts.map +1 -1
- package/dist/core/agents/canonical-emit.js +1 -2
- package/dist/core/agents/cli.js +123 -88
- package/dist/core/agents/coord-client.d.ts +7 -3
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +5 -4
- package/dist/core/agents/finalization.d.ts +68 -0
- package/dist/core/agents/finalization.d.ts.map +1 -0
- package/dist/core/agents/finalization.js +443 -0
- package/dist/core/agents/git-hook.d.ts +51 -0
- package/dist/core/agents/git-hook.d.ts.map +1 -0
- package/dist/core/agents/git-hook.js +118 -0
- package/dist/core/agents/render/prompt-context.d.ts +6 -5
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +26 -13
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +15 -2
- package/dist/core/agents/rules/claim-conflict.js +3 -3
- package/dist/core/agents/rules/commit-conflict.d.ts +15 -6
- package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/commit-conflict.js +21 -5
- package/dist/core/agents/rules/stop-hook.d.ts +3 -0
- package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
- package/dist/core/agents/rules/stop-hook.js +59 -23
- package/dist/core/agents/session-events.d.ts +8 -16
- package/dist/core/agents/session-events.d.ts.map +1 -1
- package/dist/core/agents/session-events.js +12 -26
- package/dist/core/agents/state/heartbeat-projector.d.ts +3 -0
- package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-projector.js +44 -1
- package/dist/core/agents/state/heartbeat-writer.d.ts +32 -5
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +54 -17
- package/dist/core/agents/state/names.d.ts +22 -1
- package/dist/core/agents/state/names.d.ts.map +1 -1
- package/dist/core/agents/state/names.js +40 -2
- package/dist/core/config.d.ts +30 -2
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +74 -11
- package/dist/core/governor/planning.d.ts.map +1 -1
- package/dist/core/governor/planning.js +12 -13
- package/dist/core/hooks/adapter/detect.d.ts +2 -4
- package/dist/core/hooks/adapter/detect.d.ts.map +1 -1
- package/dist/core/hooks/adapter/detect.js +4 -14
- package/dist/core/hooks/adapter/output.d.ts +1 -1
- package/dist/core/hooks/adapter/output.d.ts.map +1 -1
- package/dist/core/hooks/adapter/output.js +5 -4
- package/dist/core/hooks/adapter/wiring.d.ts +23 -0
- package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
- package/dist/core/hooks/adapter/wiring.js +32 -0
- package/dist/core/hooks/cli.d.ts +1 -2
- package/dist/core/hooks/cli.d.ts.map +1 -1
- package/dist/core/hooks/cli.js +169 -27
- package/dist/core/hooks/codex-wsl-bridge.d.ts +46 -0
- package/dist/core/hooks/codex-wsl-bridge.d.ts.map +1 -0
- package/dist/core/hooks/codex-wsl-bridge.js +137 -0
- package/dist/core/hooks/effects/index.d.ts +1 -1
- package/dist/core/hooks/effects/index.js +3 -3
- package/dist/core/hooks/events/schema.d.ts +27 -0
- package/dist/core/hooks/events/schema.d.ts.map +1 -1
- package/dist/core/hooks/resolve/owner.d.ts +7 -1
- package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
- package/dist/core/hooks/resolve/owner.js +15 -0
- package/dist/core/hooks/resolve/transcript.d.ts +44 -0
- package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
- package/dist/core/hooks/resolve/transcript.js +176 -1
- package/dist/core/hooks/session-name-presence.d.ts +30 -0
- package/dist/core/hooks/session-name-presence.d.ts.map +1 -0
- package/dist/core/hooks/session-name-presence.js +43 -0
- package/dist/core/hooks/unsafe-cross-shell.d.ts +13 -0
- package/dist/core/hooks/unsafe-cross-shell.d.ts.map +1 -0
- package/dist/core/hooks/unsafe-cross-shell.js +151 -0
- package/dist/core/work/state.d.ts +4 -5
- package/dist/core/work/state.d.ts.map +1 -1
- package/dist/core/work/state.js +5 -8
- package/dist/core/workflow/index.d.ts +1 -1
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/proof.d.ts +2 -2
- package/dist/core/workflow/proof.d.ts.map +1 -1
- package/dist/core/workflow/proof.js +2 -2
- package/dist/core/workflow/run-state.d.ts +2 -2
- package/dist/core/workflow/run-state.d.ts.map +1 -1
- package/dist/core/workflow/run-state.js +2 -2
- package/dist/core/workflow/types.d.ts +3 -3
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/execution.d.ts +2 -2
- package/dist/core/workflow/workspaces/execution.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/execution.js +5 -0
- package/dist/core/workflow/workspaces/index.d.ts +1 -1
- package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/local-git.js +1 -2
- package/dist/core/workflow/workspaces/paths.d.ts +2 -2
- package/dist/core/workflow/workspaces/types.d.ts +0 -6
- package/dist/core/workflow/workspaces/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/validate.d.ts +0 -2
- package/dist/core/workflow/workspaces/validate.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/validate.js +0 -2
- package/dist/lib/browser/client.d.ts +118 -1
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +435 -6
- package/dist/lib/browser/geometry.d.ts.map +1 -1
- package/dist/lib/browser/geometry.js +180 -37
- package/dist/lib/browser/index.d.ts +5 -1
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/index.js +5 -1
- package/dist/lib/browser/netscape-cookies.d.ts +6 -0
- package/dist/lib/browser/netscape-cookies.d.ts.map +1 -0
- package/dist/lib/browser/netscape-cookies.js +31 -0
- package/dist/lib/browser/proxy.d.ts +24 -0
- package/dist/lib/browser/proxy.d.ts.map +1 -0
- package/dist/lib/browser/proxy.js +84 -0
- package/dist/lib/browser/runts.d.ts +6 -0
- package/dist/lib/browser/runts.d.ts.map +1 -1
- package/dist/lib/browser/runts.js +21 -4
- package/dist/lib/browser/session-control.d.ts +112 -0
- package/dist/lib/browser/session-control.d.ts.map +1 -0
- package/dist/lib/browser/session-control.js +670 -0
- package/dist/lib/docs-links.d.ts +108 -0
- package/dist/lib/docs-links.d.ts.map +1 -0
- package/dist/lib/docs-links.js +555 -0
- package/dist/lib/exec.d.ts +1 -1
- package/dist/lib/exec.js +1 -1
- package/dist/lib/identities/assume.d.ts +4 -2
- package/dist/lib/identities/assume.d.ts.map +1 -1
- package/dist/lib/identities/assume.js +15 -2
- package/dist/lib/instructions/git-hooks.d.ts +75 -0
- package/dist/lib/instructions/git-hooks.d.ts.map +1 -0
- package/dist/lib/instructions/git-hooks.js +238 -0
- package/dist/lib/instructions/splice.d.ts +10 -4
- package/dist/lib/instructions/splice.d.ts.map +1 -1
- package/dist/lib/instructions/splice.js +19 -11
- package/package.json +1 -1
- package/schemas/config.schema.json +32 -0
- package/src/commander.ts +2 -0
- package/src/commands/agents.ts +96 -115
- package/src/commands/browse-session.ts +245 -0
- package/src/commands/browse.ts +287 -44
- package/src/commands/checkpoint.ts +1 -1
- package/src/commands/deinit.ts +7 -1
- package/src/commands/docs.ts +56 -0
- package/src/commands/doctor.ts +100 -18
- package/src/commands/init.ts +17 -9
- package/src/commands/tunnel.ts +2 -5
- package/src/core/agents/canonical-emit.ts +1 -2
- package/src/core/agents/cli.ts +137 -88
- package/src/core/agents/coord-client.ts +10 -5
- package/src/core/agents/finalization.ts +595 -0
- package/src/core/agents/git-hook.ts +126 -0
- package/src/core/agents/render/prompt-context.ts +28 -13
- package/src/core/agents/render/session-context.ts +15 -2
- package/src/core/agents/rules/claim-conflict.ts +3 -3
- package/src/core/agents/rules/commit-conflict.ts +35 -8
- package/src/core/agents/rules/stop-hook.ts +71 -23
- package/src/core/agents/session-events.ts +14 -41
- package/src/core/agents/state/heartbeat-projector.ts +45 -1
- package/src/core/agents/state/heartbeat-writer.ts +78 -16
- package/src/core/agents/state/names.ts +54 -2
- package/src/core/config.ts +92 -12
- package/src/core/governor/planning.ts +17 -12
- package/src/core/hooks/adapter/detect.ts +4 -13
- package/src/core/hooks/adapter/output.ts +9 -4
- package/src/core/hooks/adapter/wiring.ts +43 -0
- package/src/core/hooks/cli.ts +201 -23
- package/src/core/hooks/codex-wsl-bridge.ts +188 -0
- package/src/core/hooks/effects/index.ts +5 -5
- package/src/core/hooks/events/schema.ts +27 -0
- package/src/core/hooks/resolve/owner.ts +22 -1
- package/src/core/hooks/resolve/transcript.ts +164 -1
- package/src/core/hooks/session-name-presence.ts +52 -0
- package/src/core/hooks/unsafe-cross-shell.ts +160 -0
- package/src/core/work/state.ts +8 -12
- package/src/core/workflow/engine.ts +2 -2
- package/src/core/workflow/index.ts +0 -1
- package/src/core/workflow/proof.ts +4 -4
- package/src/core/workflow/run-state.ts +5 -5
- package/src/core/workflow/types.ts +3 -3
- package/src/core/workflow/workspaces/execution.ts +11 -4
- package/src/core/workflow/workspaces/index.ts +0 -3
- package/src/core/workflow/workspaces/local-git.ts +1 -2
- package/src/core/workflow/workspaces/paths.ts +2 -2
- package/src/core/workflow/workspaces/types.ts +0 -9
- package/src/core/workflow/workspaces/validate.ts +1 -5
- package/src/lib/browser/client.ts +528 -6
- package/src/lib/browser/geometry.ts +197 -37
- package/src/lib/browser/index.ts +39 -0
- package/src/lib/browser/netscape-cookies.ts +39 -0
- package/src/lib/browser/proxy.ts +105 -0
- package/src/lib/browser/runts.ts +27 -3
- package/src/lib/browser/session-control.ts +892 -0
- package/src/lib/docs-links.ts +674 -0
- package/src/lib/exec.ts +1 -1
- package/src/lib/identities/assume.ts +22 -1
- package/src/lib/instructions/git-hooks.ts +259 -0
- package/src/lib/instructions/splice.ts +41 -11
|
@@ -28,7 +28,7 @@ import { join } from "node:path";
|
|
|
28
28
|
import { emitAndProject } from "../../core/agents/cli-emit.ts";
|
|
29
29
|
import { emit } from "../../core/agents/events/emit.ts";
|
|
30
30
|
import { type Heartbeat, readHeartbeat } from "../../core/agents/state/heartbeat-writer.ts";
|
|
31
|
-
import { recordNameAssumption } from "../../core/agents/state/names.ts";
|
|
31
|
+
import { recordNameAssumption, resolveForkAncestry } from "../../core/agents/state/names.ts";
|
|
32
32
|
import { instanceHasLivePid, removePidmapRowsForInstance } from "../../core/agents/state/pidmap.ts";
|
|
33
33
|
import { coordFreshnessSeconds } from "../../core/config.ts";
|
|
34
34
|
import { readRemoteMachines } from "../../core/presence/index.ts";
|
|
@@ -41,6 +41,7 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
|
41
41
|
export type IdentityAssumeErrorCode =
|
|
42
42
|
| "identity_busy"
|
|
43
43
|
| "identity_in_use"
|
|
44
|
+
| "identity_is_ancestor"
|
|
44
45
|
| "identity_not_found"
|
|
45
46
|
| "invalid_identity"
|
|
46
47
|
| "no_heartbeat"
|
|
@@ -255,6 +256,7 @@ export function assumeIdentity(
|
|
|
255
256
|
coordRoot: string,
|
|
256
257
|
instanceId: string,
|
|
257
258
|
target: string,
|
|
259
|
+
opts?: { forceAncestor?: boolean },
|
|
258
260
|
): IdentityAssumeResult {
|
|
259
261
|
const release = acquireLock(coordRoot);
|
|
260
262
|
try {
|
|
@@ -273,6 +275,25 @@ export function assumeIdentity(
|
|
|
273
275
|
}
|
|
274
276
|
|
|
275
277
|
const targetIdentity = resolveTarget(coordRoot, target);
|
|
278
|
+
|
|
279
|
+
// Recorded-fork-lineage guard: a branched session inherits a transcript
|
|
280
|
+
// full of its ancestor's name, so "assume <ancestor>" is far more likely
|
|
281
|
+
// a confused fork trusting its scrollback than a legitimate role handoff.
|
|
282
|
+
// The liveness check below cannot catch this once the ancestor exits;
|
|
283
|
+
// lineage can. --force-ancestor is the deliberate-successor escape hatch.
|
|
284
|
+
if (!opts?.forceAncestor) {
|
|
285
|
+
const ancestor = resolveForkAncestry(coordRoot, instanceId).find(
|
|
286
|
+
(a) => (a.name ?? "").toLowerCase() === targetIdentity.name.toLowerCase(),
|
|
287
|
+
);
|
|
288
|
+
if (ancestor) {
|
|
289
|
+
throw new IdentityAssumeError(
|
|
290
|
+
"identity_is_ancestor",
|
|
291
|
+
`agent-${targetIdentity.name} is this session's fork ancestor (${ancestor.instance_id}): ` +
|
|
292
|
+
`this conversation was branched from that session, so its name in your context is ` +
|
|
293
|
+
`inherited history, not your role. If you are deliberately succeeding it, rerun with --force-ancestor.`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
276
297
|
let reclaimedInstanceId: string | null = null;
|
|
277
298
|
let conflict = findIdentityConflict(coordRoot, instanceId, targetIdentity.name);
|
|
278
299
|
if (conflict && reclaimAbandonedLocalConflict(coordRoot, conflict)) {
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git-hook side of ADR 0008's lifecycle contract: the coordination content of
|
|
3
|
+
* a consumer's git hooks is machine-owned, exactly like the AGENTS.md block.
|
|
4
|
+
* `init` installs/refreshes it, `deinit` removes it, `init --check` flags
|
|
5
|
+
* drift.
|
|
6
|
+
*
|
|
7
|
+
* What lives in the hook file is deliberately tiny: a hash-versioned managed
|
|
8
|
+
* region (# harnery:begin/end markers) that locates `agent-coord` and invokes
|
|
9
|
+
* `git-hook <event>`. Every piece of actual behavior — staged collection,
|
|
10
|
+
* submodule canonicalization, gitlink discrimination, verdict, claim pruning —
|
|
11
|
+
* lives in harnery and upgrades with the package. The region only changes when
|
|
12
|
+
* the invocation contract does, and then `init` re-splices it and `--check`
|
|
13
|
+
* catches a stale copy. History's lesson (the first host carried ~200 lines of
|
|
14
|
+
* coordination bash that decayed for months): logic in a host hook file is
|
|
15
|
+
* logic outside the upgrade path.
|
|
16
|
+
*
|
|
17
|
+
* A hook file harnery created whole is deletable by `deinit` (nothing but the
|
|
18
|
+
* shebang remains after the region is removed); a host-authored hook keeps all
|
|
19
|
+
* host content and only loses the managed region.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawnSync } from "node:child_process";
|
|
23
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
25
|
+
import type { GitHookEvent } from "../../core/agents/git-hook.ts";
|
|
26
|
+
import {
|
|
27
|
+
checkRegion,
|
|
28
|
+
type ManagedStatus,
|
|
29
|
+
regionBlock,
|
|
30
|
+
removeRegion,
|
|
31
|
+
spliceRegion,
|
|
32
|
+
} from "./splice.ts";
|
|
33
|
+
|
|
34
|
+
export const GIT_HOOK_EVENTS = [
|
|
35
|
+
"pre-commit",
|
|
36
|
+
"post-commit",
|
|
37
|
+
"post-checkout",
|
|
38
|
+
] as const satisfies readonly GitHookEvent[];
|
|
39
|
+
export type { GitHookEvent };
|
|
40
|
+
|
|
41
|
+
function regionName(event: GitHookEvent): string {
|
|
42
|
+
return `git-hook-${event}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The managed region body for one hook event. POSIX sh, `harnery_`-prefixed
|
|
47
|
+
* variables (the region runs inside arbitrary host hook scripts), and a bin
|
|
48
|
+
* lookup that covers both consumer layouts: git-submodule
|
|
49
|
+
* (`<root>/harnery/bin/`) and npm (`<root>/node_modules/harnery/bin/`).
|
|
50
|
+
*
|
|
51
|
+
* pre-commit propagates a blocking exit code; post-commit / post-checkout are
|
|
52
|
+
* best-effort and never fail the git operation.
|
|
53
|
+
*/
|
|
54
|
+
export function renderGitHookBody(event: GitHookEvent): string {
|
|
55
|
+
const blocking = event === "pre-commit";
|
|
56
|
+
const invoke = blocking
|
|
57
|
+
? ` "$harnery_hook_bin" git-hook ${event} "$@"\n` +
|
|
58
|
+
` harnery_hook_rc=$?\n` +
|
|
59
|
+
` [ "$harnery_hook_rc" -ne 0 ] && exit "$harnery_hook_rc"\n`
|
|
60
|
+
: ` "$harnery_hook_bin" git-hook ${event} "$@" >/dev/null 2>&1 || true\n`;
|
|
61
|
+
return (
|
|
62
|
+
`# Coordination for this hook lives in harnery (agent-coord git-hook ${event});\n` +
|
|
63
|
+
`# upgrading harnery upgrades the behavior. Managed region: do not edit by hand.\n` +
|
|
64
|
+
`harnery_hook_root=$(git rev-parse --show-superproject-working-tree 2>/dev/null)\n` +
|
|
65
|
+
`[ -n "$harnery_hook_root" ] || harnery_hook_root=$(git rev-parse --show-toplevel 2>/dev/null)\n` +
|
|
66
|
+
`for harnery_hook_bin in "$harnery_hook_root/harnery/bin/agent-coord" "$harnery_hook_root/node_modules/harnery/bin/agent-coord"; do\n` +
|
|
67
|
+
` [ -x "$harnery_hook_bin" ] || continue\n` +
|
|
68
|
+
invoke +
|
|
69
|
+
` break\n` +
|
|
70
|
+
`done`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The effective hooks dir for the repo at `projectRoot`: honors
|
|
76
|
+
* `core.hooksPath` (relative values resolve against the repo root) and
|
|
77
|
+
* worktree layouts, because that's what `git rev-parse --git-path hooks`
|
|
78
|
+
* reports. Null when `projectRoot` isn't a git repo.
|
|
79
|
+
*/
|
|
80
|
+
export function resolveHooksDir(projectRoot: string): string | null {
|
|
81
|
+
const r = spawnSync("git", ["rev-parse", "--git-path", "hooks"], {
|
|
82
|
+
cwd: projectRoot,
|
|
83
|
+
encoding: "utf8",
|
|
84
|
+
timeout: 5000,
|
|
85
|
+
});
|
|
86
|
+
if (r.status !== 0) return null;
|
|
87
|
+
const p = (r.stdout ?? "").trim();
|
|
88
|
+
if (!p) return null;
|
|
89
|
+
return isAbsolute(p) ? p : resolve(projectRoot, p);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface GitHooksResult {
|
|
93
|
+
actions: string[];
|
|
94
|
+
warnings: string[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Install or refresh the managed region in each of the three hook files. */
|
|
98
|
+
export function applyGitHooks(projectRoot: string, opts: { dryRun?: boolean }): GitHooksResult {
|
|
99
|
+
const dryRun = opts.dryRun === true;
|
|
100
|
+
const actions: string[] = [];
|
|
101
|
+
const warnings: string[] = [];
|
|
102
|
+
const hooksDir = resolveHooksDir(projectRoot);
|
|
103
|
+
if (!hooksDir) {
|
|
104
|
+
warnings.push("not a git repository; skipped git-hook regions");
|
|
105
|
+
return { actions, warnings };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!existsSync(hooksDir) && !dryRun) mkdirSync(hooksDir, { recursive: true });
|
|
109
|
+
|
|
110
|
+
for (const event of GIT_HOOK_EVENTS) {
|
|
111
|
+
const file = join(hooksDir, event);
|
|
112
|
+
const body = renderGitHookBody(event);
|
|
113
|
+
const region = regionName(event);
|
|
114
|
+
const display = relative(projectRoot, file);
|
|
115
|
+
|
|
116
|
+
if (!existsSync(file)) {
|
|
117
|
+
if (dryRun) {
|
|
118
|
+
actions.push(`+ would create ${display} (harnery-owned git hook)`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
writeFileSync(file, `#!/bin/sh\n${regionBlock(region, body, "hash")}\n`);
|
|
122
|
+
chmodSync(file, 0o755);
|
|
123
|
+
actions.push(`+ created ${display} (harnery-owned git hook)`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const content = readFileSync(file, "utf8");
|
|
128
|
+
const status = checkRegion(content, region, body, "hash");
|
|
129
|
+
if (status === "fresh") {
|
|
130
|
+
actions.push(`· ${display} git-hook region current`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (status === "stale") {
|
|
135
|
+
const spliced = spliceRegion(content, region, body, "hash");
|
|
136
|
+
if (dryRun) actions.push(`+ would refresh stale git-hook region in ${display}`);
|
|
137
|
+
else {
|
|
138
|
+
writeFileSync(file, spliced.text);
|
|
139
|
+
actions.push(`+ refreshed stale git-hook region in ${display}`);
|
|
140
|
+
}
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Missing from an existing, host-authored hook: insert right after the
|
|
145
|
+
// shebang. Coordination runs first on purpose — an agent should hear
|
|
146
|
+
// "this commit is blocked by a peer's claim" before paying for the host's
|
|
147
|
+
// typecheck/lint checks, and post-checkout's claim release must beat any
|
|
148
|
+
// host short-circuit below it.
|
|
149
|
+
if (dryRun) {
|
|
150
|
+
actions.push(`+ would insert git-hook region into ${display} (after shebang)`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const block = regionBlock(region, body, "hash");
|
|
154
|
+
let text: string;
|
|
155
|
+
if (content.startsWith("#!")) {
|
|
156
|
+
const nl = content.indexOf("\n");
|
|
157
|
+
text = `${content.slice(0, nl + 1)}\n${block}\n${content.slice(nl + 1)}`;
|
|
158
|
+
} else {
|
|
159
|
+
text = `${block}\n\n${content}`;
|
|
160
|
+
}
|
|
161
|
+
writeFileSync(file, text);
|
|
162
|
+
actions.push(`+ inserted git-hook region into ${display} (after shebang)`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return { actions, warnings };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Has this project adopted harnery-managed git hooks at all? True when any of
|
|
170
|
+
* the three hook files carries a managed region. Adoption is the gate between
|
|
171
|
+
* "never installed" (a consumer that upgraded but hasn't opted in — not drift,
|
|
172
|
+
* `--check` stays green, `doctor` nudges) and "decayed" (a region existed and
|
|
173
|
+
* is now stale or partially deleted — drift, `--check` goes red).
|
|
174
|
+
*/
|
|
175
|
+
export function gitHooksInstalled(projectRoot: string): boolean {
|
|
176
|
+
const hooksDir = resolveHooksDir(projectRoot);
|
|
177
|
+
if (!hooksDir) return false;
|
|
178
|
+
for (const event of GIT_HOOK_EVENTS) {
|
|
179
|
+
const file = join(hooksDir, event);
|
|
180
|
+
if (!existsSync(file)) continue;
|
|
181
|
+
try {
|
|
182
|
+
if (readFileSync(file, "utf8").includes(`harnery:begin ${regionName(event)}`)) return true;
|
|
183
|
+
} catch {
|
|
184
|
+
/* unreadable file counts as absent */
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Drift report for `init --check`: per-hook managed-region freshness. A
|
|
192
|
+
* project that never adopted git hooks reports fresh — an upgrade must not
|
|
193
|
+
* turn a consumer's CI red for a feature they haven't installed.
|
|
194
|
+
*/
|
|
195
|
+
export function checkGitHooks(projectRoot: string): { status: ManagedStatus; issues: string[] } {
|
|
196
|
+
const issues: string[] = [];
|
|
197
|
+
let worst: ManagedStatus = "fresh";
|
|
198
|
+
const hooksDir = resolveHooksDir(projectRoot);
|
|
199
|
+
if (!hooksDir) return { status: "fresh", issues };
|
|
200
|
+
if (!gitHooksInstalled(projectRoot)) return { status: "fresh", issues };
|
|
201
|
+
|
|
202
|
+
for (const event of GIT_HOOK_EVENTS) {
|
|
203
|
+
const file = join(hooksDir, event);
|
|
204
|
+
const display = relative(projectRoot, file);
|
|
205
|
+
if (!existsSync(file)) {
|
|
206
|
+
issues.push(`${display}: missing (run init to create the harnery git hook)`);
|
|
207
|
+
worst = "missing";
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const status = checkRegion(
|
|
211
|
+
readFileSync(file, "utf8"),
|
|
212
|
+
regionName(event),
|
|
213
|
+
renderGitHookBody(event),
|
|
214
|
+
"hash",
|
|
215
|
+
);
|
|
216
|
+
if (status !== "fresh") {
|
|
217
|
+
issues.push(`${display}: git-hook region ${status} (re-run init to refresh)`);
|
|
218
|
+
if (worst === "fresh") worst = status;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return { status: worst, issues };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Remove the managed region from each hook. A file that was harnery-created
|
|
226
|
+
* whole (nothing but a shebang left) is deleted; host-authored hooks keep
|
|
227
|
+
* every host line.
|
|
228
|
+
*/
|
|
229
|
+
export function removeGitHooks(projectRoot: string, opts: { dryRun?: boolean }): GitHooksResult {
|
|
230
|
+
const dryRun = opts.dryRun === true;
|
|
231
|
+
const actions: string[] = [];
|
|
232
|
+
const warnings: string[] = [];
|
|
233
|
+
const hooksDir = resolveHooksDir(projectRoot);
|
|
234
|
+
if (!hooksDir) return { actions, warnings };
|
|
235
|
+
|
|
236
|
+
for (const event of GIT_HOOK_EVENTS) {
|
|
237
|
+
const file = join(hooksDir, event);
|
|
238
|
+
if (!existsSync(file)) continue;
|
|
239
|
+
const display = relative(projectRoot, file);
|
|
240
|
+
const content = readFileSync(file, "utf8");
|
|
241
|
+
const { text, removed } = removeRegion(content, regionName(event), "hash");
|
|
242
|
+
if (!removed) continue;
|
|
243
|
+
|
|
244
|
+
const residue = text.replace(/^#![^\n]*\n?/, "").trim();
|
|
245
|
+
if (residue === "") {
|
|
246
|
+
if (dryRun) actions.push(`+ would remove ${display} (was harnery-owned)`);
|
|
247
|
+
else {
|
|
248
|
+
rmSync(file);
|
|
249
|
+
actions.push(`+ removed ${display} (was harnery-owned)`);
|
|
250
|
+
}
|
|
251
|
+
} else if (dryRun) {
|
|
252
|
+
actions.push(`+ would remove git-hook region from ${display} (host content kept)`);
|
|
253
|
+
} else {
|
|
254
|
+
writeFileSync(file, text);
|
|
255
|
+
actions.push(`+ removed git-hook region from ${display} (host content kept)`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return { actions, warnings };
|
|
259
|
+
}
|
|
@@ -32,17 +32,33 @@ function escapeRe(s: string): string {
|
|
|
32
32
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Marker comment style. Markdown/HTML files wrap markers in HTML comments;
|
|
37
|
+
* shell files (git hooks) use `#` line comments. The style only changes the
|
|
38
|
+
* marker syntax — hashing, splice, remove, and check semantics are identical.
|
|
39
|
+
*/
|
|
40
|
+
export type CommentStyle = "html" | "hash";
|
|
41
|
+
|
|
42
|
+
function markerPair(style: CommentStyle): { open: string; close: string } {
|
|
43
|
+
return style === "hash" ? { open: "#", close: "" } : { open: "<!--", close: "-->" };
|
|
44
|
+
}
|
|
45
|
+
|
|
35
46
|
/** Capture regex for a named managed region: begin-marker, body, end-marker. */
|
|
36
|
-
function regionRe(region: string): RegExp {
|
|
47
|
+
function regionRe(region: string, style: CommentStyle = "html"): RegExp {
|
|
37
48
|
const r = escapeRe(region);
|
|
49
|
+
const { open, close } = markerPair(style);
|
|
50
|
+
const o = escapeRe(open);
|
|
51
|
+
const c = close ? `\\s*${escapeRe(close)}` : "";
|
|
38
52
|
return new RegExp(
|
|
39
|
-
`(
|
|
53
|
+
`(${o}\\s*harnery:begin ${r}(?:\\s+v=([0-9a-f]*))?${c ? c : "[ \\t]*"})([\\s\\S]*?)(${o}\\s*harnery:end ${r}${c ? c : "[ \\t]*"})`,
|
|
40
54
|
);
|
|
41
55
|
}
|
|
42
56
|
|
|
43
57
|
/** Canonical region block: begin-marker, body flanked by newlines, end-marker. */
|
|
44
|
-
export function regionBlock(region: string, body: string): string {
|
|
45
|
-
|
|
58
|
+
export function regionBlock(region: string, body: string, style: CommentStyle = "html"): string {
|
|
59
|
+
const { open, close } = markerPair(style);
|
|
60
|
+
const tail = close ? ` ${close}` : "";
|
|
61
|
+
return `${open} harnery:begin ${region} v=${shortHash(body)}${tail}\n${body}\n${open} harnery:end ${region}${tail}`;
|
|
46
62
|
}
|
|
47
63
|
|
|
48
64
|
export type ManagedStatus = "fresh" | "stale" | "missing";
|
|
@@ -63,10 +79,15 @@ export interface SpliceResult {
|
|
|
63
79
|
* absent, the block is appended after existing content (blank-line separated);
|
|
64
80
|
* an empty/whitespace-only `content` becomes just the block.
|
|
65
81
|
*/
|
|
66
|
-
export function spliceRegion(
|
|
67
|
-
|
|
82
|
+
export function spliceRegion(
|
|
83
|
+
content: string,
|
|
84
|
+
region: string,
|
|
85
|
+
body: string,
|
|
86
|
+
style: CommentStyle = "html",
|
|
87
|
+
): SpliceResult {
|
|
88
|
+
const re = regionRe(region, style);
|
|
68
89
|
const m = content.match(re);
|
|
69
|
-
const fresh = regionBlock(region, body);
|
|
90
|
+
const fresh = regionBlock(region, body, style);
|
|
70
91
|
if (m) {
|
|
71
92
|
const stale = m[2] !== shortHash(body) || m[3] !== `\n${body}\n`;
|
|
72
93
|
// Replacer fn avoids `$`-in-body being read as a capture reference.
|
|
@@ -84,8 +105,12 @@ export function spliceRegion(content: string, region: string, body: string): Spl
|
|
|
84
105
|
* was the file's only content, the result is the empty string — the caller
|
|
85
106
|
* decides whether to delete the file.
|
|
86
107
|
*/
|
|
87
|
-
export function removeRegion(
|
|
88
|
-
|
|
108
|
+
export function removeRegion(
|
|
109
|
+
content: string,
|
|
110
|
+
region: string,
|
|
111
|
+
style: CommentStyle = "html",
|
|
112
|
+
): { text: string; removed: boolean } {
|
|
113
|
+
const re = regionRe(region, style);
|
|
89
114
|
if (!re.test(content)) return { text: content, removed: false };
|
|
90
115
|
const stripped = content
|
|
91
116
|
.replace(re, "")
|
|
@@ -96,8 +121,13 @@ export function removeRegion(content: string, region: string): { text: string; r
|
|
|
96
121
|
}
|
|
97
122
|
|
|
98
123
|
/** Region freshness: missing, stale (hash or body drifted), or fresh. */
|
|
99
|
-
export function checkRegion(
|
|
100
|
-
|
|
124
|
+
export function checkRegion(
|
|
125
|
+
content: string,
|
|
126
|
+
region: string,
|
|
127
|
+
body: string,
|
|
128
|
+
style: CommentStyle = "html",
|
|
129
|
+
): ManagedStatus {
|
|
130
|
+
const m = content.match(regionRe(region, style));
|
|
101
131
|
if (!m) return "missing";
|
|
102
132
|
return m[2] === shortHash(body) && m[3] === `\n${body}\n` ? "fresh" : "stale";
|
|
103
133
|
}
|