@deftai/directive-core 0.108.0 → 0.109.1

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 (157) 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 +8 -1
  27. package/dist/design-critique/completed-arc-record.js +98 -14
  28. package/dist/hooks/classify/host-session-identity.d.ts +25 -12
  29. package/dist/hooks/classify/host-session-identity.js +64 -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 +22 -2
  38. package/dist/hooks/dispatcher.js +338 -66
  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 +3 -0
  44. package/dist/hooks/index.js +3 -0
  45. package/dist/hooks/owner-liveness.d.ts +92 -0
  46. package/dist/hooks/owner-liveness.js +103 -0
  47. package/dist/hooks/shell-write-targets.d.ts +10 -0
  48. package/dist/hooks/shell-write-targets.js +274 -0
  49. package/dist/hooks/tools.d.ts +49 -2
  50. package/dist/hooks/tools.js +96 -1
  51. package/dist/init-deposit/agent-hooks.d.ts +10 -0
  52. package/dist/init-deposit/agent-hooks.js +39 -0
  53. package/dist/init-deposit/gitignore.d.ts +7 -0
  54. package/dist/init-deposit/gitignore.js +24 -0
  55. package/dist/init-deposit/host-tool-coverage.d.ts +53 -0
  56. package/dist/init-deposit/host-tool-coverage.js +150 -0
  57. package/dist/init-deposit/index.d.ts +1 -0
  58. package/dist/init-deposit/index.js +1 -0
  59. package/dist/init-deposit/init-deposit.js +3 -0
  60. package/dist/init-deposit/refresh.js +7 -0
  61. package/dist/init-deposit/runtime-writers.d.ts +14 -0
  62. package/dist/init-deposit/runtime-writers.js +33 -0
  63. package/dist/intake/issue-ingest.d.ts +6 -1
  64. package/dist/intake/issue-ingest.js +21 -2
  65. package/dist/intake/reconcile-issues.js +13 -13
  66. package/dist/lifecycle/brief-envelope.d.ts +25 -0
  67. package/dist/lifecycle/brief-envelope.js +42 -0
  68. package/dist/lifecycle/index.d.ts +1 -0
  69. package/dist/lifecycle/index.js +1 -0
  70. package/dist/literal-acceptance/evaluate.js +14 -5
  71. package/dist/literal-acceptance/index.d.ts +1 -1
  72. package/dist/literal-acceptance/index.js +1 -1
  73. package/dist/literal-acceptance/run.d.ts +2 -0
  74. package/dist/literal-acceptance/run.js +19 -1
  75. package/dist/orchestration/subagent-monitor.d.ts +6 -0
  76. package/dist/orchestration/subagent-monitor.js +23 -1
  77. package/dist/orphan-active/candidate-scope.d.ts +53 -0
  78. package/dist/orphan-active/candidate-scope.js +157 -0
  79. package/dist/orphan-active/evaluate.d.ts +27 -0
  80. package/dist/orphan-active/evaluate.js +63 -6
  81. package/dist/orphan-active/index.d.ts +1 -0
  82. package/dist/orphan-active/index.js +1 -0
  83. package/dist/policy/merge-approval-head.js +7 -6
  84. package/dist/pr-closing-keywords/gh.js +32 -9
  85. package/dist/pr-closing-keywords/main.d.ts +1 -0
  86. package/dist/pr-closing-keywords/main.js +68 -3
  87. package/dist/pr-closing-keywords/types.d.ts +2 -0
  88. package/dist/pr-merge-readiness/gh.js +32 -9
  89. package/dist/pr-protected-issues/gh.js +36 -9
  90. package/dist/pr-wait-mergeable/wrappers.js +3 -3
  91. package/dist/product-first-done-gate/evaluate.js +6 -2
  92. package/dist/product-first-done-gate/types.js +2 -0
  93. package/dist/release/consumer-hard-stops.d.ts +48 -0
  94. package/dist/release/consumer-hard-stops.js +140 -0
  95. package/dist/release/consumer-readiness-disclosure.d.ts +18 -0
  96. package/dist/release/consumer-readiness-disclosure.js +51 -0
  97. package/dist/release/index.d.ts +2 -0
  98. package/dist/release/index.js +2 -0
  99. package/dist/release/pipeline.js +55 -0
  100. package/dist/release/run-consumer-readiness.d.ts +15 -0
  101. package/dist/release/run-consumer-readiness.js +24 -0
  102. package/dist/release/types.d.ts +6 -0
  103. package/dist/review-monitor/github-lease.js +4 -3
  104. package/dist/run-summary/types.d.ts +2 -2
  105. package/dist/scm/build-command.d.ts +2 -2
  106. package/dist/scm/build-command.js +2 -2
  107. package/dist/scm/call-shape.d.ts +25 -0
  108. package/dist/scm/call-shape.js +59 -0
  109. package/dist/scm/call.d.ts +7 -4
  110. package/dist/scm/call.js +41 -11
  111. package/dist/scm/gh-rest.d.ts +25 -12
  112. package/dist/scm/gh-rest.js +65 -15
  113. package/dist/scm/index.d.ts +2 -0
  114. package/dist/scm/index.js +2 -0
  115. package/dist/scm/spawn-status.d.ts +33 -0
  116. package/dist/scm/spawn-status.js +53 -0
  117. package/dist/scope/acceptance-evidence.d.ts +1 -1
  118. package/dist/scope/transition.js +4 -14
  119. package/dist/session/ac-pass-banking.d.ts +2 -2
  120. package/dist/session/ac-pass-banking.js +2 -2
  121. package/dist/session/child-occupancy.d.ts +72 -0
  122. package/dist/session/child-occupancy.js +209 -0
  123. package/dist/session/host-session-owner.d.ts +93 -0
  124. package/dist/session/host-session-owner.js +148 -0
  125. package/dist/session/index.d.ts +2 -0
  126. package/dist/session/index.js +2 -0
  127. package/dist/session/occupancy.d.ts +107 -2
  128. package/dist/session/occupancy.js +272 -34
  129. package/dist/session/verify-ac-session-cache.d.ts +2 -2
  130. package/dist/session/verify-ac-session-cache.js +2 -2
  131. package/dist/swarm/complete-cohort.js +2 -0
  132. package/dist/swarm/pre-dispatch.js +2 -0
  133. package/dist/swarm/subagent-status-dir.d.ts +2 -1
  134. package/dist/swarm/subagent-status-dir.js +11 -2
  135. package/dist/swarm/worktrees.js +2 -0
  136. package/dist/triage/evaluate/worktrees.js +153 -6
  137. package/dist/umbrella-current-shape/index.d.ts +51 -3
  138. package/dist/umbrella-current-shape/index.js +106 -18
  139. package/dist/validate-content/deposit-required.d.ts +39 -0
  140. package/dist/validate-content/deposit-required.js +147 -0
  141. package/dist/validate-content/index.d.ts +1 -0
  142. package/dist/validate-content/index.js +1 -0
  143. package/dist/validate-content/validate-links.d.ts +2 -3
  144. package/dist/validate-content/validate-links.js +29 -2
  145. package/dist/vbrief-activate/activate.d.ts +7 -2
  146. package/dist/vbrief-activate/activate.js +26 -13
  147. package/dist/verify-ac/evaluate.d.ts +9 -0
  148. package/dist/verify-ac/evaluate.js +32 -9
  149. package/dist/verify-env/agent-hooks.d.ts +6 -1
  150. package/dist/verify-env/agent-hooks.js +28 -2
  151. package/dist/verify-source/deposit-closure.d.ts +23 -0
  152. package/dist/verify-source/deposit-closure.js +162 -0
  153. package/dist/verify-source/index.d.ts +2 -0
  154. package/dist/verify-source/index.js +2 -0
  155. package/dist/verify-source/semantic-single-source.d.ts +36 -0
  156. package/dist/verify-source/semantic-single-source.js +349 -0
  157. package/package.json +3 -3
