@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
@@ -1,6 +1,6 @@
1
1
  /** Port of scripts/pack_render.py — content-pack projection renderer (#1294, #1295). */
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
- import { dirname, join, resolve } from "node:path";
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync, } from "node:fs";
3
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { contentRoot } from "../content-root.js";
6
6
  export function resolveRepoRoot() {
@@ -54,6 +54,138 @@ const SWARM_SPEC_BANNER = [
54
54
  ];
55
55
  export const BANNER = `${LESSONS_BANNER.join("\n")}\n`;
56
56
  export const DOC_TITLE = "# Lessons Learned";
57
+ /** Non-zero exit code for a pack-render containment / schema refusal. */
58
+ export const PACK_RENDER_REFUSED_EXIT_CODE = 2;
59
+ /**
60
+ * Thrown, fail-closed, when a pack record's projection path escapes CONTENT_ROOT,
61
+ * violates the pack path schema, or a frontmatter field carries an injection
62
+ * payload (null byte / newline). Remediates AppSec finding skill-pack-supply-01
63
+ * from tracker #2904. Refs #2914.
64
+ */
65
+ export class PackRenderContainmentError extends Error {
66
+ pack;
67
+ field;
68
+ offendingValue;
69
+ constructor(message, details) {
70
+ super(message);
71
+ this.name = "PackRenderContainmentError";
72
+ this.pack = details.pack;
73
+ this.field = details.field;
74
+ this.offendingValue = details.offendingValue;
75
+ }
76
+ }
77
+ /**
78
+ * Path-SEGMENT containment: is `child` equal to `parent` or nested under it?
79
+ * Uses `path.relative` so `/foo` is NOT treated as containing `/foobar`
80
+ * (mirrors deposit/contain.ts).
81
+ */
82
+ function isContained(parent, child) {
83
+ if (parent === child) {
84
+ return true;
85
+ }
86
+ const rel = relative(parent, child);
87
+ return rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel);
88
+ }
89
+ /** realpath with a resolve() fallback when the path does not exist yet. */
90
+ function safeRealpath(p) {
91
+ try {
92
+ return realpathSync(p);
93
+ }
94
+ catch {
95
+ return resolve(p);
96
+ }
97
+ }
98
+ /**
99
+ * Defense-in-depth: walk each EXISTING component from CONTENT_ROOT down to the
100
+ * resolved output and refuse any symlink that escapes CONTENT_ROOT. Mirrors the
101
+ * realpath-anchored segment walk in deposit/contain.ts so a symlinked content
102
+ * subtree cannot redirect a projection write outside the shippable-content root.
103
+ */
104
+ function assertAncestorContained(packName, field, rawPath, rootReal, outPath) {
105
+ const rel = relative(rootReal, outPath);
106
+ const segments = rel.split(/[\\/]+/).filter((segment) => segment.length > 0);
107
+ let current = rootReal;
108
+ for (const segment of segments) {
109
+ current = join(current, segment);
110
+ let info;
111
+ try {
112
+ info = lstatSync(current);
113
+ }
114
+ catch {
115
+ // Component does not exist yet; nothing below it can exist either.
116
+ break;
117
+ }
118
+ if (info.isSymbolicLink()) {
119
+ let linkReal;
120
+ try {
121
+ linkReal = realpathSync(current);
122
+ }
123
+ catch {
124
+ throw new PackRenderContainmentError(`pack-render refused: ${packName} ${field} path component ${current} is a broken symlink`, { pack: packName, field, offendingValue: rawPath });
125
+ }
126
+ if (!isContained(rootReal, linkReal)) {
127
+ throw new PackRenderContainmentError(`pack-render refused: ${packName} ${field} path component ${current} is a symlink ` +
128
+ `escaping CONTENT_ROOT (resolves to ${linkReal}, outside ${rootReal})`, { pack: packName, field, offendingValue: rawPath });
129
+ }
130
+ current = linkReal;
131
+ }
132
+ }
133
+ }
134
+ /**
135
+ * Resolve a pack record's projection path against CONTENT_ROOT and refuse,
136
+ * fail-closed, any value that: is missing/empty/non-string, carries a null
137
+ * byte, is absolute, violates the pack's schema path pattern, or resolves
138
+ * outside CONTENT_ROOT (a `..` escape). Returns the contained absolute output
139
+ * path. Mirrors deposit/contain.ts segment containment. Refs #2914 / #2904.
140
+ */
141
+ export function containedOutPath(packName, rawPath, cfg) {
142
+ const field = cfg.path_field ?? "path";
143
+ if (typeof rawPath !== "string" || rawPath.length === 0) {
144
+ throw new PackRenderContainmentError(`pack-render refused: ${packName} entry has a missing/empty ${field}`, { pack: packName, field, offendingValue: String(rawPath) });
145
+ }
146
+ if (rawPath.includes("\0")) {
147
+ throw new PackRenderContainmentError(`pack-render refused: ${packName} ${field} contains a null byte`, { pack: packName, field, offendingValue: rawPath });
148
+ }
149
+ if (isAbsolute(rawPath) || /^[\\/]/.test(rawPath)) {
150
+ throw new PackRenderContainmentError(`pack-render refused: ${packName} ${field} must be a relative path (got absolute ${JSON.stringify(rawPath)})`, { pack: packName, field, offendingValue: rawPath });
151
+ }
152
+ if (cfg.path_pattern && !cfg.path_pattern.test(rawPath)) {
153
+ throw new PackRenderContainmentError(`pack-render refused: ${packName} ${field} ${JSON.stringify(rawPath)} violates the ` +
154
+ `${packName}-pack schema path pattern ${String(cfg.path_pattern)}`, { pack: packName, field, offendingValue: rawPath });
155
+ }
156
+ // Resolve with realpath semantics on the trusted root, then confirm segment
157
+ // containment. `resolve` collapses `..`, so an escape lands outside rootReal
158
+ // and is refused here even when it slipped past the schema pattern (where `.`
159
+ // can match a path separator).
160
+ const rootReal = safeRealpath(CONTENT_ROOT);
161
+ const outPath = resolve(rootReal, rawPath);
162
+ if (!isContained(rootReal, outPath)) {
163
+ throw new PackRenderContainmentError(`pack-render refused: ${packName} ${field} ${JSON.stringify(rawPath)} escapes CONTENT_ROOT ` +
164
+ `(resolves to ${outPath}, outside ${rootReal})`, { pack: packName, field, offendingValue: rawPath });
165
+ }
166
+ assertAncestorContained(packName, field, rawPath, rootReal, outPath);
167
+ return outPath;
168
+ }
169
+ /** Refuse a frontmatter field value that embeds a null byte. Refs #2914. */
170
+ function assertNoNullByte(value, packName, field) {
171
+ if (value.includes("\0")) {
172
+ throw new PackRenderContainmentError(`pack-render refused: ${packName} frontmatter field ${field} contains a null byte`, { pack: packName, field, offendingValue: value });
173
+ }
174
+ return value;
175
+ }
176
+ /**
177
+ * Sanitize a single-line YAML frontmatter field (skill `name`): refuse null
178
+ * bytes and any CR/LF. An unescaped newline would inject arbitrary YAML keys or
179
+ * a premature `---` document break into the generated frontmatter. Refs #2914.
180
+ */
181
+ function sanitizeSingleLineField(value, packName, field) {
182
+ assertNoNullByte(value, packName, field);
183
+ if (/[\r\n]/.test(value)) {
184
+ throw new PackRenderContainmentError(`pack-render refused: ${packName} frontmatter field ${field} must not contain a newline ` +
185
+ `(YAML frontmatter injection)`, { pack: packName, field, offendingValue: value });
186
+ }
187
+ return value;
188
+ }
57
189
  export const RENDER_REGISTRY = {
58
190
  lessons: {
59
191
  mode: "collection",
@@ -73,6 +205,7 @@ export const RENDER_REGISTRY = {
73
205
  name_field: "id",
74
206
  description_field: "description",
75
207
  path_field: "path",
208
+ path_pattern: /^skills\/.+\/SKILL\.md$/,
76
209
  body_field: "body",
77
210
  banner: SKILLS_BANNER,
78
211
  },
@@ -82,6 +215,7 @@ export const RENDER_REGISTRY = {
82
215
  source: join(CONTENT_ROOT, "packs", "rules", "rules-pack-0.1.json"),
83
216
  items_field: "rules",
84
217
  path_field: "path",
218
+ path_pattern: /^(?:coding\/.+\.md|AGENTS\.md|main\.md)$/,
85
219
  body_field: "body",
86
220
  banner: RULES_BANNER,
87
221
  },
@@ -91,6 +225,7 @@ export const RENDER_REGISTRY = {
91
225
  source: join(CONTENT_ROOT, "packs", "strategies", "strategies-pack-0.1.json"),
92
226
  items_field: "strategies",
93
227
  path_field: "path",
228
+ path_pattern: /^strategies\/.+\.md$/,
94
229
  body_field: "body",
95
230
  banner: STRATEGIES_BANNER,
96
231
  },
@@ -100,6 +235,7 @@ export const RENDER_REGISTRY = {
100
235
  source: join(CONTENT_ROOT, "packs", "patterns", "patterns-pack-0.1.json"),
101
236
  items_field: "patterns",
102
237
  path_field: "path",
238
+ path_pattern: /^patterns\/.+\.md$/,
103
239
  body_field: "body",
104
240
  banner: PATTERNS_BANNER,
105
241
  },
@@ -109,6 +245,7 @@ export const RENDER_REGISTRY = {
109
245
  source: join(CONTENT_ROOT, "packs", "swarm-spec", "swarm-spec-pack-0.1.json"),
110
246
  items_field: "entries",
111
247
  path_field: "path",
248
+ path_pattern: /^swarm\/.+\.md$/,
112
249
  body_field: "body",
113
250
  banner: SWARM_SPEC_BANNER,
114
251
  },
@@ -166,12 +303,18 @@ function emitDescription(description) {
166
303
  return lines.join("\n");
167
304
  }
168
305
  export function renderSkillDocument(entry, cfg) {
169
- const name = entry[cfg.name_field ?? "id"];
170
- const description = entry[cfg.description_field ?? "description"];
306
+ // Sanitize every YAML frontmatter field so a malicious pack cannot inject
307
+ // unescaped frontmatter (newline -> extra key / premature `---`) or a null
308
+ // byte. Refs #2914 / #2904 (skill-pack-supply-01).
309
+ const nameField = cfg.name_field ?? "id";
310
+ const descriptionField = cfg.description_field ?? "description";
311
+ const name = sanitizeSingleLineField(String(entry[nameField]), "skills", nameField);
312
+ const description = assertNoNullByte(String(entry[descriptionField]), "skills", descriptionField);
171
313
  const body = entry[cfg.body_field] ?? "";
172
314
  const extra = entry.frontmatter_extra;
173
- const extraBlock = typeof extra === "string" && extra.length > 0 ? `${extra}\n` : "";
174
- const frontmatter = `---\nname: ${String(name)}\n${emitDescription(String(description))}\n${extraBlock}---\n`;
315
+ const extraStr = typeof extra === "string" ? assertNoNullByte(extra, "skills", "frontmatter_extra") : "";
316
+ const extraBlock = extraStr.length > 0 ? `${extraStr}\n` : "";
317
+ const frontmatter = `---\nname: ${name}\n${emitDescription(description)}\n${extraBlock}---\n`;
175
318
  return `${frontmatter}${bannerText(cfg.banner)}\n${String(body)}`;
176
319
  }
177
320
  export function renderMarkdownDocument(entry, cfg) {
@@ -182,7 +325,7 @@ const DOCUMENT_RENDERERS = {
182
325
  skill: renderSkillDocument,
183
326
  markdown: renderMarkdownDocument,
184
327
  };
185
- function targetsForPack(pack, cfg) {
328
+ export function targetsForPack(packName, pack, cfg) {
186
329
  if (cfg.mode === "collection") {
187
330
  return [[cfg.output ?? "", renderCollection(pack, cfg)]];
188
331
  }
@@ -203,7 +346,10 @@ function targetsForPack(pack, cfg) {
203
346
  const pathField = cfg.path_field ?? "path";
204
347
  // Document projection targets (skills/rules/strategies/patterns) are
205
348
  // shippable content -- they live under content/ in the source repo (#1875).
206
- const outPath = join(CONTENT_ROOT, String(record[pathField]));
349
+ // #2914: contain the pack-supplied path under CONTENT_ROOT and enforce the
350
+ // pack schema path pattern BEFORE render so a malicious pack JSON cannot
351
+ // write outside content/ via an absolute / `..` / null-byte path.
352
+ const outPath = containedOutPath(packName, record[pathField], cfg);
207
353
  targets.push([outPath, docRenderer(record, cfg)]);
208
354
  }
209
355
  return targets;
@@ -218,7 +364,7 @@ export function collectTargets(packFilter) {
218
364
  throw new Error(`pack source not found: ${cfg.source}`);
219
365
  }
220
366
  const pack = JSON.parse(readFileSync(cfg.source, "utf8"));
221
- for (const [outPath, text] of targetsForPack(pack, cfg)) {
367
+ for (const [outPath, text] of targetsForPack(name, pack, cfg)) {
222
368
  targets.push([name, outPath, text]);
223
369
  }
224
370
  }
@@ -2,6 +2,16 @@
2
2
  export declare const SUSPICIOUS_TOKENS: readonly string[];
3
3
  export declare const QUARANTINE_FENCE_OPEN = "```quarantined";
4
4
  export declare const QUARANTINE_FENCE_CLOSE = "```";
5
+ /**
6
+ * Neutralize a body line so it cannot act as a CommonMark fence open/close.
7
+ * Used on content placed inside a ```quarantined wrapper (#2915 / cache-quarantine-03).
8
+ *
9
+ * A nested bare ``` (or longer run, optional ≤3-space indent) would otherwise
10
+ * early-close the outer fence and let following attacker text render outside
11
+ * the quarantined info-string. Break the delimiter run by inserting a backslash
12
+ * after the first fence character: ``` → `\`` , ~~~ → ~\~~ .
13
+ */
14
+ export declare function neutralizeFenceLine(line: string): string;
5
15
  export declare function quarantineBody(rawMd: string): string;
6
16
  export declare function main(argv?: string[]): number;
7
17
  //# sourceMappingURL=quarantine-ext.d.ts.map
@@ -28,6 +28,26 @@ export const SUSPICIOUS_TOKENS = [
28
28
  ];
29
29
  export const QUARANTINE_FENCE_OPEN = "```quarantined";
30
30
  export const QUARANTINE_FENCE_CLOSE = "```";
31
+ /**
32
+ * Neutralize a body line so it cannot act as a CommonMark fence open/close.
33
+ * Used on content placed inside a ```quarantined wrapper (#2915 / cache-quarantine-03).
34
+ *
35
+ * A nested bare ``` (or longer run, optional ≤3-space indent) would otherwise
36
+ * early-close the outer fence and let following attacker text render outside
37
+ * the quarantined info-string. Break the delimiter run by inserting a backslash
38
+ * after the first fence character: ``` → `\`` , ~~~ → ~\~~ .
39
+ */
40
+ export function neutralizeFenceLine(line) {
41
+ const match = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(line);
42
+ if (match === null)
43
+ return line;
44
+ const indent = match[1] ?? "";
45
+ const delim = match[2] ?? "";
46
+ const rest = match[3] ?? "";
47
+ // One backslash after the first fence char breaks the delimiter run.
48
+ // Concat (not a template literal) so "\\" is unambiguously a single "\".
49
+ return indent + delim[0] + "\\" + delim.slice(1) + rest;
50
+ }
31
51
  function isWordChar(ch) {
32
52
  return ((ch >= "A" && ch <= "Z") || (ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9") || ch === "_");
33
53
  }
@@ -133,14 +153,18 @@ export function quarantineBody(rawMd) {
133
153
  sectionEnd += 1;
134
154
  }
135
155
  out.push(QUARANTINE_FENCE_OPEN);
136
- out.push(...lines.slice(i, sectionEnd));
156
+ // #2915: neutralize nested fence lines so they cannot early-close.
157
+ for (const bodyLine of lines.slice(i, sectionEnd)) {
158
+ out.push(neutralizeFenceLine(bodyLine));
159
+ }
137
160
  out.push(QUARANTINE_FENCE_CLOSE);
138
161
  i = sectionEnd;
139
162
  continue;
140
163
  }
141
164
  if (isSuspicious(line)) {
142
165
  out.push(QUARANTINE_FENCE_OPEN);
143
- out.push(line);
166
+ // #2915: neutralize in case the single line itself is fence-shaped.
167
+ out.push(neutralizeFenceLine(line));
144
168
  out.push(QUARANTINE_FENCE_CLOSE);
145
169
  i += 1;
146
170
  continue;
@@ -14,6 +14,9 @@ const CURSOR_SIGNAL_VARS = [
14
14
  const CLOUD_SIGNAL_VARS = [
15
15
  "CURSOR_AGENT",
16
16
  "GROK_BUILD",
17
+ "OPENCLAW",
18
+ "DEFT_HAS_SESSIONS_SPAWN",
19
+ "DEFT_PROBE_SESSIONS_SPAWN",
17
20
  "DEFT_AGENT_RUNTIME",
18
21
  "CI",
19
22
  "GITHUB_ACTIONS",
@@ -4,6 +4,7 @@ export * from "./capacity.js";
4
4
  export * from "./decisions.js";
5
5
  export * from "./disclosure.js";
6
6
  export * from "./host-hooks.js";
7
+ export * from "./no-deft-directive.js";
7
8
  export * from "./org-force-on-migration.js";
8
9
  export * from "./plan-extensions.js";
9
10
  export * from "./policy-invocation.js";
@@ -12,6 +12,7 @@ export * from "./capacity.js";
12
12
  export * from "./decisions.js";
13
13
  export * from "./disclosure.js";
14
14
  export * from "./host-hooks.js";
15
+ export * from "./no-deft-directive.js";
15
16
  export * from "./org-force-on-migration.js";
16
17
  export * from "./plan-extensions.js";
17
18
  export * from "./policy-invocation.js";
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Official per-project opt-out flag for Directive (#2926).
3
+ *
4
+ * Presence of root `.no-deft-directive` means this project does not use
5
+ * Directive. Session ritual, doctor setup pressure, init/update install paths,
6
+ * and setup skills must honor the flag. Empty file or a short comment is enough.
7
+ *
8
+ * Product choices (v1):
9
+ * - Flag is **root-only** (workspace root the tool opened). Nested monorepo
10
+ * package roots are a documented follow-up.
11
+ * - Flag **wins locally** over ambient trusted-org / product-signal force-on.
12
+ * - Flag + deposit (`.deft/core`) is **inconsistent**: doctor **warns**;
13
+ * mutating install/update paths **fail closed**.
14
+ * - Creating the flag does **not** delete an existing deposit.
15
+ */
16
+ /** Canonical root-only filename (lowercase). Presence = flag. */
17
+ export declare const NO_DEFT_DIRECTIVE_FLAG_NAME = ".no-deft-directive";
18
+ /** One-line operator message when Directive is disabled by the flag. */
19
+ export declare const NO_DEFT_DIRECTIVE_DISABLED_MESSAGE = "Directive disabled via `.no-deft-directive`";
20
+ /**
21
+ * Loud diagnosis when both the opt-out flag and a deposit exist.
22
+ * Product choice (#2926): doctor **warns**; install/update **fail closed**.
23
+ */
24
+ export declare const NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE = "Inconsistent state: `.no-deft-directive` is present but a Directive deposit (`.deft/core`) also exists. Remove the flag to use Directive, or remove the deposit if opt-out is intentional.";
25
+ /** Recorded product choice for inconsistent flag+deposit handling. */
26
+ export declare const NO_DEFT_DIRECTIVE_INCONSISTENT_POLICY: "warn-and-fail-closed";
27
+ export interface NoDeftDirectiveSeams {
28
+ readonly isFile?: (path: string) => boolean;
29
+ readonly isDir?: (path: string) => boolean;
30
+ }
31
+ export interface NoDeftDirectiveState {
32
+ readonly present: boolean;
33
+ readonly flagPath: string;
34
+ readonly depositPresent: boolean;
35
+ /** True when flag and deposit are both present. */
36
+ readonly inconsistent: boolean;
37
+ }
38
+ /** Absolute path to the root flag file. */
39
+ export declare function noDeftDirectiveFlagPath(projectRoot: string): string;
40
+ /**
41
+ * Detect root `.no-deft-directive` and whether a deposit is also present.
42
+ * Presence is file-existence only (empty or short comment OK).
43
+ */
44
+ export declare function detectNoDeftDirective(projectRoot: string, seams?: NoDeftDirectiveSeams): NoDeftDirectiveState;
45
+ /** True when the root opt-out flag file exists. */
46
+ export declare function isNoDeftDirectivePresent(projectRoot: string, seams?: NoDeftDirectiveSeams): boolean;
47
+ /**
48
+ * Create the root opt-out flag. Optional one-line rationale becomes a `#` comment.
49
+ * Does not remove an existing deposit.
50
+ */
51
+ export declare function createNoDeftDirectiveFlag(projectRoot: string, options?: {
52
+ rationale?: string;
53
+ }): string;
54
+ /**
55
+ * Remove the root opt-out flag when present.
56
+ * @returns true when a file was removed.
57
+ */
58
+ export declare function removeNoDeftDirectiveFlag(projectRoot: string): boolean;
59
+ //# sourceMappingURL=no-deft-directive.d.ts.map
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Official per-project opt-out flag for Directive (#2926).
3
+ *
4
+ * Presence of root `.no-deft-directive` means this project does not use
5
+ * Directive. Session ritual, doctor setup pressure, init/update install paths,
6
+ * and setup skills must honor the flag. Empty file or a short comment is enough.
7
+ *
8
+ * Product choices (v1):
9
+ * - Flag is **root-only** (workspace root the tool opened). Nested monorepo
10
+ * package roots are a documented follow-up.
11
+ * - Flag **wins locally** over ambient trusted-org / product-signal force-on.
12
+ * - Flag + deposit (`.deft/core`) is **inconsistent**: doctor **warns**;
13
+ * mutating install/update paths **fail closed**.
14
+ * - Creating the flag does **not** delete an existing deposit.
15
+ */
16
+ import { existsSync, statSync, unlinkSync, writeFileSync } from "node:fs";
17
+ import { join, resolve } from "node:path";
18
+ import { assertWriteTargetSafe } from "../fs/projection-containment.js";
19
+ import { CANONICAL_INSTALL_ROOT } from "../init-deposit/constants.js";
20
+ /** Canonical root-only filename (lowercase). Presence = flag. */
21
+ export const NO_DEFT_DIRECTIVE_FLAG_NAME = ".no-deft-directive";
22
+ /** One-line operator message when Directive is disabled by the flag. */
23
+ export const NO_DEFT_DIRECTIVE_DISABLED_MESSAGE = "Directive disabled via `.no-deft-directive`";
24
+ /**
25
+ * Loud diagnosis when both the opt-out flag and a deposit exist.
26
+ * Product choice (#2926): doctor **warns**; install/update **fail closed**.
27
+ */
28
+ export const NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE = "Inconsistent state: `.no-deft-directive` is present but a Directive deposit (`.deft/core`) also exists. Remove the flag to use Directive, or remove the deposit if opt-out is intentional.";
29
+ /** Recorded product choice for inconsistent flag+deposit handling. */
30
+ export const NO_DEFT_DIRECTIVE_INCONSISTENT_POLICY = "warn-and-fail-closed";
31
+ function defaultIsFile(path) {
32
+ try {
33
+ return statSync(path).isFile();
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ function defaultIsDir(path) {
40
+ try {
41
+ return statSync(path).isDirectory();
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
47
+ /** Absolute path to the root flag file. */
48
+ export function noDeftDirectiveFlagPath(projectRoot) {
49
+ return resolve(projectRoot, NO_DEFT_DIRECTIVE_FLAG_NAME);
50
+ }
51
+ /**
52
+ * Detect root `.no-deft-directive` and whether a deposit is also present.
53
+ * Presence is file-existence only (empty or short comment OK).
54
+ */
55
+ export function detectNoDeftDirective(projectRoot, seams = {}) {
56
+ const flagPath = noDeftDirectiveFlagPath(projectRoot);
57
+ const isFile = seams.isFile ?? defaultIsFile;
58
+ const isDir = seams.isDir ?? defaultIsDir;
59
+ const present = isFile(flagPath);
60
+ const depositPresent = isDir(join(projectRoot, CANONICAL_INSTALL_ROOT));
61
+ return {
62
+ present,
63
+ flagPath,
64
+ depositPresent,
65
+ inconsistent: present && depositPresent,
66
+ };
67
+ }
68
+ /** True when the root opt-out flag file exists. */
69
+ export function isNoDeftDirectivePresent(projectRoot, seams = {}) {
70
+ return detectNoDeftDirective(projectRoot, seams).present;
71
+ }
72
+ /**
73
+ * Create the root opt-out flag. Optional one-line rationale becomes a `#` comment.
74
+ * Does not remove an existing deposit.
75
+ */
76
+ export function createNoDeftDirectiveFlag(projectRoot, options = {}) {
77
+ const path = noDeftDirectiveFlagPath(projectRoot);
78
+ assertWriteTargetSafe(projectRoot, path);
79
+ if (defaultIsDir(path)) {
80
+ throw new Error(`${NO_DEFT_DIRECTIVE_FLAG_NAME} exists as a directory at ${path}; remove it before creating the opt-out flag file.`);
81
+ }
82
+ const rationale = options.rationale?.trim() ?? "";
83
+ const body = rationale.length > 0 ? `# ${rationale}\n` : "";
84
+ writeFileSync(path, body, "utf8");
85
+ return path;
86
+ }
87
+ /**
88
+ * Remove the root opt-out flag when present.
89
+ * @returns true when a file was removed.
90
+ */
91
+ export function removeNoDeftDirectiveFlag(projectRoot) {
92
+ const path = noDeftDirectiveFlagPath(projectRoot);
93
+ if (!existsSync(path)) {
94
+ return false;
95
+ }
96
+ assertWriteTargetSafe(projectRoot, path);
97
+ if (defaultIsDir(path)) {
98
+ throw new Error(`${NO_DEFT_DIRECTIVE_FLAG_NAME} exists as a directory at ${path}; remove the directory manually before enabling Directive.`);
99
+ }
100
+ unlinkSync(path);
101
+ return true;
102
+ }
103
+ //# sourceMappingURL=no-deft-directive.js.map
@@ -9,6 +9,10 @@ export interface OrgForceOnMarker {
9
9
  readonly valueFeedback: boolean;
10
10
  readonly productSignal: boolean;
11
11
  readonly directiveVersion: string;
12
+ /** Pre-apply typed valueFeedback snapshot for verify-on-skip (#2903). */
13
+ readonly previousValueFeedback?: unknown;
14
+ /** Pre-apply typed productSignal snapshot for verify-on-skip (#2903). */
15
+ readonly previousProductSignal?: unknown;
12
16
  }
13
17
  /** Canonical typed block written by the one-time migration (#2822 Part A). */
14
18
  export declare const FORCE_ON_VALUE_FEEDBACK_BLOCK: {
@@ -17,8 +21,51 @@ export declare const FORCE_ON_VALUE_FEEDBACK_BLOCK: {
17
21
  readonly sessionLine: true;
18
22
  readonly upstreamPrompt: false;
19
23
  };
24
+ /** Normalize undefined → null and deep-sort keys so marker snapshots compare stably. */
25
+ export declare function normalizePolicySnapshot(raw: unknown): unknown;
26
+ /**
27
+ * Structural equality for policy snapshots (#2903).
28
+ * Key order independent via sortKeysDeep — discarded-PD recovery must not miss
29
+ * semantically equal blocks written with different JSON key order.
30
+ */
31
+ export declare function deepEqualPolicySnapshot(a: unknown, b: unknown): boolean;
20
32
  /** Read the #2822 migration marker when present and valid. */
21
33
  export declare function readOrgForceOnMarker(projectRoot: string): OrgForceOnMarker | null;
34
+ /**
35
+ * Sentinel previous* for an unforced field that had no stored baseline (legacy
36
+ * partial recovery). Must not deep-equal any real policy snapshot — including
37
+ * null/undefined after clear-value-feedback — or the next update would re-force.
38
+ */
39
+ export declare const ORG_FORCE_ON_NO_BASELINE: {
40
+ readonly "x-directive/org-force-on": "no-baseline";
41
+ };
42
+ /** Classic #2822 pre-migration all-false valueFeedback baseline (statusreport). */
43
+ export declare const PRE_MIGRATION_VALUE_FEEDBACK_BASELINE: {
44
+ readonly enabled: false;
45
+ readonly emitEvents: false;
46
+ readonly sessionLine: false;
47
+ readonly upstreamPrompt: false;
48
+ };
49
+ /** True when current VF looks like discarded pre-migration state (legacy markers). */
50
+ export declare function looksLikePreMigrationValueFeedback(raw: unknown): boolean;
51
+ /** True when current PS looks like discarded pre-migration state (legacy markers). */
52
+ export declare function looksLikePreMigrationProductSignal(raw: unknown): boolean;
53
+ /**
54
+ * Incomplete migration (#2903): marker present, PD still needs force-on, and
55
+ * current typed block still equals the pre-migration snapshot (or legacy marker
56
+ * has no previous* field and current still looks like the classic baseline).
57
+ *
58
+ * Exact restore of the pre-migration snapshot is treated as incomplete by design
59
+ * (company policy #2376 / #2822 / #2903): discarded working-tree force-on and
60
+ * "put the old all-false block back" are not distinguishable without git history,
61
+ * and trusted-org local collection should stay ON. True intentional opt-out must
62
+ * use a shape that differs from previous*, `policy:clear-value-feedback`, or
63
+ * root `.no-deft-directive`. Outbound product-signal still requires D17 consent.
64
+ *
65
+ * Legacy markers without previous*: only re-apply fields that still look like the
66
+ * classic pre-migration baseline so a distinct intentional sibling opt-out is kept.
67
+ */
68
+ export declare function isIncompleteForceOnField(needsForceOn: boolean, current: unknown, previousSnapshot: unknown | undefined, previousFieldPresent: boolean, field?: "valueFeedback" | "productSignal"): boolean;
22
69
  export interface RunOrgForceOnMigrationOptions {
23
70
  readonly autoEnable?: OrgAutoEnableOptions;
24
71
  readonly actor?: string;
@@ -33,6 +80,11 @@ export interface OrgForceOnMigrationResult {
33
80
  /**
34
81
  * One-time trusted-org install/upgrade force-on for valueFeedback + productSignal
35
82
  * (#2822). Idempotent via durable marker; non-trusted orgs are unchanged.
83
+ *
84
+ * #2903 verify-on-skip: when the marker is present but PROJECT-DEFINITION still
85
+ * matches the pre-migration snapshot (discarded / never-landed force-on write),
86
+ * re-apply. Intentional post-migration shapes that differ from both the previous
87
+ * snapshot and the force-on block keep the skip.
36
88
  */
37
89
  export declare function runOrgForceOnMigration(projectRoot: string, options?: RunOrgForceOnMigrationOptions): OrgForceOnMigrationResult;
38
90
  /** True when typed valueFeedback matches the #2822 force-on block shape. */