@lannguyensi/harness 0.21.1 → 0.22.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.
@@ -0,0 +1,60 @@
1
+ export declare const SENTINEL_BASENAME = ".harness-paused";
2
+ export interface PauseSentinel {
3
+ /** ISO-8601 of when `harness pause` ran. */
4
+ pausedAt: string;
5
+ /** ISO-8601 expiry, or null for `--indefinite`. */
6
+ expiresAt: string | null;
7
+ /** Operator-supplied reason, or null when none was passed. */
8
+ reason: string | null;
9
+ /** Caller identity recorded by the CLI (host/user). */
10
+ pausedBy: string | null;
11
+ }
12
+ export declare function sentinelPath(generatedDir: string): string;
13
+ export type ReadSentinelResult = {
14
+ kind: "absent";
15
+ } | {
16
+ kind: "active";
17
+ sentinel: PauseSentinel;
18
+ } | {
19
+ kind: "expired";
20
+ sentinel: PauseSentinel;
21
+ };
22
+ export declare function readSentinel(generatedDir: string, now?: Date): ReadSentinelResult;
23
+ export declare function writeSentinel(generatedDir: string, sentinel: PauseSentinel): void;
24
+ /** Returns true when a sentinel existed and was removed. */
25
+ export declare function deleteSentinel(generatedDir: string): boolean;
26
+ /**
27
+ * Format a short human-readable relative offset between `from` (the past
28
+ * anchor for "since X ago") or `to` (the future anchor for "in X") and
29
+ * `now`. Always rounds toward 1 so a near-boundary value never reads "0s".
30
+ */
31
+ export declare function formatRelative(targetIso: string, now: Date): string;
32
+ export interface AnnouncePauseOptions {
33
+ /** Already-resolved generatedDir. Required: hooks resolve this themselves. */
34
+ generatedDir: string;
35
+ /** Override "now" for tests. */
36
+ now?: Date;
37
+ /** Where to write the notice line. Defaults to process.stderr. */
38
+ stderr?: NodeJS.WritableStream;
39
+ /**
40
+ * Hook label inserted into the notice line so an operator scanning a
41
+ * busy stderr can tell which hook fire emitted it. Defaults to a
42
+ * generic "hook" tag.
43
+ */
44
+ hookLabel?: string;
45
+ }
46
+ /**
47
+ * Hook integration helper. One call from each PreToolUse / PostToolUse
48
+ * hook covers:
49
+ * - active sentinel → emit one stderr line; caller exits 0 (allow).
50
+ * - expired sentinel → silently delete (auto-resume); caller proceeds.
51
+ * - absent sentinel → no-op; caller proceeds.
52
+ *
53
+ * The boolean `paused` return tells the caller whether to short-circuit.
54
+ * Any I/O error in the sentinel read degrades to `paused: false` rather
55
+ * than blocking: a broken state file is a debug nuisance, not a reason
56
+ * to silently freeze the whole session.
57
+ */
58
+ export declare function maybeAnnouncePause(opts: AnnouncePauseOptions): {
59
+ paused: boolean;
60
+ };
@@ -0,0 +1,145 @@
1
+ // harness pause/resume sentinel — temporary hook bypass for operator-only
2
+ // recovery, debug, and incident-mode flows.
3
+ //
4
+ // The whole feature is one JSON file at `<generatedDir>/.harness-paused`.
5
+ // While it exists and has not expired, every PreToolUse / PostToolUse hook
6
+ // emits a one-line stderr notice and short-circuits to allow instead of
7
+ // evaluating its normal gate logic. On the first hook fire AFTER expiry,
8
+ // the sentinel is silently deleted (auto-resume) and evaluation resumes.
9
+ //
10
+ // Source-of-truth split:
11
+ // - Hook enforcement reads the sentinel file. Stat() on a known path —
12
+ // no per-call grounding-mcp roundtrip.
13
+ // - Audit trail goes to the evidence ledger via `harness pause` and
14
+ // `harness resume`. The ledger is queryable history; the sentinel is
15
+ // ephemeral state.
16
+ //
17
+ // Operator-only design: the `harness pause` verb refuses to run inside an
18
+ // agent shell (where `$CLAUDE_SESSION_ID` is set) and refuses non-TTY
19
+ // stdin without an explicit `--i-am-the-operator` acknowledgement. This
20
+ // is the load-bearing guardrail against pause becoming an agent bypass.
21
+ import * as fs from "node:fs";
22
+ import * as path from "node:path";
23
+ import { atomicWriteFile } from "../io/atomic-write.js";
24
+ export const SENTINEL_BASENAME = ".harness-paused";
25
+ export function sentinelPath(generatedDir) {
26
+ return path.join(generatedDir, SENTINEL_BASENAME);
27
+ }
28
+ export function readSentinel(generatedDir, now = new Date()) {
29
+ let raw;
30
+ try {
31
+ raw = fs.readFileSync(sentinelPath(generatedDir), "utf8");
32
+ }
33
+ catch {
34
+ return { kind: "absent" };
35
+ }
36
+ let sentinel;
37
+ try {
38
+ sentinel = normalizeSentinel(JSON.parse(raw));
39
+ }
40
+ catch {
41
+ // A malformed sentinel file is treated as absent: a broken state file
42
+ // must never escalate into a session-wide block. The `harness pause`
43
+ // verb only ever writes well-formed JSON, so this branch is reserved
44
+ // for operator-corrupted files.
45
+ return { kind: "absent" };
46
+ }
47
+ if (sentinel.expiresAt === null)
48
+ return { kind: "active", sentinel };
49
+ const expires = Date.parse(sentinel.expiresAt);
50
+ if (!Number.isFinite(expires))
51
+ return { kind: "active", sentinel };
52
+ if (expires <= now.getTime())
53
+ return { kind: "expired", sentinel };
54
+ return { kind: "active", sentinel };
55
+ }
56
+ function normalizeSentinel(raw) {
57
+ const pausedAt = typeof raw["pausedAt"] === "string" ? raw["pausedAt"] : "";
58
+ if (pausedAt === "")
59
+ throw new Error("missing pausedAt");
60
+ // expiresAt must be either an explicit null (indefinite) or a non-empty
61
+ // string. A typed-wrong value (e.g. number 42) is rejected as malformed
62
+ // rather than silently downgraded to indefinite — without this, a forged
63
+ // sentinel of `{"pausedAt":"...","expiresAt":42}` would read as a no-
64
+ // auto-resume pause that survives every hook fire.
65
+ const expiresAtRaw = raw["expiresAt"];
66
+ let expiresAt;
67
+ if (expiresAtRaw === null || expiresAtRaw === undefined) {
68
+ expiresAt = null;
69
+ }
70
+ else if (typeof expiresAtRaw === "string" && expiresAtRaw.length > 0) {
71
+ expiresAt = expiresAtRaw;
72
+ }
73
+ else {
74
+ throw new Error("expiresAt must be a non-empty ISO string or null");
75
+ }
76
+ const reason = typeof raw["reason"] === "string" ? raw["reason"] : null;
77
+ const pausedBy = typeof raw["pausedBy"] === "string" ? raw["pausedBy"] : null;
78
+ return { pausedAt, expiresAt, reason, pausedBy };
79
+ }
80
+ export function writeSentinel(generatedDir, sentinel) {
81
+ atomicWriteFile(sentinelPath(generatedDir), `${JSON.stringify(sentinel, null, 2)}\n`);
82
+ }
83
+ /** Returns true when a sentinel existed and was removed. */
84
+ export function deleteSentinel(generatedDir) {
85
+ try {
86
+ fs.rmSync(sentinelPath(generatedDir));
87
+ return true;
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ }
93
+ /**
94
+ * Format a short human-readable relative offset between `from` (the past
95
+ * anchor for "since X ago") or `to` (the future anchor for "in X") and
96
+ * `now`. Always rounds toward 1 so a near-boundary value never reads "0s".
97
+ */
98
+ export function formatRelative(targetIso, now) {
99
+ const target = Date.parse(targetIso);
100
+ if (!Number.isFinite(target))
101
+ return "?";
102
+ const abs = Math.max(1, Math.round(Math.abs(target - now.getTime()) / 1000));
103
+ if (abs < 90)
104
+ return `${abs}s`;
105
+ const mins = Math.round(abs / 60);
106
+ if (mins < 90)
107
+ return `${mins}m`;
108
+ const hrs = Math.round(mins / 60);
109
+ if (hrs < 36)
110
+ return `${hrs}h`;
111
+ const days = Math.round(hrs / 24);
112
+ return `${days}d`;
113
+ }
114
+ /**
115
+ * Hook integration helper. One call from each PreToolUse / PostToolUse
116
+ * hook covers:
117
+ * - active sentinel → emit one stderr line; caller exits 0 (allow).
118
+ * - expired sentinel → silently delete (auto-resume); caller proceeds.
119
+ * - absent sentinel → no-op; caller proceeds.
120
+ *
121
+ * The boolean `paused` return tells the caller whether to short-circuit.
122
+ * Any I/O error in the sentinel read degrades to `paused: false` rather
123
+ * than blocking: a broken state file is a debug nuisance, not a reason
124
+ * to silently freeze the whole session.
125
+ */
126
+ export function maybeAnnouncePause(opts) {
127
+ const now = opts.now ?? new Date();
128
+ const stderr = opts.stderr ?? process.stderr;
129
+ const hookLabel = opts.hookLabel ?? "hook";
130
+ const result = readSentinel(opts.generatedDir, now);
131
+ if (result.kind === "absent")
132
+ return { paused: false };
133
+ if (result.kind === "expired") {
134
+ deleteSentinel(opts.generatedDir);
135
+ return { paused: false };
136
+ }
137
+ const reason = result.sentinel.reason ?? "(no reason given)";
138
+ const since = formatRelative(result.sentinel.pausedAt, now);
139
+ const remaining = result.sentinel.expiresAt === null
140
+ ? "indefinite (no auto-resume)"
141
+ : `auto-resumes in ${formatRelative(result.sentinel.expiresAt, now)}`;
142
+ stderr.write(`harness ${hookLabel}: PAUSED since ${since} ago (reason: ${reason}); ${remaining}. Run \`harness resume\` to re-enable.\n`);
143
+ return { paused: true };
144
+ }
145
+ //# sourceMappingURL=pause-sentinel.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pause-sentinel.js","sourceRoot":"","sources":["../../src/runtime/pause-sentinel.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lannguyensi/harness",
3
- "version": "0.21.1",
3
+ "version": "0.22.0",
4
4
  "description": "Declarative control plane for agent harnesses — one YAML for grounding, tools, memory, and hooks.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/LanNguyenSi/harness",