@@ -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
@@ -0,0 +1,147 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ /**
4
+ * C1 declared deposit closure (#3601 / #3899).
5
+ *
6
+ * Required consumer-deposit paths are a closed typed declaration, not a
7
+ * regex over AGENTS.md RFC2119 prose. Existence checks reuse existsSync
8
+ * the same way validate-links.ts does, against a staged pack root (the
9
+ * prepack flatten), not the source checkout.
10
+ */
11
+ export const DEPOSIT_REQUIRED_SCHEMA = "deft.deposit-required-paths.v1";
12
+ export const DEPOSIT_REQUIRED_REL = "contracts/deposit-required-paths.json";
13
+ export const DEPOSIT_PREFIX = ".deft/core/";
14
+ /** Parser-visible field. Not a scan of ! / backtick paths. */
15
+ const DEPOSIT_REQUIRED_COMMENT = /<!--\s*deposit-required:\s+(\S+)\s*-->/g;
16
+ export function extractDepositRequiredComments(source) {
17
+ const out = [];
18
+ const re = new RegExp(DEPOSIT_REQUIRED_COMMENT.source, "g");
19
+ let match = re.exec(source);
20
+ while (match !== null) {
21
+ const path = match[1];
22
+ if (path !== undefined) {
23
+ out.push(path);
24
+ }
25
+ match = re.exec(source);
26
+ }
27
+ return out;
28
+ }
29
+ export function parseDepositRequiredDeclaration(jsonText) {
30
+ let _data;
31
+ let parsed;
32
+ try {
33
+ parsed = JSON.parse(jsonText);
34
+ }
35
+ catch (err) {
36
+ const reason = err instanceof Error ? err.message : String(err);
37
+ throw new Error(`deposit-required: invalid JSON (${reason})`);
38
+ }
39
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
40
+ throw new Error("deposit-required: expected an object");
41
+ }
42
+ const data = parsed;
43
+ if (data.schema !== DEPOSIT_REQUIRED_SCHEMA) {
44
+ throw new Error("deposit-required: expected schema " +
45
+ DEPOSIT_REQUIRED_SCHEMA +
46
+ ", got " +
47
+ String(data.schema));
48
+ }
49
+ if (!Array.isArray(data.paths) || data.paths.length === 0) {
50
+ throw new Error("deposit-required: paths must be a non-empty array of strings");
51
+ }
52
+ const paths = [];
53
+ for (const item of data.paths) {
54
+ if (typeof item !== "string" ||
55
+ !item.startsWith(DEPOSIT_PREFIX) ||
56
+ item.includes("\\") ||
57
+ item.includes("..")) {
58
+ throw new Error(`deposit-required: invalid path ${String(item)}`);
59
+ }
60
+ paths.push(item);
61
+ }
62
+ return { schema: DEPOSIT_REQUIRED_SCHEMA, paths };
63
+ }
64
+ export function packRelativeFromDepositPath(declared) {
65
+ if (!declared.startsWith(DEPOSIT_PREFIX)) {
66
+ throw new Error(`deposit-required: not a deposit path: ${declared}`);
67
+ }
68
+ return declared.slice(DEPOSIT_PREFIX.length);
69
+ }
70
+ /** Prepack mapping used by @deftai/directive-content. */
71
+ export function sourcePathForPackRelative(repoRoot, packRelative) {
72
+ const rel = packRelative.replace(/\\/g, "/");
73
+ if (rel === "main.md" ||
74
+ rel === "SKILL.md" ||
75
+ rel === "Taskfile.yml" ||
76
+ rel.startsWith("tasks/") ||
77
+ rel.startsWith(".githooks/")) {
78
+ return join(repoRoot, ...rel.split("/"));
79
+ }
80
+ return join(repoRoot, "content", ...rel.split("/"));
81
+ }
82
+ export function evaluateDepositClosure(options) {
83
+ const missing = [];
84
+ for (const declared of options.paths) {
85
+ const rel = packRelativeFromDepositPath(declared);
86
+ const target = join(options.packRoot, ...rel.split("/"));
87
+ if (!existsSync(target)) {
88
+ missing.push(declared);
89
+ }
90
+ }
91
+ return { ok: missing.length === 0, missing, checked: options.paths.length };
92
+ }
93
+ export function loadDepositRequiredDeclaration(filePath) {
94
+ return parseDepositRequiredDeclaration(readFileSync(filePath, "utf8"));
95
+ }
96
+ export function resolveDeclarationFile(root) {
97
+ const underContent = join(root, "content", DEPOSIT_REQUIRED_REL);
98
+ if (existsSync(underContent)) {
99
+ return underContent;
100
+ }
101
+ const underRoot = join(root, DEPOSIT_REQUIRED_REL);
102
+ if (existsSync(underRoot)) {
103
+ return underRoot;
104
+ }
105
+ return null;
106
+ }
107
+ export function evaluateInstalledDepositClosure(projectRoot) {
108
+ const deftDir = join(projectRoot, ".deft", "core");
109
+ const declarationPath = resolveDeclarationFile(deftDir) ?? resolveDeclarationFile(projectRoot);
110
+ if (declarationPath === null) {
111
+ return { skipped: true, missing: [], declarationPath: null, error: null };
112
+ }
113
+ if (!existsSync(deftDir)) {
114
+ return { skipped: true, missing: [], declarationPath, error: null };
115
+ }
116
+ try {
117
+ const declaration = loadDepositRequiredDeclaration(declarationPath);
118
+ const result = evaluateDepositClosure({ packRoot: deftDir, paths: declaration.paths });
119
+ return { skipped: false, missing: result.missing, declarationPath, error: null };
120
+ }
121
+ catch (err) {
122
+ const reason = err instanceof Error ? err.message : String(err);
123
+ return { skipped: false, missing: [], declarationPath, error: reason };
124
+ }
125
+ }
126
+ export function renderDeclaredDepositClosureLine(result) {
127
+ if (result.skipped) {
128
+ return "Deposit required-paths: skip -- no C1 declaration in this tree.";
129
+ }
130
+ if (result.error) {
131
+ return ("Deposit required-paths: fail -- could not read C1 declaration (" +
132
+ result.error +
133
+ "). Run directive update to refresh the deposit (#3601 C1).");
134
+ }
135
+ if (result.missing.length === 0) {
136
+ return "Deposit required-paths: ok -- declared C1 paths exist in .deft/core.";
137
+ }
138
+ const sample = result.missing.slice(0, 5).join(", ");
139
+ const extra = result.missing.length > 5 ? ` (+${String(result.missing.length - 5)} more)` : "";
140
+ return ("Deposit required-paths: fail -- " +
141
+ String(result.missing.length) +
142
+ " declared path(s) missing from .deft/core. Examples: " +
143
+ sample +
144
+ extra +
145
+ ". Run directive update to refresh the deposit (#3601 C1).");
146
+ }
147
+ //# sourceMappingURL=deposit-required.js.map
@@ -1,5 +1,6 @@
1
1
  export { resolveCapacityAllocation } from "./capacity-policy.js";
