@deftai/directive-core 0.86.0 → 0.88.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 (70) hide show
  1. package/dist/cache/scanner.d.ts +11 -1
  2. package/dist/cache/scanner.js +29 -4
  3. package/dist/check/gate-lists.js +2 -0
  4. package/dist/content-contracts/skills/helpers.d.ts +10 -0
  5. package/dist/content-contracts/skills/helpers.js +35 -0
  6. package/dist/deposit/copy-tree.d.ts +19 -1
  7. package/dist/deposit/copy-tree.js +134 -5
  8. package/dist/doctor/main.d.ts +6 -5
  9. package/dist/doctor/main.js +80 -18
  10. package/dist/doctor/taskfile.d.ts +8 -0
  11. package/dist/doctor/taskfile.js +19 -0
  12. package/dist/fs/projection-containment.d.ts +18 -0
  13. package/dist/fs/projection-containment.js +40 -0
  14. package/dist/hooks/dispatcher.d.ts +34 -2
  15. package/dist/hooks/dispatcher.js +234 -21
  16. package/dist/hooks/tools.d.ts +31 -0
  17. package/dist/hooks/tools.js +74 -0
  18. package/dist/init-deposit/agent-hooks.d.ts +1 -1
  19. package/dist/init-deposit/agent-hooks.js +38 -2
  20. package/dist/init-deposit/hygiene.d.ts +16 -0
  21. package/dist/init-deposit/hygiene.js +26 -0
  22. package/dist/init-deposit/init-dispatch.js +28 -0
  23. package/dist/init-deposit/prettierignore.js +2 -2
  24. package/dist/init-deposit/refresh.js +38 -7
  25. package/dist/init-deposit/scaffold.js +7 -3
  26. package/dist/init-deposit/xbrief-projections.js +6 -6
  27. package/dist/intake/issue-emit.d.ts +45 -2
  28. package/dist/intake/issue-emit.js +420 -17
  29. package/dist/intake/issue-ingest.js +65 -6
  30. package/dist/packs/pack-render.d.ts +33 -0
  31. package/dist/packs/pack-render.js +155 -9
  32. package/dist/packs/quarantine-ext.d.ts +10 -0
  33. package/dist/packs/quarantine-ext.js +26 -2
  34. package/dist/platform/platform-capabilities.js +3 -0
  35. package/dist/policy/index.d.ts +1 -0
  36. package/dist/policy/index.js +1 -0
  37. package/dist/policy/no-deft-directive.d.ts +59 -0
  38. package/dist/policy/no-deft-directive.js +103 -0
  39. package/dist/policy/org-force-on-migration.d.ts +52 -0
  40. package/dist/policy/org-force-on-migration.js +260 -22
  41. package/dist/policy/runtime-authority.d.ts +41 -0
  42. package/dist/policy/runtime-authority.js +274 -0
  43. package/dist/review-monitor/constants.js +3 -2
  44. package/dist/review-monitor/tier-detection.d.ts +6 -2
  45. package/dist/review-monitor/tier-detection.js +27 -2
  46. package/dist/scope/transition.js +43 -0
  47. package/dist/session/release-availability.d.ts +2 -0
  48. package/dist/session/release-availability.js +23 -8
  49. package/dist/session/session-start-hook.d.ts +3 -0
  50. package/dist/session/session-start-hook.js +15 -0
  51. package/dist/session/session-start.js +30 -0
  52. package/dist/swarm/routing-set-cli.js +5 -10
  53. package/dist/swarm/routing.d.ts +3 -2
  54. package/dist/swarm/routing.js +16 -4
  55. package/dist/triage/help/registry-data.d.ts +7 -7
  56. package/dist/triage/help/registry-data.js +15 -6
  57. package/dist/triage/queue/index.d.ts +1 -0
  58. package/dist/triage/queue/index.js +1 -0
  59. package/dist/triage/queue/show.d.ts +69 -0
  60. package/dist/triage/queue/show.js +293 -0
  61. package/dist/triage/scope/cli.js +3 -0
  62. package/dist/triage/scope/coverage.d.ts +2 -0
  63. package/dist/triage/scope/coverage.js +18 -3
  64. package/dist/verify-source/cursor-tier1.js +7 -2
  65. package/dist/verify-source/index.d.ts +1 -0
  66. package/dist/verify-source/index.js +1 -0
  67. package/dist/verify-source/openclaw-tier1.d.ts +37 -0
  68. package/dist/verify-source/openclaw-tier1.js +105 -0
  69. package/dist/xbrief-migrate/migrate-project.js +9 -5
  70. package/package.json +4 -3
@@ -2,7 +2,7 @@
2
2
  * Quarantine scanner v2 port (mirrors `scripts/cache_scanner.py`).
3
3
  * SCANNER_VERSION must stay in lockstep with the Python module.
4
4
  */
