@deftai/directive-core 0.107.0 → 0.108.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.
Files changed (167) hide show
  1. package/dist/architecture/sor-preflight.js +2 -0
  2. package/dist/check/cached-orchestrator.js +28 -3
  3. package/dist/check/named-cause.js +18 -3
  4. package/dist/codebase/provider.js +7 -1
  5. package/dist/delivery-attempt/handoff.js +1 -1
  6. package/dist/design-critique/citation-grammar.d.ts +53 -0
  7. package/dist/design-critique/citation-grammar.js +303 -0
  8. package/dist/design-critique/completed-arc-record.d.ts +62 -0
  9. package/dist/design-critique/completed-arc-record.js +232 -0
  10. package/dist/freshness/bind.d.ts +32 -3
  11. package/dist/freshness/bind.js +58 -12
  12. package/dist/hooks/classify/host-session-identity.d.ts +76 -0
  13. package/dist/hooks/classify/host-session-identity.js +470 -0
  14. package/dist/hooks/classify/index.d.ts +3 -2
  15. package/dist/hooks/classify/index.js +3 -2
  16. package/dist/hooks/classify/paths.d.ts +7 -0
  17. package/dist/hooks/classify/paths.js +35 -0
  18. package/dist/hooks/classify/stdin.d.ts +5 -0
  19. package/dist/hooks/classify/stdin.js +27 -0
  20. package/dist/hooks/dispatcher.d.ts +52 -1
  21. package/dist/hooks/dispatcher.js +538 -45
  22. package/dist/hooks/fixtures/cases.d.ts +16 -2
  23. package/dist/hooks/fixtures/cases.js +175 -0
  24. package/dist/hooks/scope.js +3 -1
  25. package/dist/init-deposit/agent-hooks.d.ts +2 -1
  26. package/dist/init-deposit/agent-hooks.js +8 -1
  27. package/dist/init-deposit/hook-runtime-travel.d.ts +77 -0
  28. package/dist/init-deposit/hook-runtime-travel.js +269 -0
  29. package/dist/intake/clause-derivation.js +9 -3
  30. package/dist/intake/issue-ingest.js +62 -2
  31. package/dist/intake/platform-capabilities.d.ts +9 -2
  32. package/dist/intake/platform-capabilities.js +38 -9
  33. package/dist/orphan-active/evaluate.d.ts +25 -0
  34. package/dist/orphan-active/evaluate.js +193 -128
  35. package/dist/orphan-active/index.d.ts +1 -0
  36. package/dist/orphan-active/index.js +1 -0
  37. package/dist/orphan-active/issue-state.d.ts +121 -0
  38. package/dist/orphan-active/issue-state.js +278 -0
  39. package/dist/platform/cursor-managed-runtime.d.ts +95 -0
  40. package/dist/platform/cursor-managed-runtime.js +241 -0
  41. package/dist/platform/index.d.ts +1 -0
  42. package/dist/platform/index.js +1 -0
  43. package/dist/platform/platform-capabilities.d.ts +23 -1
  44. package/dist/platform/platform-capabilities.js +55 -15
  45. package/dist/policy/ceremony-dial.js +5 -9
  46. package/dist/policy/host-hooks.js +4 -9
  47. package/dist/policy/org-force-on-migration.js +5 -10
  48. package/dist/policy/plan-extensions.d.ts +14 -2
  49. package/dist/policy/plan-extensions.js +24 -3
  50. package/dist/policy/product-signal.js +5 -10
  51. package/dist/policy/require-human-merge.js +5 -9
  52. package/dist/policy/resolve.js +77 -16
  53. package/dist/policy/value-feedback.js +9 -18
  54. package/dist/pr-closeout-attestable/evaluate.d.ts +80 -0
  55. package/dist/pr-closeout-attestable/evaluate.js +306 -0
  56. package/dist/pr-closeout-attestable/index.d.ts +2 -0
  57. package/dist/pr-closeout-attestable/index.js +2 -0
  58. package/dist/pr-protected-issues/gh.d.ts +6 -2
  59. package/dist/pr-protected-issues/gh.js +7 -3
  60. package/dist/pr-wait-mergeable/cascade.d.ts +9 -1
  61. package/dist/pr-wait-mergeable/cascade.js +21 -1
  62. package/dist/pr-wait-mergeable/types.d.ts +2 -0
  63. package/dist/pr-wait-mergeable/wrappers.d.ts +6 -0
  64. package/dist/pr-wait-mergeable/wrappers.js +11 -0
  65. package/dist/preflight/evaluate.d.ts +15 -0
  66. package/dist/preflight/evaluate.js +29 -3
  67. package/dist/product-first-done-gate/acceptance-resolver.d.ts +14 -2
  68. package/dist/product-first-done-gate/acceptance-resolver.js +13 -2
  69. package/dist/product-first-done-gate/evaluate.js +17 -7
  70. package/dist/release/cli-drift-report.d.ts +68 -0
  71. package/dist/release/cli-drift-report.js +189 -0
  72. package/dist/release/index.d.ts +1 -0
  73. package/dist/release/index.js +1 -0
  74. package/dist/release/issue-state-fetch.d.ts +5 -3
  75. package/dist/release/issue-state-fetch.js +86 -21
  76. package/dist/release/native-steps.js +1 -0
  77. package/dist/release/pipeline.js +19 -0
  78. package/dist/release/types.d.ts +14 -0
  79. package/dist/render/constants.d.ts +4 -0
  80. package/dist/render/constants.js +11 -8
  81. package/dist/render/export-spec.js +31 -5
  82. package/dist/render/index.d.ts +1 -1
  83. package/dist/render/index.js +1 -1
  84. package/dist/render/prd-render.d.ts +4 -1
  85. package/dist/render/prd-render.js +82 -23
  86. package/dist/render/project-render.js +14 -7
  87. package/dist/render/scope-outlook.d.ts +2 -0
  88. package/dist/render/scope-outlook.js +3 -0
  89. package/dist/render/spec-render.js +2 -2
  90. package/dist/resolution/package-manager.d.ts +33 -4
  91. package/dist/resolution/package-manager.js +210 -9
  92. package/dist/review-monitor/constants.d.ts +7 -0
  93. package/dist/review-monitor/constants.js +7 -0
  94. package/dist/review-monitor/github-lease.d.ts +98 -1
  95. package/dist/review-monitor/github-lease.js +182 -1
  96. package/dist/review-monitor/lease-comment.d.ts +36 -0
  97. package/dist/review-monitor/lease-comment.js +103 -2
  98. package/dist/scm/call.js +12 -3
  99. package/dist/scm/design-critique-chip.d.ts +1 -0
  100. package/dist/scm/design-critique-chip.js +22 -6
  101. package/dist/scm/gh-rest.d.ts +9 -1
  102. package/dist/scm/gh-rest.js +103 -0
  103. package/dist/scm/readiness.d.ts +2 -0
  104. package/dist/scm/readiness.js +28 -4
  105. package/dist/scope/project-definition-sync.js +14 -7
  106. package/dist/session/git.d.ts +24 -0
  107. package/dist/session/git.js +138 -2
  108. package/dist/session/occupancy.d.ts +238 -12
  109. package/dist/session/occupancy.js +815 -29
  110. package/dist/session/ritual-sentinel.d.ts +5 -0
  111. package/dist/session/ritual-sentinel.js +5 -0
  112. package/dist/session/session-ready.d.ts +5 -1
  113. package/dist/session/session-ready.js +98 -8
  114. package/dist/session/session-start.d.ts +5 -1
  115. package/dist/session/session-start.js +32 -17
  116. package/dist/session/toolchain-preflight.d.ts +13 -3
  117. package/dist/session/toolchain-preflight.js +93 -18
  118. package/dist/session/verify-session-ritual.d.ts +11 -0
  119. package/dist/session/verify-session-ritual.js +60 -15
  120. package/dist/slice/constants.d.ts +1 -1
  121. package/dist/spec-authority/constants.d.ts +10 -2
  122. package/dist/spec-authority/constants.js +53 -8
  123. package/dist/spec-authority/resolver.d.ts +3 -0
  124. package/dist/spec-authority/resolver.js +55 -7
  125. package/dist/subprocess/max-buffer.d.ts +14 -0
  126. package/dist/subprocess/max-buffer.js +15 -0
  127. package/dist/swarm/complete-cohort.d.ts +2 -0
  128. package/dist/swarm/complete-cohort.js +21 -13
  129. package/dist/swarm/index.d.ts +1 -0
  130. package/dist/swarm/index.js +1 -0
  131. package/dist/swarm/launch-cli.js +53 -33
  132. package/dist/swarm/launch.d.ts +8 -0
  133. package/dist/swarm/launch.js +63 -43
  134. package/dist/swarm/pre-dispatch-cli.js +2 -1
  135. package/dist/swarm/pre-dispatch.js +10 -1
  136. package/dist/swarm/subagent-status-dir.d.ts +27 -0
  137. package/dist/swarm/subagent-status-dir.js +42 -0
  138. package/dist/swarm/worktrees.d.ts +17 -3
  139. package/dist/swarm/worktrees.js +48 -4
  140. package/dist/triage/scope/mutations-core.d.ts +2 -6
  141. package/dist/triage/scope/mutations-core.js +23 -55
  142. package/dist/triage/scope-drift/add-ignore.js +5 -39
  143. package/dist/triage/subscribe/index.d.ts +2 -6
  144. package/dist/triage/subscribe/index.js +15 -65
  145. package/dist/triage/welcome/writers.js +15 -74
  146. package/dist/vbrief-build/index.d.ts +2 -1
  147. package/dist/vbrief-build/index.js +2 -1
  148. package/dist/vbrief-build/parity-scenarios.js +5 -5
  149. package/dist/vbrief-build/project-definition-io.d.ts +63 -3
  150. package/dist/vbrief-build/project-definition-io.js +357 -31
  151. package/dist/vbrief-build/project-definition-mutation.d.ts +32 -0
  152. package/dist/vbrief-build/project-definition-mutation.js +43 -0
  153. package/dist/vbrief-validate/conformance.d.ts +1 -0
  154. package/dist/vbrief-validate/conformance.js +85 -8
  155. package/dist/vbrief-validate/main.js +2 -0
  156. package/dist/vbrief-validate/precutover.js +5 -10
  157. package/dist/verify-ac/clauses.d.ts +62 -2
  158. package/dist/verify-ac/clauses.js +183 -111
  159. package/dist/verify-ac/index.d.ts +1 -1
  160. package/dist/verify-ac/index.js +1 -1
  161. package/dist/verify-env/node-runtime.d.ts +8 -4
  162. package/dist/verify-env/node-runtime.js +9 -6
  163. package/dist/verify-env/toolchain-check.d.ts +35 -3
  164. package/dist/verify-env/toolchain-check.js +155 -36
  165. package/dist/verify-source/project-definition-mutation-boundary.d.ts +104 -0
  166. package/dist/verify-source/project-definition-mutation-boundary.js +326 -0
  167. package/package.json +7 -3
