@deftai/directive-core 0.108.0 → 0.109.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 (138) hide show
  1. package/dist/cache/operations.js +1 -1
  2. package/dist/check/gate-lists.d.ts +14 -0
  3. package/dist/check/gate-lists.js +26 -3
  4. package/dist/check/session-completed-ac.d.ts +1 -1
  5. package/dist/check/session-completed-ac.js +1 -1
  6. package/dist/consumer-check-contract/evaluate.d.ts +47 -0
  7. package/dist/consumer-check-contract/evaluate.js +181 -15
  8. package/dist/delivery-attempt/evaluate.d.ts +9 -1
  9. package/dist/delivery-attempt/evaluate.js +69 -0
  10. package/dist/delivery-attempt/index.d.ts +1 -1
  11. package/dist/delivery-attempt/index.js +1 -1
  12. package/dist/deposit/live-procedure-exclusions.d.ts +18 -0
  13. package/dist/deposit/live-procedure-exclusions.js +110 -0
  14. package/dist/deposit/live-procedure-targets.d.ts +45 -0
  15. package/dist/deposit/live-procedure-targets.js +274 -0
  16. package/dist/deposit/python-free.d.ts +6 -0
  17. package/dist/deposit/python-free.js +15 -0
  18. package/dist/deposit/rewrite-deposit-links.d.ts +42 -0
  19. package/dist/deposit/rewrite-deposit-links.js +148 -0
  20. package/dist/deposit/run-stage-content-pack.d.ts +2 -0
  21. package/dist/deposit/run-stage-content-pack.js +3 -0
  22. package/dist/deposit/stage-content-pack.d.ts +17 -0
  23. package/dist/deposit/stage-content-pack.js +91 -0
  24. package/dist/design-critique/citation-grammar.d.ts +7 -0
  25. package/dist/design-critique/citation-grammar.js +1 -1
  26. package/dist/design-critique/completed-arc-record.d.ts +1 -1
  27. package/dist/design-critique/completed-arc-record.js +59 -14
  28. package/dist/hooks/classify/host-session-identity.d.ts +24 -11
  29. package/dist/hooks/classify/host-session-identity.js +46 -49
  30. package/dist/hooks/classify/index.d.ts +2 -2
  31. package/dist/hooks/classify/index.js +2 -2
  32. package/dist/hooks/classify/paths.d.ts +2 -0
  33. package/dist/hooks/classify/paths.js +8 -4
  34. package/dist/hooks/classify/stdin.js +69 -3
  35. package/dist/hooks/dest-form.d.ts +20 -1
  36. package/dist/hooks/dest-form.js +158 -21
  37. package/dist/hooks/dispatcher.d.ts +6 -2
  38. package/dist/hooks/dispatcher.js +258 -62
  39. package/dist/hooks/fixtures/cases.d.ts +6 -0
  40. package/dist/hooks/fixtures/cases.js +53 -0
  41. package/dist/hooks/git-destructive-log.d.ts +32 -0
  42. package/dist/hooks/git-destructive-log.js +46 -0
  43. package/dist/hooks/index.d.ts +2 -0
  44. package/dist/hooks/index.js +2 -0
  45. package/dist/hooks/shell-write-targets.d.ts +10 -0
  46. package/dist/hooks/shell-write-targets.js +274 -0
  47. package/dist/hooks/tools.d.ts +20 -2
  48. package/dist/hooks/tools.js +30 -1
  49. package/dist/init-deposit/gitignore.d.ts +7 -0
  50. package/dist/init-deposit/gitignore.js +24 -0
  51. package/dist/init-deposit/init-deposit.js +3 -0
  52. package/dist/init-deposit/refresh.js +7 -0
  53. package/dist/init-deposit/runtime-writers.d.ts +14 -0
  54. package/dist/init-deposit/runtime-writers.js +33 -0
  55. package/dist/intake/issue-ingest.d.ts +6 -1
  56. package/dist/intake/issue-ingest.js +21 -2
  57. package/dist/intake/reconcile-issues.js +13 -13
  58. package/dist/lifecycle/brief-envelope.d.ts +25 -0
  59. package/dist/lifecycle/brief-envelope.js +42 -0
  60. package/dist/lifecycle/index.d.ts +1 -0
  61. package/dist/lifecycle/index.js +1 -0
  62. package/dist/literal-acceptance/evaluate.js +14 -5
  63. package/dist/literal-acceptance/index.d.ts +1 -1
  64. package/dist/literal-acceptance/index.js +1 -1
  65. package/dist/literal-acceptance/run.d.ts +2 -0
  66. package/dist/literal-acceptance/run.js +19 -1
  67. package/dist/orphan-active/candidate-scope.d.ts +53 -0
  68. package/dist/orphan-active/candidate-scope.js +157 -0
  69. package/dist/orphan-active/evaluate.d.ts +27 -0
  70. package/dist/orphan-active/evaluate.js +63 -6
  71. package/dist/orphan-active/index.d.ts +1 -0
  72. package/dist/orphan-active/index.js +1 -0
  73. package/dist/policy/merge-approval-head.js +7 -6
  74. package/dist/pr-closing-keywords/gh.js +32 -9
  75. package/dist/pr-closing-keywords/main.d.ts +1 -0
  76. package/dist/pr-closing-keywords/main.js +68 -3
  77. package/dist/pr-closing-keywords/types.d.ts +2 -0
  78. package/dist/pr-merge-readiness/gh.js +32 -9
  79. package/dist/pr-protected-issues/gh.js +36 -9
  80. package/dist/pr-wait-mergeable/wrappers.js +3 -3
  81. package/dist/product-first-done-gate/evaluate.js +6 -2
  82. package/dist/product-first-done-gate/types.js +2 -0
  83. package/dist/release/consumer-hard-stops.d.ts +48 -0
  84. package/dist/release/consumer-hard-stops.js +140 -0
  85. package/dist/release/consumer-readiness-disclosure.d.ts +18 -0
  86. package/dist/release/consumer-readiness-disclosure.js +51 -0
  87. package/dist/release/index.d.ts +2 -0
  88. package/dist/release/index.js +2 -0
  89. package/dist/release/pipeline.js +55 -0
  90. package/dist/release/run-consumer-readiness.d.ts +15 -0
  91. package/dist/release/run-consumer-readiness.js +24 -0
  92. package/dist/release/types.d.ts +6 -0
  93. package/dist/review-monitor/github-lease.js +4 -3
  94. package/dist/run-summary/types.d.ts +2 -2
  95. package/dist/scm/build-command.d.ts +2 -2
  96. package/dist/scm/build-command.js +2 -2
  97. package/dist/scm/call-shape.d.ts +25 -0
  98. package/dist/scm/call-shape.js +59 -0
  99. package/dist/scm/call.d.ts +7 -4
  100. package/dist/scm/call.js +41 -11
  101. package/dist/scm/gh-rest.d.ts +25 -12
  102. package/dist/scm/gh-rest.js +65 -15
  103. package/dist/scm/index.d.ts +2 -0
  104. package/dist/scm/index.js +2 -0
  105. package/dist/scm/spawn-status.d.ts +33 -0
  106. package/dist/scm/spawn-status.js +53 -0
  107. package/dist/scope/acceptance-evidence.d.ts +1 -1
  108. package/dist/scope/transition.js +4 -14
  109. package/dist/session/ac-pass-banking.d.ts +2 -2
  110. package/dist/session/ac-pass-banking.js +2 -2
  111. package/dist/session/host-session-owner.d.ts +53 -0
  112. package/dist/session/host-session-owner.js +89 -0
  113. package/dist/session/index.d.ts +1 -0
  114. package/dist/session/index.js +1 -0
  115. package/dist/session/occupancy.d.ts +21 -1
  116. package/dist/session/occupancy.js +70 -12
  117. package/dist/session/verify-ac-session-cache.d.ts +2 -2
  118. package/dist/session/verify-ac-session-cache.js +2 -2
  119. package/dist/triage/evaluate/worktrees.js +153 -6
  120. package/dist/umbrella-current-shape/index.d.ts +51 -3
  121. package/dist/umbrella-current-shape/index.js +106 -18
  122. package/dist/validate-content/deposit-required.d.ts +39 -0
  123. package/dist/validate-content/deposit-required.js +147 -0
  124. package/dist/validate-content/index.d.ts +1 -0
  125. package/dist/validate-content/index.js +1 -0
  126. package/dist/validate-content/validate-links.d.ts +2 -3
  127. package/dist/validate-content/validate-links.js +29 -2
  128. package/dist/vbrief-activate/activate.d.ts +7 -2
  129. package/dist/vbrief-activate/activate.js +26 -13
  130. package/dist/verify-ac/evaluate.d.ts +9 -0
  131. package/dist/verify-ac/evaluate.js +32 -9
  132. package/dist/verify-source/deposit-closure.d.ts +23 -0
  133. package/dist/verify-source/deposit-closure.js +162 -0
  134. package/dist/verify-source/index.d.ts +2 -0
  135. package/dist/verify-source/index.js +2 -0
  136. package/dist/verify-source/semantic-single-source.d.ts +36 -0
  137. package/dist/verify-source/semantic-single-source.js +349 -0
  138. package/package.json +3 -3