5
- export declare const SCANNER_VERSION = "2.1.0";
5
+ export declare const SCANNER_VERSION = "2.2.0";
6
6
  export interface ScanFlag {
7
7
  category: string;
8
8
  severity: string;
@@ -21,6 +21,16 @@ export interface ScanResult {
21
21
  * Scans the line once; byte-identical match semantics to the prior regex.
22
22
  */
23
23
  export declare function lineHasShellVector(line: string): boolean;
24
+ /**
25
+ * Neutralize a body line so it cannot act as a CommonMark fence open/close.
26
+ * Used on content placed inside a ```quarantined wrapper (#2915 / cache-quarantine-03).
27
+ *
28
+ * A nested bare ``` (or longer run, optional ≤3-space indent) would otherwise
29
+ * early-close the outer fence and let following attacker text render outside
30
+ * the quarantined info-string. Break the delimiter run by inserting a backslash
31
+ * after the first fence character: ``` → `\`` , ~~~ → ~\~~ .
32
+ */
33
+ export declare function neutralizeFenceLine(line: string): string;
24
34
  /** Run scanner v2 over content markdown. */
25
35
  export declare function scan(contentMd: string, scannedAt?: string): ScanResult;
26
36
  /** Map scan flags for meta.json (omit match_count when zero). */
@@ -3,9 +3,12 @@ import { parseMarkdownHeading } from "../text/redos-safe.js";
3
3
  * Quarantine scanner v2 port (mirrors `scripts/cache_scanner.py`).
4
4
  * SCANNER_VERSION must stay in lockstep with the Python module.
5
5
  */
6
- export const SCANNER_VERSION = "2.1.0";
6
+ export const SCANNER_VERSION = "2.2.0";
7
7
  const CREDENTIAL_PATTERNS = [
8
8
  ["github-pat", /\bgh[pousr]_[A-Za-z0-9]{30,}\b/],
9
+ // #2910: fine-grained PATs (`github_pat_...`) — aligned with product-signal
10
+ // SECRET_PATTERNS so cache/ingest fail-closes on modern GitHub tokens.
11
+ ["github-fine-grained-pat", /\bgithub_pat_[A-Za-z0-9_]{20,}\b/],
9
12
  ["anthropic-api-key", /\bsk-ant-[A-Za-z0-9_-]{20,}\b/],
10
13
  ["openai-api-key", /\bsk-[A-Za-z0-9]{20,}\b/],
11
14
  ["slack-token", /\bxox[bp]-[A-Za-z0-9-]{20,}\b/],
@@ -151,6 +154,26 @@ export function lineHasShellVector(line) {
151
154
  const FENCE_RE = /^(```|~~~)/;
152
155
  const QUARANTINE_FENCE_OPEN = "```quarantined";
153
156
  const QUARANTINE_FENCE_CLOSE = "```";
157
+ /**
158
+ * Neutralize a body line so it cannot act as a CommonMark fence open/close.
159
+ * Used on content placed inside a ```quarantined wrapper (#2915 / cache-quarantine-03).
160
+ *
161
+ * A nested bare ``` (or longer run, optional ≤3-space indent) would otherwise
162
+ * early-close the outer fence and let following attacker text render outside
163
+ * the quarantined info-string. Break the delimiter run by inserting a backslash
164
+ * after the first fence character: ``` → `\`` , ~~~ → ~\~~ .
165
+ */
166
+ export function neutralizeFenceLine(line) {
167
+ const match = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(line);
168
+ if (match === null)
169
+ return line;
170
+ const indent = match[1] ?? "";
171
+ const delim = match[2] ?? "";
172
+ const rest = match[3] ?? "";
173
+ // One backslash after the first fence char breaks the delimiter run.
174
+ // Concat (not a template literal) so "\\" is unambiguously a single "\".
175
+ return indent + delim[0] + "\\" + delim.slice(1) + rest;
176
+ }
154
177
  function isInvisible(ch) {
155
178
  const cp = ch.codePointAt(0);
156
179
  if (cp === undefined)
@@ -277,7 +300,8 @@ function detectInjectionHeading(text) {
277
300
  if (hSignal || bSignal) {
278
301
  out.push(QUARANTINE_FENCE_OPEN);
279
302
  for (let j = i; j < sectionEnd; j += 1) {
280
- out.push(lines[j] ?? "");
303
+ // #2915: neutralize nested fence lines so they cannot early-close.
304
+ out.push(neutralizeFenceLine(lines[j] ?? ""));
281
305
  }
282
306
  out.push(QUARANTINE_FENCE_CLOSE);
283
307
  sectionsWrapped += 1;
@@ -292,7 +316,8 @@ function detectInjectionHeading(text) {
292
316
  }
293
317
  if (headingSignal(line) || lineHasShellVector(line)) {
294
318
  out.push(QUARANTINE_FENCE_OPEN);
295
- out.push(line);
319
+ // #2915: neutralize in case the single line itself is fence-shaped.
320
+ out.push(neutralizeFenceLine(line));
296
321
  out.push(QUARANTINE_FENCE_CLOSE);
297
322
  sectionsWrapped += 1;
298
323
  i += 1;
@@ -310,7 +335,7 @@ function detectInjectionHeading(text) {
310
335
  {
311
336
  category: "injection-heading",
312
337
  severity: "fence-and-pass",
313
- detail: `wrapped ${sectionsWrapped} injection-shaped section(s) in \`quarantined\` fence (v2.1.0 strict-signal policy)`,
338
+ detail: `wrapped ${sectionsWrapped} injection-shaped section(s) in \`quarantined\` fence (v2.2.0 strict-signal policy)`,
314
339
  match_count: sectionsWrapped,
315
340
  },
316
341
  ];
@@ -19,9 +19,11 @@ export const FRAMEWORK_CHECK_GATES = [
19
19
  "verify:rule-ownership",
20
20
  "verify:biome-config",
21
21
  "verify:content-manifest",
22
+ "verify:license-sync",
22
23
  "verify:skill-external-fetch-gate",
23
24
  "verify:contract-drift",
24
25
  "verify:cursor-tier1",
26
+ "verify:openclaw-tier1",
25
27
  "verify:go-freeze",
26
28
  "verify:bridge-drift",
27
29
  "verify:branch",
@@ -13,6 +13,16 @@ export declare function resolveRepoPath(relPath: string): string;
13
13
  export declare function readRepoFile(relPath: string): string;
14
14
  export declare function repoFileExists(relPath: string): boolean;
15
15
  export declare function readSkill(relPath: string): string;
16
+ /**
17
+ * Progressive-disclosure surface for deft-directive-swarm (#2928).
18
+ * Thin SKILL.md is the dispatch card; operative depth lives under references/.
19
+ * Content contracts assert against this ordered join so host adapters can leave
20
+ * the always-loaded SKILL without dropping coverage.
21
+ */
22
+ export declare const SWARM_SKILL_REL = "skills/deft-directive-swarm/SKILL.md";
23
+ /** Stable load order: core phases, then host launch adapters, then ops. */
24
+ export declare const SWARM_REFERENCE_ORDER: readonly ["core-phase-0.md", "core-phase-1-2.md", "core-phase-3.md", "host-warp.md", "host-generic.md", "host-grok-build.md", "host-cursor.md", "host-openclaw.md", "core-phase-4.md", "core-phase-5-6.md", "core-ops.md"];
25
+ export declare function readSwarmSkillSurface(): string;
16
26
  export declare function readAgentsMd(): string;
17
27
  /** Slice the first `## Returning Sessions` section body out of AGENTS.md. */
18
28
  export declare function returningSessionsSection(): string;
@@ -32,6 +32,41 @@ export function repoFileExists(relPath) {
32
32
  export function readSkill(relPath) {
33
33
  return readRepoFile(relPath);
34
34
  }
35
+ /**
36
+ * Progressive-disclosure surface for deft-directive-swarm (#2928).
37
+ * Thin SKILL.md is the dispatch card; operative depth lives under references/.
38
+ * Content contracts assert against this ordered join so host adapters can leave
39
+ * the always-loaded SKILL without dropping coverage.
40
+ */
41
+ export const SWARM_SKILL_REL = "skills/deft-directive-swarm/SKILL.md";
42
+ /** Stable load order: core phases, then host launch adapters, then ops. */
43
+ export const SWARM_REFERENCE_ORDER = [
44
+ "core-phase-0.md",
45
+ "core-phase-1-2.md",
46
+ "core-phase-3.md",
47
+ "host-warp.md",
48
+ "host-generic.md",
49
+ "host-grok-build.md",
50
+ "host-cursor.md",
51
+ "host-openclaw.md",
52
+ "core-phase-4.md",
53
+ "core-phase-5-6.md",
54
+ "core-ops.md",
55
+ ];
56
+ export function readSwarmSkillSurface() {
57
+ const parts = [readRepoFile(SWARM_SKILL_REL)];
58
+ for (const name of SWARM_REFERENCE_ORDER) {
59
+ const rel = `skills/deft-directive-swarm/references/${name}`;
60
+ // Fail-loud: SWARM_REFERENCE_ORDER is the complete shipped surface (#2928).
61
+ // Silently skipping a missing reference lets incomplete packs pass contracts
62
+ // that never assert markers unique to the omitted file (Greptile on #2936).
63
+ if (!repoFileExists(rel)) {
64
+ throw new Error(`readSwarmSkillSurface: missing declared reference ${rel}`);
65
+ }
66
+ parts.push(readRepoFile(rel));
67
+ }
68
+ return parts.join("\n\n");
69
+ }
35
70
  export function readAgentsMd() {
36
71
  return readRepoFile("AGENTS.md");
37
72
  }
@@ -5,12 +5,30 @@
5
5
  * directories are created mode 0o755; files keep their source permission bits
6
6
  * (including the executable bit for hooks and the `run` launcher).
7
7
  *
8
- * Refs #1942 S1, #1477.
8
+ * `replaceTree` is the npm-path counterpart of Go `swapInCore` (#2913): full
9
+ * destination replace so dst-only agent content cannot survive a refresh.
10
+ *
11
+ * Refs #1942 S1, #1477, #2913.
9
12
  */
10
13
  /**
11
14
  * Recursively copy `src` into `dst`, preserving nested structure and file modes.
12
15
  *
13
16
  * The contents of `src` are placed under `dst` (equivalent to Go `copyDir`).
17
+ * This is **additive** — pre-existing destination entries not present in `src`
18
+ * survive. Prefer {@link replaceTree} for deposit refresh integrity (#2913).
14
19
  */
15
20
  export declare function copyTree(src: string, dst: string): Promise<void>;
21
+ /**
22
+ * Full-tree replace of `dst` with the contents of `src` (Go `swapInCore` parity).
23
+ *
24
+ * Stages the new tree out-of-line, moves any existing `dst` aside, then moves
25
+ * the staged tree into place. On failure after the old tree was moved, restores
26
+ * the previous payload. Destination-only files (stale or malicious) do **not**
27
+ * survive — unlike additive {@link copyTree}.
28
+ *
29
+ * Refuses to operate when `dst` itself is a symlink (#2305 / #2912).
30
+ *
31
+ * Refs #2913, #2904 (install-deposit-06).
32
+ */
33
+ export declare function replaceTree(src: string, dst: string): Promise<void>;
16
34
  //# sourceMappingURL=copy-tree.d.ts.map
@@ -5,22 +5,27 @@
5
5
  * directories are created mode 0o755; files keep their source permission bits
6
6
  * (including the executable bit for hooks and the `run` launcher).
7
7
  *
8
- * Refs #1942 S1, #1477.
8
+ * `replaceTree` is the npm-path counterpart of Go `swapInCore` (#2913): full
9
+ * destination replace so dst-only agent content cannot survive a refresh.
10
+ *
11
+ * Refs #1942 S1, #1477, #2913.
9
12
  */
10
13
  import { constants } from "node:fs";
11
- import { lstat, mkdir, open, readdir, readFile, stat } from "node:fs/promises";
14
+ import { lstat, mkdir, mkdtemp, open, readdir, readFile, rename, rm, stat } from "node:fs/promises";
15
+ import { tmpdir } from "node:os";
12
16
  import { dirname, join } from "node:path";
13
17
  const DEFAULT_FILE_MODE = 0o644;
14
18
  const DEFAULT_DIR_MODE = 0o755;
15
- async function assertDestinationIsNotSymlink(path) {
19
+ async function assertDestinationIsNotSymlink(path, label = "copyTree") {
16
20
  try {
17
21
  const info = await lstat(path);
18
22
  if (info.isSymbolicLink()) {
19
- throw new Error(`copyTree: refusing to write through destination symlink ${path}`);
23
+ throw new Error(`${label}: refusing to write through destination symlink ${path}`);
20
24
  }
21
25
  }
22
26
  catch (err) {
23
- if (err instanceof Error && err.message.startsWith("copyTree: refusing")) {
27
+ if (err instanceof Error &&
28
+ err.message.includes("refusing to write through destination symlink")) {
24
29
  throw err;
25
30
  }
26
31
  if (err.code === "ENOENT") {
@@ -77,6 +82,8 @@ async function copyDirContents(src, dst) {
77
82
  * Recursively copy `src` into `dst`, preserving nested structure and file modes.
78
83
  *
79
84
  * The contents of `src` are placed under `dst` (equivalent to Go `copyDir`).
85
+ * This is **additive** — pre-existing destination entries not present in `src`
86
+ * survive. Prefer {@link replaceTree} for deposit refresh integrity (#2913).
80
87
  */
81
88
  export async function copyTree(src, dst) {
82
89
  const srcInfo = await stat(src);
@@ -85,4 +92,126 @@ export async function copyTree(src, dst) {
85
92
  }
86
93
  await copyDirContents(src, dst);
87
94
  }
95
+ async function pathExists(path) {
96
+ try {
97
+ await lstat(path);
98
+ return true;
99
+ }
100
+ catch (err) {
101
+ if (err.code === "ENOENT")
102
+ return false;
103
+ throw err;
104
+ }
105
+ }
106
+ /**
107
+ * Move `src` to `dst`, falling back to copy+remove across devices (EXDEV),
108
+ * mirroring Go `movePayload` used by `swapInCore`.
109
+ */
110
+ async function moveTree(src, dst) {
111
+ await mkdir(dirname(dst), { recursive: true, mode: DEFAULT_DIR_MODE });
112
+ try {
113
+ await rename(src, dst);
114
+ return;
115
+ }
116
+ catch (err) {
117
+ const code = err.code;
118
+ if (code !== "EXDEV" && code !== "EPERM") {
119
+ // On some platforms rename across volumes raises EXDEV; fall through only
120
+ // for cross-device / permission cases that still allow a copy fallback.
121
+ if (code !== "EINVAL")
122
+ throw err;
123
+ }
124
+ }
125
+ await copyDirContents(src, dst);
126
+ await rm(src, { recursive: true, force: true });
127
+ }
128
+ /**
129
+ * Full-tree replace of `dst` with the contents of `src` (Go `swapInCore` parity).
130
+ *
131
+ * Stages the new tree out-of-line, moves any existing `dst` aside, then moves
132
+ * the staged tree into place. On failure after the old tree was moved, restores
133
+ * the previous payload. Destination-only files (stale or malicious) do **not**
134
+ * survive — unlike additive {@link copyTree}.
135
+ *
136
+ * Refuses to operate when `dst` itself is a symlink (#2305 / #2912).
137
+ *
138
+ * Refs #2913, #2904 (install-deposit-06).
139
+ */
140
+ export async function replaceTree(src, dst) {
141
+ const srcInfo = await stat(src);
142
+ if (!srcInfo.isDirectory()) {
143
+ throw new Error(`replaceTree: source ${src} is not a directory`);
144
+ }
145
+ await assertDestinationIsNotSymlink(dst, "replaceTree");
146
+ const parent = dirname(dst);
147
+ await mkdir(parent, { recursive: true, mode: DEFAULT_DIR_MODE });
148
+ const staging = await mkdtemp(join(tmpdir(), "deft-core-stage-"));
149
+ let backup = null;
150
+ /** When true, leave `backup` on disk so an operator can recover after dual failure. */
151
+ let preserveBackupOnExit = false;
152
+ try {
153
+ await copyDirContents(src, staging);
154
+ if (await pathExists(dst)) {
155
+ backup = await mkdtemp(join(tmpdir(), "deft-core-bak-"));
156
+ // mkdtemp created an empty dir; remove it so moveTree can rename onto the path.
157
+ await rm(backup, { recursive: true, force: true });
158
+ try {
159
+ await moveTree(dst, backup);
160
+ }
161
+ catch (asideErr) {
162
+ // Greptile P1: moveTree may fall back to copy+remove. If removal fails after a
163
+ // successful copy (or after a partial delete of dst), `backup` is the only full
164
+ // recovery copy while dst may already be damaged. Do NOT let `finally` delete it.
165
+ if (await pathExists(backup)) {
166
+ preserveBackupOnExit = true;
167
+ }
168
+ else {
169
+ backup = null;
170
+ }
171
+ const asideMsg = asideErr instanceof Error ? asideErr.message : String(asideErr);
172
+ throw new Error(`replaceTree: failed to move existing destination aside (${asideMsg})` +
173
+ (backup ? ` — recovery copy at ${backup}` : ""));
174
+ }
175
+ }
176
+ try {
177
+ await moveTree(staging, dst);
178
+ }
179
+ catch (err) {
180
+ if (backup !== null) {
181
+ try {
182
+ if (await pathExists(dst)) {
183
+ await rm(dst, { recursive: true, force: true });
184
+ }
185
+ await moveTree(backup, dst);
186
+ backup = null;
187
+ }
188
+ catch (restoreErr) {
189
+ preserveBackupOnExit = true;
190
+ const installMsg = err instanceof Error ? err.message : String(err);
191
+ const restoreMsg = restoreErr instanceof Error ? restoreErr.message : String(restoreErr);
192
+ throw new Error(`replaceTree: install new payload failed (${installMsg}); ROLLBACK ALSO FAILED (${restoreMsg})` +
193
+ (backup ? ` — previous payload preserved at ${backup}` : ""));
194
+ }
195
+ }
196
+ throw err instanceof Error
197
+ ? err
198
+ : new Error(`replaceTree: install new payload failed: ${String(err)}`);
199
+ }
200
+ // Successful install — best-effort drop the backup (Go keeps it for operator
201
+ // rollback; the npm path does not surface a backup path today and must not
202
+ // litter TEMP). Cleanup MUST NOT fail the replace: the new payload is already
203
+ // live at `dst`. A thrown rm here would reject replaceTree and skip the
204
+ // VERSION stamp in runRefreshDeposit → version drift (Greptile P1).
205
+ if (backup !== null) {
206
+ await rm(backup, { recursive: true, force: true }).catch(() => undefined);
207
+ backup = null;
208
+ }
209
+ }
210
+ finally {
211
+ await rm(staging, { recursive: true, force: true }).catch(() => undefined);
212
+ if (backup !== null && !preserveBackupOnExit) {
213
+ await rm(backup, { recursive: true, force: true }).catch(() => undefined);
214
+ }
215
+ }
216
+ }
88
217
  //# sourceMappingURL=copy-tree.js.map
@@ -6,11 +6,12 @@ export declare function runAgentHooksHealthCheck(projectRoot: string, consumerCo
6
6
  export declare function runAgentHooksLiveProbeCheck(projectRoot: string, sink: ReturnType<typeof createPlainSink>, addFinding: (finding: Finding) => void, seams: DoctorSeams): void;
7
7
  /**
8
8
  * Never emit a bare `task ...` remediation in a project without Taskfile wiring
9
- * (#2267). The `directive` surface always works; `task deft:X` is optional and
10
- * only present when the consumer wired the include. `plan()` already emits the
11
- * `directive` / `npx` / `npm` surface, so this guard is a defensive invariant:
12
- * any `task`-prefixed command is rewritten to the `directive` surface unless the
13
- * project actually has the include.
9
+ * (#2267 / #2893). The `directive`/`deft` surface always works; `task deft:X` is
10
+ * the go-task namespaced form only when the consumer wired the include (bare
11
+ * `task pr:watch` is not the consumer form under include key `deft:`). `plan()`
12
+ * already emits the `directive` / `npx` / `npm` surface, so this guard is a
13
+ * defensive invariant: any `task`-prefixed command is rewritten to the
14
+ * `directive` surface unless the project actually has the include.
14
15
  */
15
16
  /**
16
17
  * Detect Taskfile wiring through the injected seam (#2267). Mirrors
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { evaluate as evaluateAgentsMdAdvisory } from "../agents-md-advisory/evaluate.js";
4
4
  import { contentRoot } from "../content-root.js";
5
+ import { detectNoDeftDirective, NO_DEFT_DIRECTIVE_DISABLED_MESSAGE, NO_DEFT_DIRECTIVE_FLAG_NAME, NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE, NO_DEFT_DIRECTIVE_INCONSISTENT_POLICY, } from "../policy/no-deft-directive.js";
5
6
  import { describeShadowedPlanExtension, detectShadowedPlanExtensions, } from "../policy/plan-extensions.js";
6
7
  import { loadProjectDefinition } from "../policy/resolve.js";
7
8
  import { checkLocalEngineIntegrity, classify, detectPackageManager, evaluateSkew, reconcileVersions, plan as resolvePlan, } from "../resolution/index.js";
@@ -20,7 +21,7 @@ import { createPlainSink } from "./output.js";
20
21
  import { readTextSafe, resolveFrameworkRootForProject, resolvePath, resolveVersion, runningInsideDeftRepo, } from "./paths.js";
21
22
  import { runPayloadStalenessCheck } from "./payload-staleness.js";
22
23
  import { runLocalSignpostChecks } from "./signpost-checks.js";
23
- import { classifyTaskfileInclude, formatMissingIncludeSnippet, includesBlockHasDeftTaskfile, resolveConsumerTaskfile, } from "./taskfile.js";
24
+ import { classifyTaskfileInclude, formatGatesSurfaceDualRemediation, formatMissingIncludeSnippet, GATES_SURFACE_DEFT_REMEDIATION, includesBlockHasDeftTaskfile, resolveConsumerTaskfile, } from "./taskfile.js";
24
25
  import { defaultWhich } from "./which.js";
25
26
  const DEFAULT_RESOLUTION_PLATFORMS = ["linux", "darwin", "win32"];
26
27
  /**
@@ -62,6 +63,53 @@ export function cmdDoctor(args, seams = {}) {
62
63
  const consumerContext = resolve(projectRoot) !== resolve(frameworkRoot);
63
64
  const whichFn = seams.whichFn ?? defaultWhich;
64
65
  const nowFn = seams.now ?? (() => new Date());
66
+ // #2926: official root opt-out — short-circuit Directive doctor when clean;
67
+ // diagnose flag+deposit inconsistency (warn; exit dirty).
68
+ const optOut = detectNoDeftDirective(projectRoot);
69
+ if (optOut.present) {
70
+ if (optOut.inconsistent) {
71
+ const message = `${NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE} (${NO_DEFT_DIRECTIVE_DISABLED_MESSAGE})`;
72
+ if (jsonMode) {
73
+ const payload = {
74
+ status: "disabled-inconsistent",
75
+ disabled: true,
76
+ disabled_via: NO_DEFT_DIRECTIVE_FLAG_NAME,
77
+ inconsistent: true,
78
+ inconsistent_policy: NO_DEFT_DIRECTIVE_INCONSISTENT_POLICY,
79
+ deposit_present: true,
80
+ message,
81
+ findings: [
82
+ {
83
+ severity: "warning",
84
+ message,
85
+ check: "no-deft-directive",
86
+ policy: NO_DEFT_DIRECTIVE_INCONSISTENT_POLICY,
87
+ },
88
+ ],
89
+ };
90
+ process.stdout.write(`${pythonJsonDump(payload)}\n`);
91
+ }
92
+ else if (!quietMode) {
93
+ process.stderr.write(`${message} [policy=${NO_DEFT_DIRECTIVE_INCONSISTENT_POLICY}]\n`);
94
+ }
95
+ return 1;
96
+ }
97
+ if (jsonMode) {
98
+ const payload = {
99
+ status: "disabled",
100
+ disabled: true,
101
+ disabled_via: NO_DEFT_DIRECTIVE_FLAG_NAME,
102
+ inconsistent: false,
103
+ deposit_present: false,
104
+ message: NO_DEFT_DIRECTIVE_DISABLED_MESSAGE,
105
+ };
106
+ process.stdout.write(`${pythonJsonDump(payload)}\n`);
107
+ }
108
+ else if (!quietMode) {
109
+ process.stdout.write(`${NO_DEFT_DIRECTIVE_DISABLED_MESSAGE}\n`);
110
+ }
111
+ return 0;
112
+ }
65
113
  if (!fullMode) {
66
114
  const state = (seams.readState ?? readState)(projectRoot);
67
115
  const decision = decideThrottle(state, nowFn());
@@ -291,7 +339,7 @@ export function cmdDoctor(args, seams = {}) {
291
339
  if (!jsonMode) {
292
340
  sink.blank();
293
341
  }
294
- sink.info("Checking optional root Taskfile.yml include...");
342
+ sink.info("Checking gates-surface readiness (Taskfile include for deep-think agent gates)...");
295
343
  runTaskfileIncludeCheck(projectRoot, fixMode, jsonMode, sink, addFinding, seams);
296
344
  let resolution = null;
297
345
  if (!runningInsideDeftRepo(projectRoot, seams)) {
@@ -640,15 +688,21 @@ function runTaskfileIncludeCheck(projectRoot, fixMode, jsonMode, sink, addFindin
640
688
  }
641
689
  const includeStatus = classifyTaskfileInclude(projectRoot);
642
690
  if (includeStatus === "ok") {
643
- sink.success("Root Taskfile.yml includes the deft framework");
691
+ sink.success("Gates-surface ready: root Taskfile.yml includes the deft framework (`task deft:<verb>`)");
644
692
  return;
645
693
  }
646
694
  if (includeStatus === "missing-file") {
647
695
  let includeMissing = true;
648
696
  const target = join(projectRoot, "Taskfile.yml");
649
- const message = "Root Taskfile.yml missing. This is OK for package-manager installs that use the `deft X` surface directly. To also enable the optional `task deft:X` surface, paste this into " +
650
- `${target}:`;
651
- sink.info(message);
697
+ // #2893: elevate to warning deep-think agent gates need a working invoke path.
698
+ // Dual remediations: (1) deft CLI primary (2) Taskfile include for task deft: verbs.
699
+ const message = "Gates-surface readiness: root Taskfile.yml missing. Deep-think agent gates " +
700
+ "(`pr:watch`, `review-monitor:*`) need a working invoke path — not optional convenience. " +
701
+ "1. " +
702
+ GATES_SURFACE_DEFT_REMEDIATION +
703
+ ` 2. Create ${target} with the canonical include so go-task exposes \`task deft:<verb>\` ` +
704
+ "(include key `deft:` → namespaced tasks; bare `task pr:watch` is not the consumer form):";
705
+ sink.warn(message);
652
706
  if (!jsonMode) {
653
707
  sink.blank();
654
708
  sink.raw(TASKFILE_INCLUDE_SNIPPET);
@@ -668,22 +722,27 @@ function runTaskfileIncludeCheck(projectRoot, fixMode, jsonMode, sink, addFindin
668
722
  }
669
723
  }
670
724
  else {
671
- sink.info("Skipped Taskfile.yml creation -- paste the snippet above when you are ready.");
725
+ sink.info("Skipped Taskfile.yml creation -- use `deft <verb>` now, or paste the include snippet when ready.");
672
726
  }
673
727
  }
674
728
  if (includeMissing) {
675
729
  addFinding({
676
730
  severity: "warning",
677
- message: "Root Taskfile.yml missing; optional Taskfile include unavailable",
731
+ message: "Gates-surface: root Taskfile.yml missing deep-think gates need `deft` CLI or `task deft:` include",
678
732
  check: "taskfile-include",
679
733
  file: target,
680
- suggestion: TASKFILE_INCLUDE_SNIPPET,
734
+ suggestion: formatGatesSurfaceDualRemediation("missing-file"),
681
735
  });
682
736
  }
683
737
  return;
684
738
  }
685
739
  if (includeStatus === "missing-include") {
686
- const message = "Root Taskfile.yml exists but does not include the deft framework. The `deft X` surface still works; add this to the Taskfile `includes:` block only if you want the optional `task deft:X` surface (doctor NEVER mutates an existing user-owned Taskfile):";
740
+ const message = "Gates-surface readiness: root Taskfile.yml exists but does not include the deft framework. " +
741
+ "Deep-think agent gates (`pr:watch`, `review-monitor:*`) need a working invoke path. " +
742
+ "1. " +
743
+ GATES_SURFACE_DEFT_REMEDIATION +
744
+ " 2. Add this to the Taskfile `includes:` block so go-task exposes `task deft:<verb>` " +
745
+ "(doctor NEVER mutates an existing user-owned Taskfile; bare `task pr:watch` is not the consumer form when the include key is `deft:`):";
687
746
  sink.warn(message);
688
747
  if (!jsonMode) {
689
748
  sink.blank();
@@ -692,21 +751,23 @@ function runTaskfileIncludeCheck(projectRoot, fixMode, jsonMode, sink, addFindin
692
751
  const tf = resolveConsumerTaskfile(projectRoot);
693
752
  addFinding({
694
753
  severity: "warning",
695
- message: "Root Taskfile.yml does not include the deft framework",
754
+ message: "Gates-surface: root Taskfile.yml does not include the deft framework — deep-think gates need `deft` CLI or `task deft:` include",
696
755
  check: "taskfile-include",
697
756
  file: tf,
698
- suggestion: formatMissingIncludeSnippet(),
757
+ suggestion: formatGatesSurfaceDualRemediation("missing-include"),
699
758
  });
700
759
  return;
701
760
  }
702
761
  const taskfilePath = resolveConsumerTaskfile(projectRoot) ?? join(projectRoot, "Taskfile.yml");
703
- const message = `Root Taskfile.yml at ${taskfilePath} exists but could not be read -- check file permissions.`;
762
+ const message = `Gates-surface readiness: root Taskfile.yml at ${taskfilePath} exists but could not be read ` +
763
+ "check file permissions. Deep-think gates still work via `deft <verb>` until the include is readable.";
704
764
  sink.warn(message);
705
765
  addFinding({
706
766
  severity: "warning",
707
767
  message,
708
768
  check: "taskfile-include",
709
769
  file: taskfilePath,
770
+ suggestion: GATES_SURFACE_DEFT_REMEDIATION,
710
771
  });
711
772
  }
712
773
  /**
@@ -786,11 +847,12 @@ function runPlanExtensionShadowCheck(projectRoot, sink, addFinding, seams) {
786
847
  }
787
848
  /**
788
849
  * Never emit a bare `task ...` remediation in a project without Taskfile wiring
789
- * (#2267). The `directive` surface always works; `task deft:X` is optional and
790
- * only present when the consumer wired the include. `plan()` already emits the
791
- * `directive` / `npx` / `npm` surface, so this guard is a defensive invariant:
792
- * any `task`-prefixed command is rewritten to the `directive` surface unless the
793
- * project actually has the include.
850
+ * (#2267 / #2893). The `directive`/`deft` surface always works; `task deft:X` is
851
+ * the go-task namespaced form only when the consumer wired the include (bare
852
+ * `task pr:watch` is not the consumer form under include key `deft:`). `plan()`
853
+ * already emits the `directive` / `npx` / `npm` surface, so this guard is a
854
+ * defensive invariant: any `task`-prefixed command is rewritten to the
855
+ * `directive` surface unless the project actually has the include.
794
856
  */
795
857
  /**
796
858
  * Detect Taskfile wiring through the injected seam (#2267). Mirrors
@@ -3,4 +3,12 @@ export declare function resolveConsumerTaskfile(projectRoot: string): string | n
3
3
  export type TaskfileIncludeStatus = "ok" | "missing-file" | "missing-include" | "unreadable";
4
4
  export declare function classifyTaskfileInclude(projectRoot: string): TaskfileIncludeStatus;
5
5
  export declare function formatMissingIncludeSnippet(): string;
6
+ /** Primary remediation for deep-think gates when Taskfile include is absent (#2893). */
7
+ export declare const GATES_SURFACE_DEFT_REMEDIATION: string;
8
+ /**
9
+ * Dual remediations for gates-surface readiness (#2893):
10
+ * 1. `deft <verb>` CLI (works without Taskfile)
11
+ * 2. Taskfile include so go-task exposes `task deft:<verb>` (not bare `task pr:watch`)
12
+ */
13
+ export declare function formatGatesSurfaceDualRemediation(kind: "missing-file" | "missing-include"): string;
6
14
  //# sourceMappingURL=taskfile.d.ts.map
@@ -62,4 +62,23 @@ export function classifyTaskfileInclude(projectRoot) {
62
62
  export function formatMissingIncludeSnippet() {
63
63
  return " deft:\n taskfile: ./.deft/core/Taskfile.yml\n optional: true\n";
64
64
  }
65
+ /** Primary remediation for deep-think gates when Taskfile include is absent (#2893). */
66
+ export const GATES_SURFACE_DEFT_REMEDIATION = "Prefer `deft pr:watch` / `deft review-monitor:register` / `deft verify:review-monitor` " +
67
+ "(primary npm/CLI surface; no Taskfile required).";
68
+ /**
69
+ * Dual remediations for gates-surface readiness (#2893):
70
+ * 1. `deft <verb>` CLI (works without Taskfile)
71
+ * 2. Taskfile include so go-task exposes `task deft:<verb>` (not bare `task pr:watch`)
72
+ */
73
+ export function formatGatesSurfaceDualRemediation(kind) {
74
+ const includePart = kind === "missing-file"
75
+ ? "Create root Taskfile.yml with the canonical deft include so go-task exposes `task deft:<verb>` " +
76
+ "(not bare `task pr:watch`):\n" +
77
+ "version: '3'\n\nincludes:\n" +
78
+ formatMissingIncludeSnippet()
79
+ : "Add the deft include to the existing Taskfile `includes:` block so go-task exposes " +
80
+ "`task deft:<verb>` (doctor NEVER mutates an existing user-owned Taskfile):\n" +
81
+ formatMissingIncludeSnippet();
82
+ return `${GATES_SURFACE_DEFT_REMEDIATION}\n2. ${includePart}`;
83
+ }
65
84
  //# sourceMappingURL=taskfile.js.map
@@ -37,6 +37,24 @@ export declare function assertProjectionContained(projectDir: string, targetPath
37
37
  * Pair with {@link assertProjectionContained} before read/write/mkdir/append.
38
38
  */
39
39
  export declare function assertWriteTargetSafe(projectDir: string, targetPath: string): void;
40
+ /**
41
+ * Refuse projection writes that would follow an IN-TREE destination symlink on
42
+ * the write path (#2912).
43
+ *
44
+ * {@link assertProjectionContained} only rejects symlinks that ESCAPE the
45
+ * project tree, so an in-tree symlink (leaf or parent) pointing at another
46
+ * checked-in path is silently followed — letting a malicious or mistaken repo
47
+ * symlink divert a consumer projection write (AGENTS.md, .githooks/**,
48
+ * .gitattributes, .github/**, .agents/**, vbrief|xbrief/**, package.json, …)
49
+ * onto an unintended file under operator credentials.
50
+ *
51
+ * This guard first runs the escape checks in {@link assertProjectionContained},
52
+ * then walks every EXISTING component from the project root down to the write
53
+ * target and refuses the write if ANY of them is a symlink — regardless of
54
+ * whether the link resolves inside the tree. Call it BEFORE any projection
55
+ * read/write/mkdir/append on consumer deposit sinks.
56
+ */
57
+ export declare function assertDestinationNotSymlink(projectDir: string, targetPath: string): void;
40
58
  /**
41
59
  * Refuse when a lifecycle corpus root is itself a symlink (#2626 category-b).
42
60
  */