@@ -1,5 +1,7 @@
1
1
  import { execFileSync, spawnSync } from "node:child_process";
2
- import { resolve } from "node:path";
2
+ import { existsSync, statSync } from "node:fs";
3
+ import { dirname, isAbsolute, resolve } from "node:path";
4
+ import { resolveCaptureFailureStderr, SUBPROCESS_MAX_BUFFER } from "../subprocess/max-buffer.js";
3
5
  function coerceGitBytes(value) {
4
6
  if (Buffer.isBuffer(value))
5
7
  return value;
@@ -21,6 +23,9 @@ export const defaultGitRunner = (projectRoot, args) => {
21
23
  const stdout = execFileSync("git", [...args], {
22
24
  cwd: projectRoot,
23
25
  stdio: ["ignore", "pipe", "pipe"],
26
+ // `git show <tip>:<path>` streams whole blobs; SPECIFICATION.md alone is
27
+ // past Node's 1 MB default (#3903).
28
+ maxBuffer: SUBPROCESS_MAX_BUFFER,
24
29
  });
25
30
  return { code: 0, stdout: gitStdoutString(coerceGitBytes(stdout), args), stderr: "" };
26
31
  }
@@ -32,10 +37,96 @@ export const defaultGitRunner = (projectRoot, args) => {
32
37
  return {
33
38
  code: typeof e.status === "number" ? e.status : 2,
34
39
  stdout: gitStdoutString(coerceGitBytes(e.stdout), args),
35
- stderr: coerceGitBytes(e.stderr).toString("utf8").trimEnd(),
40
+ // A spawn-level failure (ENOBUFS on an oversized blob, timeout kill)
41
+ // throws with no status and empty stderr (#3903).
42
+ stderr: resolveCaptureFailureStderr({
43
+ captured: coerceGitBytes(e.stderr).toString("utf8").trimEnd(),
44
+ status: e.status,
45
+ message: e.message,
46
+ }),
36
47
  };
37
48
  }
38
49
  };