@@ -40,6 +40,7 @@ import { containedRemove, containedWrite } from "../fs/contained-write.js";
40
40
  import { assertWriteTargetSafe } from "../fs/projection-containment.js";
41
41
  import { assertAppendLockOwned, withAppendLock } from "../slice/lock.js";
42
42
  import { SWARM_WORKER_ROLES } from "../swarm/routing.js";
43
+ import { ambientHostSessionOwner } from "./host-session-owner.js";
43
44
  import { stableJson } from "./json.js";
44
45
  import { parseTimestamp, timestampIso } from "./time.js";
45
46
  export const OCCUPANCY_SCHEMA_VERSION = 1;
@@ -166,6 +167,19 @@ export function occupancyAdmission(record, sessionId, now = new Date()) {
166
167
  return "owner";
167
168
  return occupancyGrantFor(record, presented, now) === null ? "stranger" : "member";
168
169
  }
170
+ // Session ids reach remediation text from operator flags, host environments and
171
+ // whatever a peer wrote into the lease, so a value can carry whitespace or shell
172
+ // metacharacters. Only a value a shell would take as one bare token is inlined
173
+ // into a printed command; anything else keeps its placeholder, because the right
174
+ // quoting differs per shell and a mis-parsed copyable command is worse than one
175
+ // the reader has to fill in. The id itself is still named in the prose above.
176
+ // A leading dash is excluded as well: every CLI parser here reads such a value
177
+ // as another option, so `--occupant --weird-id` fails argument parsing even
178
+ // though the shell itself would have passed the token through intact.
179
+ const SHELL_SAFE_SESSION_ID = /^(?!-)[A-Za-z0-9_.:+=,/-]+$/;
180
+ function commandSessionId(sessionId, placeholder) {
181
+ return SHELL_SAFE_SESSION_ID.test(sessionId) ? sessionId : placeholder;
182
+ }
169
183
  function occupancyClockLine(record) {
170
184
  const lastWrite = record.lastWriteAt === null ? "" : ` last_write_at=${timestampIso(record.lastWriteAt)}`;
171
185
  return `claimed_at=${timestampIso(record.claimedAt)} heartbeat_at=${timestampIso(record.heartbeatAt)}${lastWrite}`;
@@ -178,7 +192,7 @@ export function formatOccupancyStaleWarning(record, now = new Date(), ttlMs = OC
178
192
  const age = heartbeatAgeSeconds(record, now);
179
193
  return (`Occupancy lease for session ${record.sessionId} has not beaten for ${age}s of its ` +
180
194
  `${Math.round(ttlMs / 1000)}s window; another session may read it as abandoned. ` +
181
- `Refresh it with \`deft occupancy:heartbeat --session-id=${record.sessionId}\`.`);
195
+ `Refresh it with \`deft occupancy:heartbeat --session-id=${commandSessionId(record.sessionId, "<your-session-id>")}\`.`);
182
196
  }