2
2
  export { computeReport, renderReport } from "./capacity-show.js";
3
+ export * as depositRequired from "./deposit-required.js";
3
4
  export { isDatePrefixedVbriefFilename } from "./filename.js";
4
5
  export { extractLinkTargets, shouldSkipLinkTarget } from "./link-parser.js";
5
6
  export * from "./types.js";
@@ -1,5 +1,6 @@
1
1
  export { resolveCapacityAllocation } from "./capacity-policy.js";
2
2
  export { computeReport, renderReport } from "./capacity-show.js";
3
+ export * as depositRequired from "./deposit-required.js";
3
4
  export { isDatePrefixedVbriefFilename } from "./filename.js";
4
5
  export { extractLinkTargets, shouldSkipLinkTarget } from "./link-parser.js";
5
6
  export * from "./types.js";
@@ -18,8 +18,7 @@ export interface ValidateLinksOptions {
18
18
  readonly linkCheckStrict?: boolean;
19
19
  readonly argv?: readonly string[];
20
20
  }
21
- /**
22
- * Validate internal markdown links. Faithful to `scripts/validate-links.py`.
23
- */
21
+ /** Collect broken internal markdown links under cwd. Used by packed-fixture tests (#3937). */
22
+ export declare function collectBrokenLinks(cwd: string): BrokenLink[];
24
23
  export declare function evaluate(options?: ValidateLinksOptions): EvaluateResult;
25
24
  //# sourceMappingURL=validate-links.d.ts.map