50
+ /**
51
+ * One `git rev-parse` that answers the three ref reads the mutation gates
52
+ * otherwise make separately. Output is one line per requested value, in
53
+ * argument order: full HEAD, worktree root, branch name.
54
+ */
55
+ const DISPATCH_GIT_CONTEXT_ARGS = [
56
+ "rev-parse",
57
+ "HEAD",
58
+ "--show-toplevel",
59
+ "--abbrev-ref",
60
+ "HEAD",
61
+ ];
62
+ /** The individual reads `DISPATCH_GIT_CONTEXT_ARGS` can answer without a spawn. */
63
+ const COALESCED_GIT_ARGS = [
64
+ ["rev-parse", "--verify", "HEAD"],
65
+ ["rev-parse", "--show-toplevel"],
66
+ ["symbolic-ref", "--short", "HEAD"],
67
+ ];
68
+ /**
69
+ * Ref and identity reads whose answer cannot change inside one dispatch.
70
+ * Deliberately excludes content reads (no repeat callers, unbounded memory)
71
+ * and anything that touches the object store or a remote.
72
+ */
73
+ const CACHEABLE_GIT_VERBS = new Set([
74
+ "rev-parse",
75
+ "symbolic-ref",
76
+ "merge-base",
77
+ "rev-list",
78
+ ]);
79
+ function isCoalescedGitArgs(args) {
80
+ return COALESCED_GIT_ARGS.some((candidate) => candidate.length === args.length && candidate.every((arg, i) => arg === args[i]));
81
+ }
82
+ /**
83
+ * Serve a dispatch's repeated ref reads from one `git` child (#3736).
84
+ *
85
+ * The host mutation gate resolved HEAD, the worktree root, and the branch with
86
+ * a separate spawn each, twice over; on a loaded Windows box a spawn measured a
87
+ * 2.2s p50, so every concurrent agent's hook slowed every other agent's hook.
88
+ * The probe is lazy on purpose: it only fires for a read it can actually
89
+ * answer, so a caller that never asks for context never pays for it.
90
+ */
91
+ export function memoizeGitRunner(runGit = defaultGitRunner) {
92
+ const cache = new Map();
93
+ const probed = new Set();
94
+ const keyFor = (root, args) => JSON.stringify([root, args]);
95
+ const probeContext = (projectRoot, root) => {
96
+ probed.add(root);
97
+ const context = runGit(projectRoot, DISPATCH_GIT_CONTEXT_ARGS);
98
+ if (context.code !== 0)
99
+ return;
100
+ const [head, worktree, branch, ...extra] = context.stdout.split(/\r?\n/);
101
+ if (!head || !worktree || !branch || extra.length > 0)
102
+ return;
103
+ const ok = (stdout) => ({ code: 0, stdout, stderr: "" });
104
+ cache.set(keyFor(root, ["rev-parse", "--verify", "HEAD"]), ok(head));
105
+ cache.set(keyFor(root, ["rev-parse", "--show-toplevel"]), ok(worktree));
106
+ // `--abbrev-ref` prints the literal "HEAD" on a detached head, where
107
+ // `symbolic-ref` exits non-zero — which is what callers branch on.
108
+ cache.set(keyFor(root, ["symbolic-ref", "--short", "HEAD"]), branch === "HEAD" ? { code: 1, stdout: "", stderr: "" } : ok(branch));
109
+ };
110
+ return (projectRoot, args) => {
111
+ const root = resolve(projectRoot);
112
+ const key = keyFor(root, args);
113
+ const cached = cache.get(key);
114
+ if (cached !== undefined)
115
+ return cached;
116
+ if (!probed.has(root) && isCoalescedGitArgs(args)) {
117
+ probeContext(projectRoot, root);
118
+ const coalesced = cache.get(key);
119
+ if (coalesced !== undefined)
120
+ return coalesced;
121
+ }
122
+ const result = runGit(projectRoot, args);
123
+ const verb = args[0];
124
+ if (verb !== undefined && CACHEABLE_GIT_VERBS.has(verb)) {
125
+ cache.set(key, result);
126
+ }
127
+ return result;
128
+ };
129
+ }
39
130
  export function gitHead(projectRoot, runGit = defaultGitRunner) {
40
131
  const { code, stdout, stderr } = runGit(projectRoot, ["rev-parse", "--verify", "HEAD"]);
41
132
  if (code !== 0 || !stdout) {
@@ -50,6 +141,51 @@ export function worktreePath(projectRoot, runGit = defaultGitRunner) {
50
141
  }
51
142
  return resolve(projectRoot);
52
143
  }
144
+ /**
145
+ * Walk from `targetPath` to the nearest existing directory. A write that
146
+ * creates a missing nested file is the common case; git cannot use a
147
+ * nonexistent cwd.
148
+ */
149
+ export function existingAncestorDir(targetPath) {
150
+ let current = resolve(targetPath);
151
+ for (;;) {
152
+ if (existsSync(current)) {
153
+ try {
154
+ if (statSync(current).isDirectory())
155
+ return current;
156
+ }
157
+ catch {
158
+ // Fall through to dirname when the path is not stat-able.
159
+ }
160
+ const parent = dirname(current);
161
+ return parent === current ? null : parent;
162
+ }
163
+ const parent = dirname(current);
164
+ if (parent === current)
165
+ return null;
166
+ current = parent;
167
+ }
168
+ }
169
+ /**
170
+ * Failure-expressing sibling of {@link worktreePath}. Returns null instead of
171
+ * falling back to `startDir` so a missing git tree is distinguishable from
172
+ * success (#3794).
173
+ */
174
+ export function worktreePathOrNull(startDir, runGit = defaultGitRunner) {
175
+ const { code, stdout } = runGit(startDir, ["rev-parse", "--show-toplevel"]);
176
+ const trimmed = stdout.trim();
177
+ if (code !== 0 || trimmed.length === 0)
178
+ return null;
179
+ return resolve(trimmed);
180
+ }
181
+ /** Absolute `--git-common-dir` for `projectRoot`, or null on failure. */
182
+ export function gitCommonDir(projectRoot, runGit = defaultGitRunner) {
183
+ const { code, stdout } = runGit(projectRoot, ["rev-parse", "--git-common-dir"]);
184
+ const trimmed = stdout.trim();
185
+ if (code !== 0 || trimmed.length === 0)
186
+ return null;
187
+ return isAbsolute(trimmed) ? resolve(trimmed) : resolve(projectRoot, trimmed);
188
+ }
53
189
  /** True when `ancestor` is reachable from `descendant` (same commit counts). */
54
190
  export function gitIsAncestor(projectRoot, ancestor, descendant, runGit = defaultGitRunner) {
55
191
  if (ancestor === descendant) {
@@ -4,24 +4,121 @@
4
4
  * Ritual-state is "this session completed ceremony." Occupancy is "who may
5
5
  * mutate this tree right now." Those lifetimes differ; do not overload
6
6
  * ritual-state.json. Ordinary end is occupancy:release / session:end (#3604).
7
- * Join negotiation (`occupancy:request`) is out of scope.
7
+ *
8
+ * What this boundary is (#3755): a cooperative bearer-id boundary, not a
9
+ * lineage. The lease admits whoever presents an id the record itself names —
10
+ * the occupant's id, or a child id the occupant granted — so possession of a
11
+ * string is the whole credential. Nothing here observes parentage, so a
12
+ * dispatched child is admitted because a grant records it, never because it
13
+ * inherited the holder's rights. Membership is explicit, attributable and
14
+ * expiring (`grantOccupancyMembership`), and it admits writes only: release,
15
+ * steal, heartbeat and cohort close-out stay owner-only, so a grant cannot be
16
+ * spent on the lease itself. Child-initiated join queuing stays out of scope —
17
+ * the owner issues membership; the child does not request it. Ritual state is
18
+ * still single-owner, so a member writes under the occupant's ceremony: the
19
+ * composite hook write gate measures the tree's verified ritual owner against
20
+ * the occupant that issued the grant, not against the writer.
8
21
  *
9
22
  * Concurrency model:
10
23
  * - Assumptions: local filesystem; cooperating processes on one machine.
11
24
  * - Guarantees: mutual exclusion under crash-free operation; detect-and-abort
12
25
  * if the sidecar lock is compromised (fence before rename/unlink).
13
26
  * - Non-goals: network filesystems; Byzantine processes; perfect off-Linux
14
- * PID-reuse detection (hard age cap + fence instead).
27
+ * PID-reuse detection (hard age cap `OCCUPANCY_MAX_LEASE_MS` — plus fence
28
+ * instead).
29
+ * - Residual: the write gate authorizes a write it does not itself perform, so
30
+ * no verdict is atomic with the write. A takeover that publishes after the
31
+ * allow — including one already holding the lock but not yet written — is
32
+ * outside what this gate can see. Closing that would mean denying every
33
+ * owner whose lease file is momentarily locked, which is the load-shedding
34
+ * regression #3736 fixed. The bound is the TTL, not the gate.
15
35
  */
16
36
  import { type LockDeps } from "../slice/lock.js";
37
+ import { type SwarmWorkerRole } from "../swarm/routing.js";
17
38
  export declare const OCCUPANCY_SCHEMA_VERSION = 1;
18
39
  export declare const OCCUPANCY_RELPATH: readonly [".deft", "occupancy.json"];
19
40
  /** Crash recovery TTL: 20 minutes without heartbeat (15–30 window). */
20
41
  export declare const OCCUPANCY_TTL_MS: number;
42
+ /**
43
+ * Owner-allow re-stamp floor (#3599). The write gate runs on every gated write,
44
+ * so refreshing unconditionally would rewrite the lease file per keystroke-scale
45
+ * event. A quarter of the TTL bounds that without shortening the safe window:
46
+ * a write at any age past this floor resets the clock, so an owner that writes
47
+ * at least once per TTL never expires.
48
+ */
49
+ export declare const OCCUPANCY_REFRESH_AFTER_MS: number;
50
+ /** Owner-allow staleness warning floor: three quarters of the TTL (#3599). */
51
+ export declare const OCCUPANCY_STALE_WARN_MS: number;
52
+ /**
53
+ * Absolute lease age cap, keyed on `claimedAt` and independent of refresh
54
+ * (#3599). Occupancy admits whoever presents the occupant's session id, so
55
+ * "the owner is still writing" only proves that some process holds that
56
+ * string. Without a bound on claim age, refresh would turn the heartbeat TTL —
57
+ * the sole mechanism that reclaims a worktree from a dead session — into
58
+ * something a writer can extend forever.
59
+ *
60
+ * Thirty-six TTLs is twelve hours, sized by the stalled owner rather than the
61
+ * busy one. Refresh keys on writes, so an agent that finishes overnight and
62
+ * waits for its operator is alive, correct, and silent — it stops refreshing
63
+ * while staying entirely legitimate. Twelve hours spans a 23:00 dispatch to a
64
+ * 09:00 handoff and still bounds reclaim well inside a day. Reaching the cap
65
+ * costs the owner one re-claim, not its work.
66
+ *
67
+ * Known limitation: a pure time cap cannot tell a stalled-but-live owner from a
68
+ * dead one, because the only liveness signal on this path is a write. If that
69
+ * ambiguity starts to bite, the answer is a liveness signal that needs no write
70
+ * — an explicit parked state, or refresh on non-write activity — not a larger
71
+ * number here.
72
+ */
73
+ export declare const OCCUPANCY_MAX_LEASE_MS: number;
21
74
  export declare const OCCUPANCY_INTENTS: readonly ["mutation", "swarm", "review"];
22
75
  export type OccupancyIntent = (typeof OCCUPANCY_INTENTS)[number];
23
76
  export declare const OCCUPANCY_JOIN_PROTOCOLS: readonly ["none", "heartbeat-file", "parent-message"];
24
77
  export type OccupancyJoinProtocol = (typeof OCCUPANCY_JOIN_PROTOCOLS)[number];
78
+ /**
79
+ * Default life of a grant (#3755), sized by one dispatched unit of work:
80
+ * implement, open the PR, run the review cycle. Four hours is a third of the
81
+ * absolute lease cap, so a grant that outlives its child still dies well inside
82
+ * the lease that issued it, and re-granting costs the owner one command.
83
+ */
84
+ export declare const OCCUPANCY_GRANT_TTL_MS: number;
85
+ /**
86
+ * Grants a single lease may carry (#3755). Bounded because the list is rewritten
87
+ * into the lease file on every touch and the topology it serves is a nuclear
88
+ * family (#3155), not a mesh — a lease needing more than this is a design
89
+ * problem, not a capacity one.
90
+ */
91
+ export declare const OCCUPANCY_MAX_GRANTS = 32;
92
+ /**
93
+ * A child admitted to the owner's lease (#3755). The five recorded fields are
94
+ * the point: possession of a session string proves nothing about who is behind
95
+ * it, so admission has to name the owner that issued it, the child it admits,
96
+ * the tree it covers, the role it was dispatched for, and when it stops being
97
+ * true. A grant admits writes; it never admits administration.
98
+ *
99
+ * `worktreePath` is recorded rather than assumed. Dispatched children land in
100
+ * their own worktree because the dispatch envelope puts them there, not because
101
+ * anything enforces it, so same-tree dispatch stays reachable and a lease may
102
+ * hold several grants over one path.
103
+ */
104
+ export interface OccupancyGrant {
105
+ readonly ownerSessionId: string;
106
+ readonly childSessionId: string;
107
+ readonly worktreePath: string;
108
+ readonly role: SwarmWorkerRole;
109
+ readonly expiresAt: Date;
110
+ /** Per-actor host of the granted child, mirroring the occupant's own field. */
111
+ readonly host: string;
112
+ /** Per-actor address of the granted child, mirroring the occupant's own field. */
113
+ readonly address: string;
114
+ /** How this child reports back, from the parked join vocabulary. */
115
+ readonly joinProtocol: OccupancyJoinProtocol;
116
+ }
117
+ /**
118
+ * Who the presented id is to this lease (#3755). `member` is the only thing a
119
+ * grant buys, and it buys it for writes alone.
120
+ */
121
+ export type OccupancyAdmission = "owner" | "member" | "stranger";
25
122
  export interface OccupancyRecord {
26
123
  readonly schemaVersion: number;
27
124
  readonly sessionId: string;
@@ -29,13 +126,24 @@ export interface OccupancyRecord {
29
126
  readonly intent: OccupancyIntent;
30
127
  readonly claimedAt: Date;
31
128
  readonly heartbeatAt: Date;
129
+ /**
130
+ * Last gated product write under this lease — by the owner or by a granted
131
+ * member (#3599 / #3755) — or null when none is recorded. Distinct from
132
+ * `heartbeatAt`, which any lease touch advances.
133
+ * Coarse to `OCCUPANCY_REFRESH_AFTER_MS`: a write inside that floor does not
134
+ * re-stamp, so the recorded time can trail the true last write by up to the
135
+ * refresh interval.
136
+ */
137
+ readonly lastWriteAt: Date | null;
32
138
  readonly host: string;
33
139
  readonly address: string;
34
140
  readonly retainCapable: boolean;
35
141
  readonly joinProtocol: OccupancyJoinProtocol;
142
+ /** Children the occupant admitted to this lease for writes (#3755). */
143
+ readonly grants: readonly OccupancyGrant[];
36
144
  readonly raw: Record<string, unknown>;
37
145
  }
38
- export type OccupancyAction = "claimed" | "heartbeat" | "stolen" | "denied" | "released";
146
+ export type OccupancyAction = "claimed" | "heartbeat" | "stolen" | "denied" | "released" | "granted" | "revoked";
39
147
  export interface OccupancyDecision {
40
148
  readonly action: OccupancyAction;
41
149
  readonly sessionId: string;
@@ -57,18 +165,70 @@ export interface ApplyOccupancyInput {
57
165
  readonly address?: string;
58
166
  readonly retainCapable?: boolean;
59
167
  readonly joinProtocol?: OccupancyJoinProtocol;
60
- /** When false, evaluate only (no write). Steal still writes. */
168
+ /** Record this touch as a product write, not only a heartbeat (#3599). */
169
+ readonly markWrite?: boolean;
170
+ /** When false, evaluate only (including confirmed steal) without writing. */
61
171
  readonly write?: boolean;
62
172
  /** Test seam for lock wait / timeout. */
63
173
  readonly lockDeps?: LockDeps;
64
174
  }
65
175
  export declare function occupancyPath(projectRoot: string): string;
66
176
  export declare function heartbeatAgeSeconds(record: OccupancyRecord, now?: Date): number;
67
- export declare function isOccupancyExpired(record: OccupancyRecord, now?: Date, ttlMs?: number): boolean;
177
+ /** Age of the occupant's last recorded product write, or null when none (#3599). */
178
+ export declare function lastWriteAgeSeconds(record: OccupancyRecord, now?: Date): number | null;
179
+ /**
180
+ * Human phrase for how recently the occupant wrote (#3599). Heartbeat age alone
181
+ * cannot distinguish an occupant mid-edit from one that merely claimed and left.
182
+ */
183
+ export declare function formatLastWritePhrase(record: OccupancyRecord, now?: Date): string;
184
+ /** Age of the lease itself, measured from the claim that opened it (#3599). */
185
+ export declare function leaseAgeSeconds(record: OccupancyRecord, now?: Date): number;
186
+ /**
187
+ * Why a lease is or is not live (#3599). The two dead states are not the same
188
+ * operator problem: `heartbeat-stale` says nobody has touched the lease, while
189
+ * `age-capped` says the holder may well be active but has held the tree past
190
+ * the bound that keeps crash recovery possible.
191
+ */
192
+ export type OccupancyLiveness = "live" | "heartbeat-stale" | "age-capped";
193
+ export declare function occupancyLiveness(record: OccupancyRecord, now?: Date, ttlMs?: number, maxLeaseMs?: number): OccupancyLiveness;
194
+ export declare function isOccupancyExpired(record: OccupancyRecord, now?: Date, ttlMs?: number, maxLeaseMs?: number): boolean;
195
+ /** Grants that still admit somebody (#3755). An expired grant admits nobody. */
196
+ export declare function liveOccupancyGrants(record: OccupancyRecord, now?: Date): readonly OccupancyGrant[];
197
+ /**
198
+ * The grant admitting `sessionId`, or null (#3755). Expiry is refused here, on
199
+ * read, rather than trusted to a sweep: nothing guarantees a lease is ever
200
+ * touched again after the grant is written, so a grant that outlived its clock
201
+ * must stop admitting the moment it is read, not the next time it is rewritten.
202
+ */
203
+ export declare function occupancyGrantFor(record: OccupancyRecord, sessionId: string, now?: Date): OccupancyGrant | null;
204
+ /**
205
+ * What the presented id is to this lease (#3755). Deliberately not a liveness
206
+ * question: it answers who, and callers pair it with `occupancyLiveness` to
207
+ * answer whether the lease is still worth anything.
208
+ */
209
+ export declare function occupancyAdmission(record: OccupancyRecord, sessionId: string, now?: Date): OccupancyAdmission;
210
+ /**
211
+ * Warn the holder that its own lease is inside the staleness window (#3599).
212
+ * Without this the owner learns it went stale only when a peer steals the lease.
213
+ */
214
+ export declare function formatOccupancyStaleWarning(record: OccupancyRecord, now?: Date, ttlMs?: number): string;
215
+ /**
216
+ * Tell the holder its lease aged out of the absolute cap (#3599). Distinct
217
+ * remediation from a stale heartbeat: beating harder cannot help, because the
218
+ * lease is gone rather than merely quiet, so the answer is to re-claim.
219
+ */
220
+ export declare function formatOccupancyAgeCapRemediation(record: OccupancyRecord, now?: Date, maxLeaseMs?: number): string;
68
221
  export declare function formatOccupancyRemediation(record: OccupancyRecord, now?: Date): string;
222
+ /**
223
+ * Refuse an administrative verb to a granted child (#3755). Named apart from
224
+ * the stranger refusal because the answer differs: this caller is admitted, and
225
+ * telling it to steal or wait would send it to take the very lease its grant
226
+ * derives from. A grant admits writes; the lease has one owner.
227
+ */
228
+ export declare function formatOccupancyMemberAdministrationRefusal(record: OccupancyRecord, grant: OccupancyGrant, verb: string): string;
69
229
  export declare function resolveOccupancySessionId(input?: ApplyOccupancyInput): string;
70
230
  export declare function readOccupancy(projectRoot: string): OccupancyRecord | null;
71
- export declare function liveOccupant(projectRoot: string, now?: Date, ttlMs?: number): OccupancyRecord | null;
231
+ export declare function liveOccupant(projectRoot: string, now?: Date, ttlMs?: number, maxLeaseMs?: number): OccupancyRecord | null;
72
232
  export declare function applyWorktreeOccupancy(projectRoot: string, input?: ApplyOccupancyInput): OccupancyDecision;
73
233
  export declare function stealOccupancy(projectRoot: string, input?: ApplyOccupancyInput): OccupancyDecision;
74
234
  export declare function releaseOccupancy(projectRoot: string, input?: {
@@ -78,15 +238,81 @@ export declare function releaseOccupancy(projectRoot: string, input?: {
78
238
  readonly swarmCloseout?: boolean;
79
239
  readonly lockDeps?: LockDeps;
80
240
  }): OccupancyDecision;
81
- export declare function evaluateOccupancyWriteGate(projectRoot: string, input?: {
241
+ export interface OccupancyMembershipInput {
242
+ /** The owner issuing or withdrawing the grant; never the child. */
243
+ readonly sessionId?: string;
244
+ readonly childSessionId?: string;
245
+ readonly role?: string;
246
+ readonly worktreePath?: string;
247
+ /** Explicit end of the grant; clamped to the owner's own lease cap. */
248
+ readonly expiresAt?: Date;
249
+ readonly ttlMs?: number;
250
+ readonly host?: string;
251
+ readonly address?: string;
252
+ readonly joinProtocol?: OccupancyJoinProtocol;
253
+ readonly env?: NodeJS.ProcessEnv;
254
+ readonly now?: Date;
255
+ readonly lockDeps?: LockDeps;
256
+ }
257
+ /**
258
+ * Admit a dispatched child to this lease for writes (#3755).
259
+ *
260
+ * Owner-only, and the record is the point: a child that writes here is named
261
+ * on the lease it writes under, so an unexpected edit resolves to a session, a
262
+ * role and a tree instead of to "somebody who had the string". The grant cannot
263
+ * outlive the lease that issued it — expiry is clamped to the absolute lease
264
+ * cap — and it buys writes alone.
265
+ */
266
+ export declare function grantOccupancyMembership(projectRoot: string, input?: OccupancyMembershipInput): OccupancyDecision;
267
+ /**
268
+ * Withdraw a child's grant early (#3755). Expiry already bounds every grant, so
269
+ * this exists for the case expiry cannot serve: the child finished, or should
270
+ * never have been admitted, and the owner wants that true now.
271
+ */
272
+ export declare function revokeOccupancyMembership(projectRoot: string, input?: OccupancyMembershipInput): OccupancyDecision;
273
+ export interface OccupancyWriteGateResult {
274
+ readonly allow: boolean;
275
+ readonly message: string | null;
276
+ readonly occupant: OccupancyRecord | null;
277
+ /** True when this call re-stamped the owner's lease (#3599). */
278
+ readonly refreshed: boolean;
279
+ /** Set when the owner's own lease was inside the staleness window (#3599). */
280
+ readonly warning: string | null;
281
+ /** Why the write was admitted, or null on a refusal (#3755). */
282
+ readonly admitted: Exclude<OccupancyAdmission, "stranger"> | null;
283
+ /** The grant that admitted a member, or null (#3755). */
284
+ readonly grant: OccupancyGrant | null;
285
+ }
286
+ export interface OccupancyWriteGateInput {
82
287
  readonly sessionId?: string;
83
288
  readonly env?: NodeJS.ProcessEnv;
84
289
  readonly now?: Date;
85
- }): {
86
- allow: boolean;
87
- message: string | null;
88
- occupant: OccupancyRecord | null;
89
- };
290
+ /**
291
+ * Re-stamp the owner's lease on the same-session allow (#3599). Off by
292
+ * default so the gate stays a pure read for callers that only probe; the
293
+ * dispatcher turns it on for the evaluation that immediately precedes an
294
+ * allowed write, so the stamp records a write that actually happened.
295
+ */
296
+ readonly refresh?: boolean;
297
+ readonly lockDeps?: LockDeps;
298
+ }
299
+ /**
300
+ * Decide whether the presented session may write, and — on the owner-allow
301
+ * path — keep the owner's lease alive (#3599).
302
+ *
303
+ * Before this, the gate was read-only on owner-allow, so the one event that
304
+ * proves the owner is alive did not extend its lease: the live window was
305
+ * twenty minutes from claim, once, regardless of how long the session worked.
306
+ */
307
+ export declare function evaluateOccupancyWriteGate(projectRoot: string, input?: OccupancyWriteGateInput): OccupancyWriteGateResult;
308
+ /**
309
+ * Refresh the caller's own live lease (#3599). Discoverable counterpart to the
310
+ * automatic write-gate refresh, for sessions whose work is long and quiet:
311
+ * reading, building, or waiting produces no gated write to ride on.
312
+ *
313
+ * Never claims and never mints an owner — an unheld or foreign lease is denied.
314
+ */
315
+ export declare function heartbeatOccupancy(projectRoot: string, input?: ApplyOccupancyInput): OccupancyDecision;
90
316
  /** Close-out identity comes from the launch manifest or DEFT_SESSION_ID — never occupancy.json. */
91
317
  export declare function releaseSwarmOccupancy(projectRoot: string, input?: {
92
318
  readonly sessionId?: string;