183
197
  /**
184
198
  * Tell the holder its lease aged out of the absolute cap (#3599). Distinct
@@ -191,17 +205,45 @@ export function formatOccupancyAgeCapRemediation(record, now = new Date(), maxLe
191
205
  `(claimed ${leaseAgeSeconds(record, now)}s ago, ${occupancyClockLine(record)}), so this ` +
192
206
  "worktree is no longer held and a peer may claim it at any moment. Heartbeats cannot " +
193
207
  "extend a capped lease — re-claim the worktree with " +
194
- `\`deft session:start --session-id=${record.sessionId}\` before writing again.`);
208
+ `\`deft session:start --session-id=${commandSessionId(record.sessionId, "<your-session-id>")}\` before writing again.`);
195
209
  }
196
- export function formatOccupancyRemediation(record, now = new Date()) {
210
+ /**
211
+ * Tell a refused caller who holds the lease and what it can actually run.
212
+ *
213
+ * `presented` is the id the refused caller offered (#3873). Without it the
214
+ * message can only print `<your-session-id>` placeholders, which is fine for a
215
+ * CLI caller that passed its own `--session-id` and useless to a hook process,
216
+ * which does not know what identity it presented. Passing it also keeps the
217
+ * message honest when there is none: a grant cannot name an empty child --
218
+ * `occupancy:grant --child-session-id=` is refused at parse and at membership --
219
+ * so that remediation is not printed to a caller who could never run it.
220
+ */
221
+ export function formatOccupancyRemediation(record, now = new Date(), presented) {
197
222
  const age = heartbeatAgeSeconds(record, now);
198
- return (`Worktree occupied by session ${record.sessionId} (intent=${record.intent}, heartbeat ${age}s ago, ` +
199
- `${formatLastWritePhrase(record, now)}, ${occupancyClockLine(record)}).\n` +
223
+ const header = `Worktree occupied by session ${record.sessionId} (intent=${record.intent}, heartbeat ${age}s ago, ` +
224
+ `${formatLastWritePhrase(record, now)}, ${occupancyClockLine(record)}).\n`;
225
+ const tail = "\nThe occupant may release (`occupancy:release` / `session:end`).";
226
+ if (presented === undefined) {
227
+ return (`${header}Stay read-only (\`session:start --read-only\`), use another worktree,\n` +
228
+ "ask the occupant for a write grant (`occupancy:grant --child-session-id=<your-session-id> " +
229
+ "--role <worker-role>`, run by the occupant), or run a confirmed owner transition " +
230
+ `(\`session:start --steal --confirm --occupant <reported-session-id> --session-id=<your-session-id>\`).${tail}`);
231
+ }
232
+ const actor = presented.trim();
233
+ const occupantArg = commandSessionId(record.sessionId, "<reported-session-id>");
234
+ if (actor.length === 0) {
235
+ return (`${header}This process presented no session identity, so a write grant cannot name it ` +
236
+ "and an owner transition would not be recognised on its next write.\n" +
237
+ "Stay read-only (`session:start --read-only`), use another worktree, or ask the occupant " +
238
+ `to release the lease (\`occupancy:release --session-id=${occupantArg}\` / \`session:end\`).${tail}`);
239
+ }
240
+ const actorArg = commandSessionId(actor, "<your-session-id>");
241
+ return (`${header}This process presented session ${actor}, which neither holds that lease nor has a ` +
242
+ "write grant on it.\n" +
200
243
  "Stay read-only (`session:start --read-only`), use another worktree,\n" +
201
- "ask the occupant for a write grant (`occupancy:grant --child-session-id=<your-session-id> " +
244
+ `ask the occupant for a write grant (\`occupancy:grant --child-session-id=${actorArg} ` +
202
245
  "--role <worker-role>`, run by the occupant), or run a confirmed owner transition " +
203
- "(`session:start --steal --confirm --occupant <reported-session-id> --session-id=<your-session-id>`).\n" +
204
- "The occupant may release (`occupancy:release` / `session:end`).");
246
+ `(\`session:start --steal --confirm --occupant ${occupantArg} --session-id=${actorArg}\`).${tail}`);
205
247
  }
206
248
  /**
207
249
  * Refuse an administrative verb to a granted child (#3755). Named apart from
@@ -215,13 +257,26 @@ export function formatOccupancyMemberAdministrationRefusal(record, grant, verb)
215
257
  "grant never escalates into administration.\n" +
216
258
  `Ask the occupant (session ${record.sessionId}) to run it, or wait for the grant to expire.`);
217
259
  }
260
+ /**
261
+ * The owner a claim is made under: an explicit id, then `DEFT_SESSION_ID`, then
262
+ * the id the running host published, then a mint.
263
+ *
264
+ * The host step is what makes an identified host's claim reachable (#3873).
265
+ * Minting instead binds the lease to an id no later hook process can present,
266
+ * so the session that claimed the worktree is refused by its own lease. The
267
+ * mint stays as the last resort for hosts that publish nothing.
268
+ */
218
269
  export function resolveOccupancySessionId(input = {}) {
219
270
  const explicit = input.sessionId?.trim();
220
271
  if (explicit)
221
272
  return explicit;
222
- const envId = (input.env ?? process.env).DEFT_SESSION_ID?.trim();
273
+ const env = input.env ?? process.env;
274
+ const envId = env.DEFT_SESSION_ID?.trim();
223
275
  if (envId)
224
276
  return envId;
277
+ const hostOwner = ambientHostSessionOwner(env);
278
+ if (hostOwner !== null)
279
+ return hostOwner;
225
280
  return (input.newSessionId ?? randomUUID)();
226
281
  }
227
282
  export function readOccupancy(projectRoot) {
@@ -596,7 +651,7 @@ export function grantOccupancyMembership(projectRoot, input = {}) {
596
651
  message: capped && current !== null
597
652
  ? formatOccupancyAgeCapRemediation(current, now)
598
653
  : "occupancy:grant found no live lease to grant on. A grant is derived authority, " +
599
- `so claim the worktree first with \`deft session:start --session-id=${owner}\`.`,
654
+ `so claim the worktree first with \`deft session:start --session-id=${commandSessionId(owner, "<your-session-id>")}\`.`,
600
655
  code: 1,
601
656
  };
602
657
  }
@@ -809,7 +864,10 @@ export function evaluateOccupancyWriteGate(projectRoot, input = {}) {
809
864
  if (admission === "stranger") {
810
865
  return {
811
866
  allow: false,
812
- message: formatOccupancyRemediation(live, now),
867
+ // The refused caller is told what identity it actually presented (#3873).
868
+ // A hook process cannot otherwise know, and the grant this message offers
869
+ // is only runnable when the occupant can name a non-empty child.
870
+ message: formatOccupancyRemediation(live, now, incoming),
813
871
  occupant: live,
814
872
  refreshed: false,
815
873
  warning: null,
@@ -974,7 +1032,7 @@ export function heartbeatOccupancy(projectRoot, input = {}) {
974
1032
  message: capped && existing !== null
975
1033
  ? formatOccupancyAgeCapRemediation(existing, now)
976
1034
  : "occupancy:heartbeat found no live lease to refresh. Claim one with " +
977
- `\`deft session:start --session-id=${caller}\`.`,
1035
+ `\`deft session:start --session-id=${commandSessionId(caller, "<your-session-id>")}\`.`,
978
1036
  code: 1,
979
1037
  };
980
1038
  }
@@ -4,8 +4,8 @@
4
4
  * Disk-backed so check composition (spawned verify:ac) can serve from_cache
5
5
  * after a green run in the same session when plan + product hash still match.
6
6
  */
7
- export declare const VERIFY_AC_SESSION_CACHE_DIR = ".deft/verify-ac-session-cache";
8
- export declare const AC_SERVED_FROM: readonly ["bank", "cache", "executed"];
7
+ export declare const VERIFY_AC_SESSION_CACHE_DIR = ".deft/cache/verify-ac-session-cache";
8
+ export declare const AC_SERVED_FROM: readonly ["bank", "cache", "executed", "refused"];
9
9
  export type AcServedFrom = (typeof AC_SERVED_FROM)[number];
10
10
  export interface CachedVerifyAcSnapshot {
11
11
  readonly ok: boolean;
@@ -8,8 +8,8 @@ import { existsSync, mkdirSync, readFileSync } from "node:fs";
8
8
  import { join, resolve } from "node:path";
9
9
  import { containedWrite } from "../fs/contained-write.js";
10
10
  import { sanitizeScopeIdForFilename } from "./ac-pass-banking.js";
11
- export const VERIFY_AC_SESSION_CACHE_DIR = ".deft/verify-ac-session-cache";
12
- export const AC_SERVED_FROM = ["bank", "cache", "executed"];
11
+ export const VERIFY_AC_SESSION_CACHE_DIR = ".deft/cache/verify-ac-session-cache";
12
+ export const AC_SERVED_FROM = ["bank", "cache", "executed", "refused"];
13
13
  function asRecord(value) {
14
14
  if (typeof value === "object" && value !== null && !Array.isArray(value)) {
15
15
  return value;
@@ -1,5 +1,7 @@
1
- import { existsSync, mkdirSync, rmSync } from "node:fs";
2
- import { dirname, resolve } from "node:path";
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
3
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { containedRemove, containedWrite } from "../../fs/contained-write.js";
3
5
  import { defaultGitRunner as swarmGitRunner, } from "../../swarm/worktrees.js";
4
6
  import { evaluatorWorktreePath } from "./paths.js";
5
7
  export class EvaluatorWorktreeError extends Error {
@@ -27,17 +29,162 @@ function forceDeleteWorktreeDir(worktreePath) {
27
29
  }
28
30
  rmSync(worktreePath, { recursive: true, force: true });
29
31
  }
32
+ const caseInsensitiveDirCache = new Map();
33
+ function slashResolve(path) {
34
+ return resolve(path).replace(/\\/g, "/");
35
+ }
36
+ function directoryIgnoresCase(dir) {
37
+ let existing = resolve(dir);
38
+ while (!existsSync(existing)) {
39
+ const parent = dirname(existing);
40
+ if (parent === existing) {
41
+ return process.platform === "win32";
42
+ }
43
+ existing = parent;
44
+ }
45
+ const cached = caseInsensitiveDirCache.get(existing);
46
+ if (cached !== undefined) {
47
+ return cached;
48
+ }
49
+ const tag = randomBytes(6).toString("hex");
50
+ const lower = join(existing, `.deft-cs-${tag}a`);
51
+ const upper = join(existing, `.deft-cs-${tag}A`);
52
+ let ignores = false;
53
+ let probed = false;
54
+ try {
55
+ containedWrite({
56
+ root: existing,
57
+ target: lower,
58
+ data: "",
59
+ mode: "create",
60
+ mkdir: false,
61
+ mutation: false,
62
+ });
63
+ ignores = existsSync(upper);
64
+ probed = true;
65
+ }
66
+ catch {
67
+ // Fail closed: do not fold unless the probe proved case-insensitivity.
68
+ }
69
+ finally {
70
+ try {
71
+ containedRemove({ root: existing, target: lower, mutation: false });
72
+ }
73
+ catch {
74
+ // ignore
75
+ }
76
+ try {
77
+ containedRemove({ root: existing, target: upper, mutation: false });
78
+ }
79
+ catch {
80
+ // ignore
81
+ }
82
+ }
83
+ if (probed) {
84
+ caseInsensitiveDirCache.set(existing, ignores);
85
+ }
86
+ return ignores;
87
+ }
88
+ function canonicalizeWorktreePath(path) {
89
+ const resolved = slashResolve(path);
90
+ let existing = "";
91
+ if (existsSync(path)) {
92
+ existing = path;
93
+ }
94
+ else if (existsSync(resolved)) {
95
+ existing = resolved;
96
+ }
97
+ if (existing.length > 0) {
98
+ try {
99
+ return realpathSync.native(existing).replace(/\\/g, "/");
100
+ }
101
+ catch {
102
+ // Path vanished between exists and realpath.
103
+ }
104
+ }
105
+ const parent = dirname(resolved);
106
+ const base = basename(resolved);
107
+ let parentCanon = slashResolve(parent);
108
+ try {
109
+ if (existsSync(parent)) {
110
+ parentCanon = realpathSync.native(parent).replace(/\\/g, "/");
111
+ }
112
+ }
113
+ catch {
114
+ // Keep slash-resolved parent.
115
+ }
116
+ const leaf = directoryIgnoresCase(parent) ? base.toLowerCase() : base;
117
+ return `${parentCanon}/${leaf}`;
118
+ }
119
+ /**
120
+ * Unregister one missing worktree by deleting only its `$GIT_DIR/worktrees/<id>`
121
+ * admin directory. Containment root is that worktrees dir (the git common dir
122
+ * may sit outside a linked-worktree projectRoot). `git worktree prune` has no
123
+ * path argument and operates on every registration, including concurrent
124
+ * agents' reflogs.
125
+ *
126
+ * Path identity uses on-disk realpath when the worktree exists, and otherwise
127
+ * folds case only when the parent directory is case-insensitive. A case-sensitive
128
+ * Windows directory can host two worktrees that differ only by case; those
129
+ * must not share an admin entry.
130
+ */
131
+ function pruneEvaluatorWorktreeAdmin(git, projectRoot, worktreePath) {
132
+ const common = git(["rev-parse", "--git-common-dir"], projectRoot);
133
+ const trimmed = common.stdout.trim();
134
+ if (common.returncode !== 0 || trimmed.length === 0) {
135
+ return;
136
+ }
137
+ const commonDir = isAbsolute(trimmed) ? resolve(trimmed) : resolve(projectRoot, trimmed);
138
+ const worktreesDir = join(commonDir, "worktrees");
139
+ if (!existsSync(worktreesDir)) {
140
+ return;
141
+ }
142
+ const needle = canonicalizeWorktreePath(worktreePath);
143
+ let names = [];
144
+ try {
145
+ names = readdirSync(worktreesDir);
146
+ }
147
+ catch {
148
+ return;
149
+ }
150
+ for (const name of names) {
151
+ if (name === "." || name === ".." || name.includes("/") || name.includes("\\")) {
152
+ continue;
153
+ }
154
+ const gitdirFile = join(worktreesDir, name, "gitdir");
155
+ if (!existsSync(gitdirFile)) {
156
+ continue;
157
+ }
158
+ let recorded = "";
159
+ try {
160
+ recorded = readFileSync(gitdirFile, "utf8").trim();
161
+ }
162
+ catch {
163
+ continue;
164
+ }
165
+ const recordedWorktree = recorded.replace(/\\/g, "/").replace(/\/\.git$/u, "");
166
+ if (canonicalizeWorktreePath(recordedWorktree) === needle) {
167
+ containedRemove({
168
+ root: resolve(worktreesDir),
169
+ target: join(worktreesDir, name),
170
+ recursive: true,
171
+ mutation: false,
172
+ });
173
+ return;
174
+ }
175
+ }
176
+ }
30
177
  function worktreeStillRegistered(git, projectRoot, worktreePath) {
31
178
  const listed = git(["worktree", "list", "--porcelain"], projectRoot);
32
179
  if (listed.returncode !== 0) {
33
180
  return true;
34
181
  }
35
- const needle = resolve(worktreePath).replace(/\\/g, "/").toLowerCase();
182
+ const needle = canonicalizeWorktreePath(worktreePath);
36
183
  for (const line of listed.stdout.split(/\r?\n/u)) {
37
184
  if (!line.startsWith("worktree ")) {
38
185
  continue;
39
186
  }
40
- const listedPath = line.slice("worktree ".length).replace(/\\/g, "/").toLowerCase();
187
+ const listedPath = canonicalizeWorktreePath(line.slice("worktree ".length));
41
188
  if (listedPath === needle) {
42
189
  return true;
43
190
  }
@@ -53,13 +200,13 @@ export function removeEvaluatorWorktree(projectRoot, worktreePath, git = swarmGi
53
200
  }
54
201
  const firstError = proc.stderr.trim() || "<no stderr>";
55
202
  forceDeleteWorktreeDir(worktreePath);
56
- runner(["worktree", "prune"], projectRoot);
203
+ pruneEvaluatorWorktreeAdmin(runner, projectRoot, worktreePath);
57
204
  const retry = runner(["worktree", "remove", "--force", worktreePath], projectRoot);
58
205
  if (retry.returncode === 0) {
59
206
  return;
60
207
  }
61
208
  forceDeleteWorktreeDir(worktreePath);
62
- runner(["worktree", "prune"], projectRoot);
209
+ pruneEvaluatorWorktreeAdmin(runner, projectRoot, worktreePath);
63
210
  if (!worktreeStillRegistered(runner, projectRoot, worktreePath) && !existsSync(worktreePath)) {
64
211
  return;
65
212
  }
@@ -93,6 +93,27 @@ export interface CurrentShapeSidecar {
93
93
  readonly authorAssociation: string;
94
94
  readonly body: string;
95
95
  }
96
+ /** A shape-shaped comment dropped by the #2307 authorship filter (#3934). */
97
+ export interface DiscardedShapeCandidate {
98
+ readonly commentId: number;
99
+ /** Normalized `author_association` -- never comment text. */
100
+ readonly authorAssociation: string;
101
+ }
102
+ /**
103
+ * Why `selectCurrentShapeComment` returned null (#3934). Advisory: it is not
104
+ * consumed by any gate, exit code, or count.
105
+ */
106
+ export interface CurrentShapeNullReason {
107
+ readonly kind: "no-shape-comment" | "non-maintainer-shape";
108
+ readonly discarded: readonly DiscardedShapeCandidate[];
109
+ readonly message: string;
110
+ }
111
+ /** Sidecar payload, or the reason none was selectable (#3934). */
112
+ export interface CurrentShapeSidecarOutcome {
113
+ readonly sidecar: CurrentShapeSidecar | null;
114
+ /** Non-null exactly when `sidecar` is null. */
115
+ readonly reason: CurrentShapeNullReason | null;
116
+ }
96
117
  /** True when labels or sub-issue summary mark the issue as umbrella/tracker-like. */
97
118
  export declare function isUmbrellaLikeIssue(raw: Record<string, unknown>): boolean;
98
119
  /** Normalize a raw issue payload's comment array (REST or pre-mapped). */
@@ -104,13 +125,28 @@ export declare function commentsFromRawPayload(raw: Record<string, unknown>): Is
104
125
  export declare function countMaintainerCurrentShapeComments(comments: readonly IssueComment[]): number;
105
126
  /** Markdown section appended to cache content.md when a canonical shape exists. */
106
127
  export declare function formatCurrentShapeSection(selected: CurrentShapeComment): string;
128
+ /** Advisory cache note for a selected-null thread that had discarded candidates (#3934). */
129
+ export declare function formatCurrentShapeNotSelectedSection(reason: CurrentShapeNullReason): string;
107
130
  /**
108
131
  * Append the canonical current-shape comment (if any) to a rendered cache body.
109
- * Returns the input unchanged when no maintainer-authored current-shape exists.
132
+ *
133
+ * When nothing is selectable but the thread carried shape-shaped comments that
134
+ * the #2307 authorship filter dropped, append the advisory not-selected note
135
+ * instead of returning the body unchanged (#3934): an agent reading content.md
136
+ * without invoking `umbrella:current-shape` would otherwise see only the stale
137
+ * body the #1152 rule tells it to distrust. A thread with no shape comment at
138
+ * all is still returned unchanged, so ordinary issues gain no note.
110
139
  */
111
140
  export declare function appendCurrentShapeSection(baseContent: string, raw: Record<string, unknown>): string;
112
- /** Build the current-shape.json sidecar payload, or null when none is selectable. */
113
- export declare function buildCurrentShapeSidecar(raw: Record<string, unknown>): CurrentShapeSidecar | null;
141
+ /**
142
+ * Build the current-shape.json sidecar payload.
143
+ *
144
+ * Reports why nothing was selectable rather than returning a bare null (#3934),
145
+ * so a caller can tell "no shape comment on this thread" from "a shape comment
146
+ * exists but its author is outside MAINTAINER_ASSOCIATIONS". Advisory only --
147
+ * no sidecar is written on either null kind, exactly as before.
148
+ */
149
+ export declare function buildCurrentShapeSidecar(raw: Record<string, unknown>): CurrentShapeSidecarOutcome;
114
150
  /** Merge `gh api --paginate` concatenated JSON array pages into comment rows. */
115
151
  export declare function parseCommentsFromGhStdout(stdout: string): IssueComment[];
116
152
  export declare function extractPassFromBody(body: string): number | null;
@@ -128,6 +164,18 @@ export declare function detectSections(body: string): SectionPresence;
128
164
  export declare function sectionsRecord(presence: SectionPresence): Record<string, boolean>;
129
165
  export declare const NO_CURRENT_SHAPE_MESSAGE: string;
130
166
  export declare const NON_MAINTAINER_CURRENT_SHAPE_MESSAGE: string;
167
+ /** Bound the diagnostic so one forged thread cannot flood a cache note (#3934). */
168
+ export declare const MAX_REPORTED_DISCARDED_CANDIDATES = 5;
169
+ /**
170
+ * Classify a null return from `selectCurrentShapeComment` (#3934).
171
+ *
172
+ * Advisory only: it changes no selection, no maintainer count, and no exit code.
173
+ * Discarded candidates are named by comment id and normalized author
174
+ * association; a comment body is never reproduced, because forwarding untrusted
175
+ * text into a cache or xBRIEF narrative is the injection this filter exists to
176
+ * refuse (#2307).
177
+ */
178
+ export declare function describeCurrentShapeNull(comments: readonly IssueComment[]): CurrentShapeNullReason;
131
179
  export declare function fetchCurrentShape(options: {
132
180
  repo: string;
133
181
  issueNumber: number;
@@ -1,6 +1,6 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { scan } from "../cache/scanner.js";
3
- import { resolveBinary } from "../scm/binary.js";
3
+ import { resolveBinaryForArgv } from "../scm/call-shape.js";
4
4
  import { SUBPROCESS_MAX_BUFFER } from "../subprocess/max-buffer.js";
5
5
  import { resolveRepo } from "../triage/queue/repo.js";
6
6
  /**
@@ -192,30 +192,64 @@ export function formatCurrentShapeSection(selected) {
192
192
  "",
193
193
  ].join("\n");
194
194
  }
195
+ /** Advisory cache note for a selected-null thread that had discarded candidates (#3934). */
196
+ export function formatCurrentShapeNotSelectedSection(reason) {
197
+ return [
198
+ "",
199
+ "---",
200
+ "",
201
+ "## Canonical current shape: not selected (#1152 / #2307)",
202
+ "",
203
+ `_${reason.message} Deterministic read path: \`task umbrella:current-shape <N>\`._`,
204
+ "",
205
+ ].join("\n");
206
+ }
195
207
  /**
196
208
  * Append the canonical current-shape comment (if any) to a rendered cache body.
197
- * Returns the input unchanged when no maintainer-authored current-shape exists.
209
+ *
210
+ * When nothing is selectable but the thread carried shape-shaped comments that
211
+ * the #2307 authorship filter dropped, append the advisory not-selected note
212
+ * instead of returning the body unchanged (#3934): an agent reading content.md
213
+ * without invoking `umbrella:current-shape` would otherwise see only the stale
214
+ * body the #1152 rule tells it to distrust. A thread with no shape comment at
215
+ * all is still returned unchanged, so ordinary issues gain no note.
198
216
  */
199
217
  export function appendCurrentShapeSection(baseContent, raw) {
200
- const selected = selectCurrentShapeComment(commentsFromRawPayload(raw));
218
+ const comments = commentsFromRawPayload(raw);
219
+ const selected = selectCurrentShapeComment(comments);
201
220
  if (selected === null) {
202
- return baseContent;
221
+ const reason = describeCurrentShapeNull(comments);
222
+ if (reason.kind !== "non-maintainer-shape") {
223
+ return baseContent;
224
+ }
225
+ return `${baseContent.trimEnd()}\n${formatCurrentShapeNotSelectedSection(reason)}`;
203
226
  }
204
227
  return `${baseContent.trimEnd()}\n${formatCurrentShapeSection(selected)}`;
205
228
  }
206
- /** Build the current-shape.json sidecar payload, or null when none is selectable. */
229
+ /**
230
+ * Build the current-shape.json sidecar payload.
231
+ *
232
+ * Reports why nothing was selectable rather than returning a bare null (#3934),
233
+ * so a caller can tell "no shape comment on this thread" from "a shape comment
234
+ * exists but its author is outside MAINTAINER_ASSOCIATIONS". Advisory only --
235
+ * no sidecar is written on either null kind, exactly as before.
236
+ */
207
237
  export function buildCurrentShapeSidecar(raw) {
208
- const selected = selectCurrentShapeComment(commentsFromRawPayload(raw));
238
+ const comments = commentsFromRawPayload(raw);
239
+ const selected = selectCurrentShapeComment(comments);
209
240
  if (selected === null) {
210
- return null;
241
+ return { sidecar: null, reason: describeCurrentShapeNull(comments) };
211
242
  }
212
243
  return {
213
- commentId: selected.id,
214
- htmlUrl: selected.htmlUrl,
215
- pass: selected.pass,
216
- authorLogin: selected.authorLogin,
217
- authorAssociation: selected.authorAssociation,
218
- body: selected.body,
244
+ sidecar: {
245
+ commentId: selected.id,
246
+ htmlUrl: selected.htmlUrl,
247
+ pass: selected.pass,
248
+ authorLogin: selected.authorLogin,
249
+ authorAssociation: selected.authorAssociation,
250
+ body: selected.body,
251
+ },
252
+ reason: null,
219
253
  };
220
254
  }
221
255
  /** Merge `gh api --paginate` concatenated JSON array pages into comment rows. */
@@ -278,9 +312,10 @@ export function parseCommentsFromGhStdout(stdout) {
278
312
  return comments;
279
313
  }
280
314
  function defaultFetchComments(repo, issueNumber) {
281
- const binary = resolveBinary();
282
315
  const path = `repos/${repo}/issues/${issueNumber}/comments?per_page=100`;
283
- const proc = spawnSync(binary, ["api", "--paginate", path], {
316
+ const apiArgs = ["--paginate", path];
317
+ const binary = resolveBinaryForArgv("api", apiArgs);
318
+ const proc = spawnSync(binary, ["api", ...apiArgs], {
284
319
  encoding: "utf8",
285
320
  maxBuffer: SUBPROCESS_MAX_BUFFER,
286
321
  });
@@ -385,6 +420,58 @@ export const NON_MAINTAINER_CURRENT_SHAPE_MESSAGE = "A ## Current shape (as of p
385
420
  "non-maintainer (author_association not in OWNER/MEMBER/COLLABORATOR) and is " +
386
421
  "ignored per AGENTS.md ## Umbrella current-shape convention (#1152 / #2307). " +
387
422
  "A maintainer must (re-)post the current-shape comment for it to be authoritative.";
423
+ /** Bound the diagnostic so one forged thread cannot flood a cache note (#3934). */
424
+ export const MAX_REPORTED_DISCARDED_CANDIDATES = 5;
425
+ const SAFE_ASSOCIATION_RE = /^[A-Z_]{1,32}$/;
426
+ /**
427
+ * GitHub sets `author_association` from a fixed enum, but a replayed or
428
+ * hand-built payload can carry anything. Normalizing keeps the diagnostic from
429
+ * smuggling arbitrary payload text into an agent-facing surface (#3934).
430
+ */
431
+ function normalizeAssociation(raw) {
432
+ const upper = raw.trim().toUpperCase();
433
+ return SAFE_ASSOCIATION_RE.test(upper) ? upper : "UNKNOWN";
434
+ }
435
+ function formatDiscardedCandidates(discarded) {
436
+ const shown = discarded
437
+ .slice(0, MAX_REPORTED_DISCARDED_CANDIDATES)
438
+ .map((candidate) => `comment ${candidate.commentId} (${candidate.authorAssociation})`);
439
+ const hidden = discarded.length - shown.length;
440
+ return hidden > 0 ? `${shown.join(", ")}, and ${hidden} more` : shown.join(", ");
441
+ }
442
+ /**
443
+ * Classify a null return from `selectCurrentShapeComment` (#3934).
444
+ *
445
+ * Advisory only: it changes no selection, no maintainer count, and no exit code.
446
+ * Discarded candidates are named by comment id and normalized author
447
+ * association; a comment body is never reproduced, because forwarding untrusted
448
+ * text into a cache or xBRIEF narrative is the injection this filter exists to
449
+ * refuse (#2307).
450
+ */
451
+ export function describeCurrentShapeNull(comments) {
452
+ const discarded = [];
453
+ for (const comment of comments) {
454
+ if (isMaintainerAuthored(comment.authorAssociation)) {
455
+ continue;
456
+ }
457
+ if (extractPassFromBody(comment.body) === null) {
458
+ continue;
459
+ }
460
+ discarded.push({
461
+ commentId: comment.id,
462
+ authorAssociation: normalizeAssociation(comment.authorAssociation),
463
+ });
464
+ }
465
+ if (discarded.length === 0) {
466
+ return { kind: "no-shape-comment", discarded: [], message: NO_CURRENT_SHAPE_MESSAGE };
467
+ }
468
+ return {
469
+ kind: "non-maintainer-shape",
470
+ discarded,
471
+ message: `${NON_MAINTAINER_CURRENT_SHAPE_MESSAGE} ` +
472
+ `Discarded candidate(s): ${formatDiscardedCandidates(discarded)}.`,
473
+ };
474
+ }
388
475
  export function fetchCurrentShape(options) {
389
476
  const fetcher = options.fetchComments ?? defaultFetchComments;
390
477
  const fetched = fetcher(options.repo, options.issueNumber);
@@ -393,11 +480,12 @@ export function fetchCurrentShape(options) {
393
480
  }
394
481
  const selected = selectCurrentShapeComment(fetched);
395
482
  if (selected === null) {
396
- // Distinguish provenance-filtered absence from genuine absence (#2307).
397
- const hadNonMaintainerShape = fetched.some((c) => extractPassFromBody(c.body) !== null && !isMaintainerAuthored(c.authorAssociation));
483
+ // Distinguish provenance-filtered absence from genuine absence (#2307), via
484
+ // the same classifier the cache-side callers use (#3934). The CLI keeps its
485
+ // two existing messages verbatim -- discarded ids stay off this surface.
398
486
  return {
399
487
  ok: false,
400
- error: hadNonMaintainerShape
488
+ error: describeCurrentShapeNull(fetched).kind === "non-maintainer-shape"
401
489
  ? NON_MAINTAINER_CURRENT_SHAPE_MESSAGE
402
490
  : NO_CURRENT_SHAPE_MESSAGE,
403
491
  kind: "not-found",
@@ -0,0 +1,39 @@
1
+ /**
2
+ * C1 declared deposit closure (#3601 / #3899).
3
+ *
4
+ * Required consumer-deposit paths are a closed typed declaration, not a
5
+ * regex over AGENTS.md RFC2119 prose. Existence checks reuse existsSync
6
+ * the same way validate-links.ts does, against a staged pack root (the
7
+ * prepack flatten), not the source checkout.
8
+ */
9
+ export declare const DEPOSIT_REQUIRED_SCHEMA: "deft.deposit-required-paths.v1";
10
+ export declare const DEPOSIT_REQUIRED_REL = "contracts/deposit-required-paths.json";
11
+ export declare const DEPOSIT_PREFIX = ".deft/core/";
12
+ export interface DepositRequiredDeclaration {
13
+ readonly schema: typeof DEPOSIT_REQUIRED_SCHEMA;
14
+ readonly paths: readonly string[];
15
+ }
16
+ export interface DepositClosureResult {
17
+ readonly ok: boolean;
18
+ readonly missing: readonly string[];
19
+ readonly checked: number;
20
+ }
21
+ export declare function extractDepositRequiredComments(source: string): string[];
22
+ export declare function parseDepositRequiredDeclaration(jsonText: string): DepositRequiredDeclaration;
23
+ export declare function packRelativeFromDepositPath(declared: string): string;
24
+ /** Prepack mapping used by @deftai/directive-content. */
25
+ export declare function sourcePathForPackRelative(repoRoot: string, packRelative: string): string;
26
+ export declare function evaluateDepositClosure(options: {
27
+ readonly packRoot: string;
28
+ readonly paths: readonly string[];
29
+ }): DepositClosureResult;
30
+ export declare function loadDepositRequiredDeclaration(filePath: string): DepositRequiredDeclaration;
31
+ export declare function resolveDeclarationFile(root: string): string | null;
32
+ export declare function evaluateInstalledDepositClosure(projectRoot: string): {
33
+ readonly skipped: boolean;
34
+ readonly missing: readonly string[];
35
+ readonly declarationPath: string | null;
36
+ readonly error: string | null;
37
+ };
38
+ export declare function renderDeclaredDepositClosureLine(result: ReturnType<typeof evaluateInstalledDepositClosure>): string;
39
+ //# sourceMappingURL=deposit-required.d.ts.map