@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,9 +1,9 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { assertDepositContained } from "../deposit/contain.js";
4
- import { DIRECT_WRITE_HOOK_MATCHER, SPAWN_HOOK_MATCHER } from "../hooks/tools.js";
4
+ import { DIRECT_WRITE_HOOK_MATCHER, MCP_HOOK_MATCHER, SHELL_HOOK_MATCHER, SPAWN_HOOK_MATCHER, } from "../hooks/tools.js";
5
5
  import { isHostHookDepositEnabled, loadHostHooksPolicyFromProject, } from "../policy/host-hooks.js";
6
- export { DIRECT_WRITE_HOOK_MATCHER, SPAWN_HOOK_MATCHER } from "../hooks/tools.js";
6
+ export { DIRECT_WRITE_HOOK_MATCHER, MCP_HOOK_MATCHER, SHELL_HOOK_MATCHER, SPAWN_HOOK_MATCHER, } from "../hooks/tools.js";
7
7
  export const DEFT_HOOK_COMMAND_MARKER = "deft-hook";
8
8
  export const LEGACY_DEFT_HOOK_COMMAND_MARKER = "deft hook:dispatch";
9
9
  export const AGENT_HOOK_PATHS = [
@@ -103,6 +103,10 @@ function mergeNestedConfig(config, path, host, options = {}) {
103
103
  ...preTool,
104
104
  nestedGroup(host, "tool.before", DIRECT_WRITE_HOOK_MATCHER),
105
105
  nestedGroup(host, "tool.before", SPAWN_HOOK_MATCHER),
106
+ // Shell/Bash for runtimeAuthority scopes.push / scopes.merge (#2711)
107
+ nestedGroup(host, "tool.before", SHELL_HOOK_MATCHER),
108
+ // MCP push/merge (mcp__*, bare merge_pull_request / git_push, …) (#2711)
109
+ nestedGroup(host, "tool.before", MCP_HOOK_MATCHER),
106
110
  ];
107
111
  if (options.compact) {
108
112
  const preCompact = eventArray(hooks, "PreCompact", path).filter((entry) => !isManagedNestedGroup(entry));
@@ -164,6 +168,18 @@ function mergeCursorConfig(config, path) {
164
168
  failClosed: true,
165
169
  timeout: 5,
166
170
  },
171
+ {
172
+ command: command("cursor", "tool.before"),
173
+ matcher: SHELL_HOOK_MATCHER,
174
+ failClosed: true,
175
+ timeout: 5,
176
+ },
177
+ {
178
+ command: command("cursor", "tool.before"),
179
+ matcher: MCP_HOOK_MATCHER,
180
+ failClosed: true,
181
+ timeout: 5,
182
+ },
167
183
  ];
168
184
  hooks.preCompact = [...preCompact, { command: command("cursor", "session.compact"), timeout: 5 }];
169
185
  return { ...config, version: 1, hooks };
@@ -285,6 +301,14 @@ function hasNestedRegistration(config, host, options = {}) {
285
301
  preTool.some((entry) => {
286
302
  const group = object(entry);
287
303
  return group?.matcher === SPAWN_HOOK_MATCHER && nestedCommands(entry).includes(toolCommand);
304
+ }) &&
305
+ preTool.some((entry) => {
306
+ const group = object(entry);
307
+ return group?.matcher === SHELL_HOOK_MATCHER && nestedCommands(entry).includes(toolCommand);
308
+ }) &&
309
+ preTool.some((entry) => {
310
+ const group = object(entry);
311
+ return group?.matcher === MCP_HOOK_MATCHER && nestedCommands(entry).includes(toolCommand);
288
312
  });
289
313
  if (!base)
290
314
  return false;
@@ -315,6 +339,18 @@ function hasCursorRegistration(config) {
315
339
  hook.matcher === SPAWN_HOOK_MATCHER &&
316
340
  hook.failClosed === true);
317
341
  }) &&
342
+ preTool.some((entry) => {
343
+ const hook = object(entry);
344
+ return (hook?.command === command("cursor", "tool.before") &&
345
+ hook.matcher === SHELL_HOOK_MATCHER &&
346
+ hook.failClosed === true);
347
+ }) &&
348
+ preTool.some((entry) => {
349
+ const hook = object(entry);
350
+ return (hook?.command === command("cursor", "tool.before") &&
351
+ hook.matcher === MCP_HOOK_MATCHER &&
352
+ hook.failClosed === true);
353
+ }) &&
318
354
  preCompact.some((entry) => object(entry)?.command === command("cursor", "session.compact")));
319
355
  }
320
356
  /** Read-only registration probe shared by verify and doctor. */
@@ -77,8 +77,24 @@ export interface PrunePackageAbsentDepositPathsResult {
77
77
  * Remove deposit files not shipped by `@deftai/directive-content`, preserving
78
78
  * generated deposit metadata such as `VERSION` (#2804). Subsumes the legacy
79
79
  * hard-coded `packages/` prune (#2347).
80
+ *
81
+ * Individual removal failures are reported but do not throw — callers that must
82
+ * refuse a VERSION stamp until the deposit matches the content package should
83
+ * use {@link reconcileDepositToContentPackage} (#2913).
80
84
  */
81
85
  export declare function prunePackageAbsentDepositPaths(deftDir: string, contentRoot: string, io: InitDepositIo): Promise<PrunePackageAbsentDepositPathsResult>;
86
+ /**
87
+ * Fail-closed deposit reconcile against the installed content package (#2913).
88
+ *
89
+ * Runs {@link prunePackageAbsentDepositPaths}, then re-scans. If any
90
+ * package-absent path remains, throws so callers refuse the VERSION stamp
91
+ * (dst-only stale/malicious agent content must not survive a refresh).
92
+ *
93
+ * After a successful {@link replaceTree} full-swap this is typically a no-op
94
+ * verification; it also covers additive copy seams and the already-current
95
+ * refresh path that skips payload copy.
96
+ */
97
+ export declare function reconcileDepositToContentPackage(deftDir: string, contentRoot: string, io: InitDepositIo): Promise<PrunePackageAbsentDepositPathsResult>;
82
98
  export interface PruneStrayDepositPathsResult {
83
99
  readonly pruned: string[];
84
100
  }
@@ -257,6 +257,10 @@ async function pruneEmptyParentsForFile(deftDir, contentDirs, relFile) {
257
257
  * Remove deposit files not shipped by `@deftai/directive-content`, preserving
258
258
  * generated deposit metadata such as `VERSION` (#2804). Subsumes the legacy
259
259
  * hard-coded `packages/` prune (#2347).
260
+ *
261
+ * Individual removal failures are reported but do not throw — callers that must
262
+ * refuse a VERSION stamp until the deposit matches the content package should
263
+ * use {@link reconcileDepositToContentPackage} (#2913).
260
264
  */
261
265
  export async function prunePackageAbsentDepositPaths(deftDir, contentRoot, io) {
262
266
  const absent = await findPackageAbsentDepositPaths(deftDir, contentRoot);
@@ -278,6 +282,28 @@ export async function prunePackageAbsentDepositPaths(deftDir, contentRoot, io) {
278
282
  }
279
283
  return { pruned, prunedDirs };
280
284
  }
285
+ /**
286
+ * Fail-closed deposit reconcile against the installed content package (#2913).
287
+ *
288
+ * Runs {@link prunePackageAbsentDepositPaths}, then re-scans. If any
289
+ * package-absent path remains, throws so callers refuse the VERSION stamp
290
+ * (dst-only stale/malicious agent content must not survive a refresh).
291
+ *
292
+ * After a successful {@link replaceTree} full-swap this is typically a no-op
293
+ * verification; it also covers additive copy seams and the already-current
294
+ * refresh path that skips payload copy.
295
+ */
296
+ export async function reconcileDepositToContentPackage(deftDir, contentRoot, io) {
297
+ const result = await prunePackageAbsentDepositPaths(deftDir, contentRoot, io);
298
+ const remaining = await findPackageAbsentDepositPaths(deftDir, contentRoot);
299
+ if (remaining.length > 0) {
300
+ const sample = remaining.slice(0, 5).join(", ");
301
+ const more = remaining.length > 5 ? ` (+${remaining.length - 5} more)` : "";
302
+ throw new Error(`deposit reconcile failed: ${remaining.length} package-absent path(s) remain under .deft/core ` +
303
+ `(e.g. ${sample}${more}). Refusing VERSION stamp until dst-only content is removed (#2913).`);
304
+ }
305
+ return result;
306
+ }
281
307
  /**
282
308
  * Remove framework-source subdirectories from `.deft/core/` that are not
283
309
  * present in the deposited content package (#2347). Silently skips any path
@@ -21,6 +21,7 @@
21
21
  * dispatcher never re-implements deposit, refresh, or migrate behavior.
22
22
  */
23
23
  import { resolve } from "node:path";
24
+ 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";
24
25
  import { classify, plan } from "../resolution/index.js";
25
26
  import { runInitDepositCli } from "./init-deposit.js";
26
27
  import { runMigrateCli } from "./migrate.js";
@@ -119,6 +120,33 @@ export async function runInitDispatchCli(options) {
119
120
  options.writeOut(text);
120
121
  },
121
122
  };
123
+ // #2926: root opt-out — do not scaffold/install/refresh when flag is present.
124
+ const optOut = detectNoDeftDirective(projectDir);
125
+ if (optOut.present) {
126
+ io.printf(`\n[directive init] ${NO_DEFT_DIRECTIVE_DISABLED_MESSAGE}\n`);
127
+ if (optOut.inconsistent) {
128
+ io.printf(`[directive init] ${NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE}\n`);
129
+ }
130
+ if (options.jsonOut) {
131
+ options.writeOut(`${JSON.stringify({
132
+ success: false,
133
+ action: "init",
134
+ disabled: true,
135
+ disabled_via: NO_DEFT_DIRECTIVE_FLAG_NAME,
136
+ inconsistent: optOut.inconsistent,
137
+ ...(optOut.inconsistent
138
+ ? { inconsistent_policy: NO_DEFT_DIRECTIVE_INCONSISTENT_POLICY }
139
+ : {}),
140
+ deposit_present: optOut.depositPresent,
141
+ project_dir: projectDir,
142
+ message: optOut.inconsistent
143
+ ? NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE
144
+ : NO_DEFT_DIRECTIVE_DISABLED_MESSAGE,
145
+ }, null, 2)}\n`);
146
+ }
147
+ // Clean opt-out is a successful short-circuit; inconsistent state fails closed.
148
+ return optOut.inconsistent ? 1 : 0;
149
+ }
122
150
  const classification = classifyInitDispatch(projectDir, seams.classifySeams ?? {});
123
151
  const { decision, plan: resolutionPlan } = classification;
124
152
  printInitDispatchSummary(io, decision, resolutionPlan);
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
11
11
  import { join } from "node:path";
12
- import { assertProjectionContained } from "../fs/projection-containment.js";
12
+ import { assertDestinationNotSymlink } from "../fs/projection-containment.js";
13
13
  import { stripGitignoreInlineComment } from "../triage/bootstrap/gitignore.js";
14
14
  /** Directory ignore entry for the hybrid deposit. */
15
15
  export const PRETTIERIGNORE_DEFT_CORE_LINE = ".deft/core/";
@@ -18,7 +18,7 @@ const DEFT_CORE_COVERING_LINES = new Set([".deft/core/", ".deft/core", ".deft/co
18
18
  const DEFT_FRAMEWORK_PRETTIERIGNORE_HEADER = "# Deft framework: the vendored payload is outside the consumer Prettier gate (#2534).\n";
19
19
  function projectionTarget(projectDir) {
20
20
  const target = join(projectDir, ".prettierignore");
21
- assertProjectionContained(projectDir, target);
21
+ assertDestinationNotSymlink(projectDir, target);
22
22
  return target;
23
23
  }
24
24
  function prettierIgnoreCoversLine(present, line) {
@@ -12,19 +12,20 @@ import { existsSync, readFileSync, renameSync, statSync } from "node:fs";
12
12
  import { platform as osPlatform } from "node:os";
13
13
  import { join, resolve } from "node:path";
14
14
  import { assertDepositContained } from "../deposit/contain.js";
15
- import { copyTree } from "../deposit/copy-tree.js";
15
+ import { replaceTree } from "../deposit/copy-tree.js";
16
16
  import { prunePythonArtifactsFromDeposit } from "../deposit/python-free.js";
17
17
  import { resolveInstalledContentRoot } from "../deposit/resolve-content.js";
18
18
  import { manifestTagToVersion, parseInstallManifest } from "../doctor/manifest.js";
19
19
  import { readCorePackageVersion } from "../engine-version.js";
20
20
  import { resolveLifecycleRoot } from "../layout/resolve.js";
21
+ 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";
21
22
  import { runOrgForceOnMigration } from "../policy/org-force-on-migration.js";
22
23
  import { checkLocalEngineIntegrity, classify, ENGINE_PACKAGE, plan, renderGlobalInstall, resolveEngine, } from "../resolution/index.js";
23
24
  import { gitPorcelain } from "../story-ready/git.js";
24
25
  import { removeStaleMigratedFrameworkNarrative } from "../xbrief-migrate/migrate-project.js";
25
26
  import { writeAgentHookDeposit } from "./agent-hooks.js";
26
27
  import { ensureInitGitignoreLines, isDepositTrackedInGit } from "./gitignore.js";
27
- import { depositStagePaths, isInstallerManagedPath, printCommitGuidance, prunePackageAbsentDepositPaths, } from "./hygiene.js";
28
+ import { depositStagePaths, isInstallerManagedPath, printCommitGuidance, reconcileDepositToContentPackage, } from "./hygiene.js";
28
29
  import { parseInitArgv } from "./init-deposit.js";
29
30
  import { buildLegacyRefusalJson, buildLegacyRefusalMessage, detectLegacyLayout, LEGACY_LAYOUT_REFUSED_EXIT_CODE, LegacyLayoutRefusedError, } from "./legacy-detect.js";
30
31
  import { printMigrateNudgeIfNeeded } from "./migrate.js";
@@ -392,7 +393,9 @@ export async function runRefreshDeposit(args, io, seams = {}) {
392
393
  // the resolved project tree. Writes nothing on refusal.
393
394
  assertDepositContained(projectDir, deftDir);
394
395
  const resolveContent = seams.resolveContentRoot ?? resolveInstalledContentRoot;
395
- const copyContent = seams.copyContent ?? copyTree;
396
+ // #2913: default is full-tree replace (Go swapInCore parity), not additive copyTree.
397
+ // Injected seams.copyContent still wins (tests / specialized callers).
398
+ const copyContent = seams.copyContent ?? replaceTree;
396
399
  const readEngine = seams.readEngineVersion ?? readCorePackageVersion;
397
400
  const readPackageVersion = seams.readPackageVersion ?? readCorePackageVersion;
398
401
  const contentRoot = await resolveContent();
@@ -406,15 +409,19 @@ export async function runRefreshDeposit(args, io, seams = {}) {
406
409
  const strategy = alreadyCurrent ? "no-op" : "file-swap";
407
410
  if (alreadyCurrent) {
408
411
  io.printf("[deft update] Framework payload already current; skipping payload copy.\n");
412
+ // #2913: still fail-closed reconcile so dst-only leftovers cannot linger when
413
+ // VERSION already matches (e.g. pre-#2804 additive deposits). Does not re-stamp.
414
+ await reconcileDepositToContentPackage(deftDir, contentRoot, io);
409
415
  migrateLegacyInstallManifest(projectDir, join(deftDir, "VERSION"));
410
416
  }
411
417
  else {
418
+ // Full-tree replace (or injected seam). Additive copy is no longer the default.
412
419
  await copyContent(contentRoot, deftDir);
413
420
  await prunePythonArtifactsFromDeposit(deftDir, projectDir, io);
414
- // #2804 / #2347: prune deposit paths absent from the content package. The
415
- // additive file-swap never removes them, causing stale bridge-era files to
416
- // survive across upgrades until manually cleaned.
417
- await prunePackageAbsentDepositPaths(deftDir, contentRoot, io);
421
+ // #2913 / #2804 / #2347: fail-closed delete-not-in-source BEFORE VERSION stamp.
422
+ // replaceTree already drops dst-only paths; reconcile verifies and covers
423
+ // additive seams. Throws => no VERSION rewrite (refuse stamp until clean).
424
+ await reconcileDepositToContentPackage(deftDir, contentRoot, io);
418
425
  const nowIso = seams.nowIso ?? (() => new Date().toISOString().replace(/\.\d{3}Z$/, "Z"));
419
426
  const manifestFields = {
420
427
  ref: contentVersion.startsWith("v") ? contentVersion : `v${contentVersion}`,
@@ -586,6 +593,30 @@ export async function runRefreshDepositCli(options) {
586
593
  // Legacy layouts keep their existing refusal path (thrown by runRefreshDeposit
587
594
  // and handled below), so classification is skipped for them.
588
595
  const projectDir = resolve(options.projectDir);
596
+ // #2926: root opt-out wins over update/refresh and ambient force-on.
597
+ const optOut = detectNoDeftDirective(projectDir);
598
+ if (optOut.present) {
599
+ const message = optOut.inconsistent
600
+ ? NO_DEFT_DIRECTIVE_INCONSISTENT_MESSAGE
601
+ : NO_DEFT_DIRECTIVE_DISABLED_MESSAGE;
602
+ io.printf(`${message}\n`);
603
+ if (options.jsonOut) {
604
+ options.writeOut(`${JSON.stringify({
605
+ success: false,
606
+ action: "update",
607
+ disabled: true,
608
+ disabled_via: NO_DEFT_DIRECTIVE_FLAG_NAME,
609
+ inconsistent: optOut.inconsistent,
610
+ ...(optOut.inconsistent
611
+ ? { inconsistent_policy: NO_DEFT_DIRECTIVE_INCONSISTENT_POLICY }
612
+ : {}),
613
+ deposit_present: optOut.depositPresent,
614
+ project_dir: projectDir,
615
+ message,
616
+ }, null, 2)}\n`);
617
+ }
618
+ return UPDATE_REFUSED_EXIT_CODE;
619
+ }
589
620
  const detectLegacy = options.seams?.detectLegacy ?? detectLegacyLayout;
590
621
  let classification = null;
591
622
  if (!detectLegacy(projectDir).legacy) {
@@ -9,7 +9,7 @@ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync,
9
9
  import { mkdir, readdir, rm, stat, writeFile } from "node:fs/promises";
10
10
  import { platform } from "node:os";
11
11
  import { dirname, join, relative } from "node:path";
12
- import { assertProjectionContained, ProjectionContainmentError, } from "../fs/projection-containment.js";
12
+ import { assertDestinationNotSymlink, ProjectionContainmentError, } from "../fs/projection-containment.js";
13
13
  import { agentsRefreshPlan } from "../platform/agents-md.js";
14
14
  import { MIGRATED_ARTIFACT_DIR } from "../xbrief-migrate/constants.js";
15
15
  import { CANONICAL_INSTALL_ROOT } from "./constants.js";
@@ -17,10 +17,14 @@ import { installerManagedGuardEre } from "./hygiene.js";
17
17
  import { syncConsumerXbriefSchemas } from "./xbrief-projections.js";
18
18
  export { CANONICAL_INSTALL_ROOT };
19
19
  export const CORE_GLOB = ".deft/core/**";
20
- /** Refuse init/update projection writes that escape via repo-controlled symlinks (#2446). */
20
+ /**
21
+ * Refuse init/update projection writes that escape via repo-controlled symlinks
22
+ * (#2446) OR that would follow an in-tree destination symlink on the write path
23
+ * (#2912). Every consumer projection sink in this module routes through here.
24
+ */
21
25
  function projectionTarget(projectDir, ...relSegments) {
22
26
  const target = join(projectDir, ...relSegments);
23
- assertProjectionContained(projectDir, target);
27
+ assertDestinationNotSymlink(projectDir, target);
24
28
  return target;
25
29
  }
26
30
  const CODEQL_CONFIG_REL = ".github/codeql/codeql-config.yml";
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
9
9
  import { join, relative } from "node:path";
10
- import { assertProjectionContained } from "../fs/projection-containment.js";
10
+ import { assertDestinationNotSymlink } from "../fs/projection-containment.js";
11
11
  import { resolveLifecycleRoot } from "../layout/resolve.js";
12
12
  import { DEV_FALLBACK } from "../platform/constants.js";
13
13
  import { MIGRATED_ARTIFACT_DIR } from "../xbrief-migrate/constants.js";
@@ -54,7 +54,7 @@ export function rewriteProjectedSchemaContent(content) {
54
54
  * Upstream vbrief/schemas/ may keep legacy paths; consumer copies must not (#2670).
55
55
  */
56
56
  export function assertProjectedSchemaDescriptionsRooted(projectDir, destinationDir) {
57
- assertProjectionContained(projectDir, destinationDir);
57
+ assertDestinationNotSymlink(projectDir, destinationDir);
58
58
  if (!isDirectory(destinationDir))
59
59
  return;
60
60
  for (const rel of collectSchemaFiles(destinationDir)) {
@@ -66,7 +66,7 @@ export function assertProjectedSchemaDescriptionsRooted(projectDir, destinationD
66
66
  }
67
67
  }
68
68
  function writeFileIfChanged(projectDir, target, content) {
69
- assertProjectionContained(projectDir, target);
69
+ assertDestinationNotSymlink(projectDir, target);
70
70
  const desired = Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8");
71
71
  try {
72
72
  if (readFileSync(target).equals(desired))
@@ -91,7 +91,7 @@ export function syncConsumerXbriefSchemas(projectDir, deftDir) {
91
91
  throw new Error(`cannot project xbrief schemas: framework payload is missing ${CURRENT_CORE_SCHEMA}`);
92
92
  }
93
93
  const destinationDir = join(projectDir, MIGRATED_ARTIFACT_DIR, "schemas");
94
- assertProjectionContained(projectDir, destinationDir);
94
+ assertDestinationNotSymlink(projectDir, destinationDir);
95
95
  mkdirSync(destinationDir, { recursive: true });
96
96
  let changed = false;
97
97
  for (const rel of collectSchemaFiles(sourceDir)) {
@@ -103,7 +103,7 @@ export function syncConsumerXbriefSchemas(projectDir, deftDir) {
103
103
  changed = writeFileIfChanged(projectDir, destination, projected) || changed;
104
104
  }
105
105
  const obsoleteDestination = join(destinationDir, OBSOLETE_CORE_SCHEMA);
106
- assertProjectionContained(projectDir, obsoleteDestination);
106
+ assertDestinationNotSymlink(projectDir, obsoleteDestination);
107
107
  if (existsSync(obsoleteDestination)) {
108
108
  rmSync(obsoleteDestination, { force: true });
109
109
  changed = true;
@@ -117,7 +117,7 @@ function syncBareVersionMarkerWithPolicy(projectDir, version, allowRootFallback)
117
117
  return false;
118
118
  const canonicalRoot = join(projectDir, MIGRATED_ARTIFACT_DIR);
119
119
  if (existsSync(canonicalRoot)) {
120
- assertProjectionContained(projectDir, join(canonicalRoot, ".deft-version"));
120
+ assertDestinationNotSymlink(projectDir, join(canonicalRoot, ".deft-version"));
121
121
  }
122
122
  let targetDir = projectDir;
123
123
  try {
@@ -1,11 +1,51 @@
1
1
  import type { ScmCallFn } from "./reconcile-issues.js";
2
2
  export declare const GITHUB_ISSUE_REF_TYPE = "x-xbrief/github-issue";
3
3
  export declare const EXTERNAL_TRUST_LEVEL = "external";
4
+ /**
5
+ * Structured post-create failure: remote issue exists; local stamp/ledger may not.
6
+ * `createdUrl` is the durable handle for retry (also mirrored in process + OS-temp recovery).
7
+ */
4
8
  export declare class IssueEmitError extends Error {
5
- constructor(message: string);
9
+ readonly createdUrl?: string;
10
+ constructor(message: string, options?: {
11
+ createdUrl?: string;
12
+ });
6
13
  }
14
+ export declare function recoverySidecarPath(vbriefAbsPath: string): string;
15
+ /**
16
+ * Always-on recovery after remote create: process memory + private OS-temp sidecar.
17
+ * Independent of project-contained ledger so dual local failure still reconciles on retry (#2880).
18
+ */
19
+ export declare function rememberCreatedUrl(vbriefAbsPath: string, url: string): void;
20
+ export declare function loadRecoveredUrl(vbriefAbsPath: string): string | undefined;
21
+ export declare function clearRecoveredUrl(vbriefAbsPath: string): void;
22
+ /** Project ledger first, then process/OS-temp recovery (#2880). */
23
+ export declare function resolvePriorCreatedUrl(projectRoot: string, vbriefAbsPath: string): string | undefined;
24
+ /**
25
+ * Record URL immediately after remote create. Project ledger is best-effort;
26
+ * recovery layers always run first so dual local failure cannot force re-create (#2880).
27
+ */
28
+ export declare function recordCreatedUrlDurable(projectRoot: string, vbriefAbsPath: string, url: string): void;
29
+ /** Contained durable map: abs vbrief path -> issue URL for in-flight emits (#2871). */
30
+ export declare function pendingEmitLedgerPath(projectRoot: string): string;
31
+ export declare function loadPendingEmitUrls(projectRoot: string): Record<string, string>;
32
+ export declare function savePendingEmitUrl(projectRoot: string, vbriefAbsPath: string, url: string): void;
33
+ export declare function clearPendingEmitUrl(projectRoot: string, vbriefAbsPath: string): void;
7
34
  export declare function loadVbrief(path: string): Record<string, unknown>;
8
- export declare function writeVbrief(path: string, data: Record<string, unknown>): void;
35
+ /**
36
+ * Resolve project root and refuse unsafe write targets before any emit side-effect.
37
+ * Callers that create remote issues MUST invoke this before `fileIssue` so a
38
+ * containment refusal cannot leave an orphan GitHub issue (#2869 / #2871).
39
+ *
40
+ * ⊗ Do not fall back to `dirname(path)` as the containment root — that trusts a
41
+ * possibly-symlinked parent and would re-open the escape (Greptile P1 on #2871).
42
+ */
43
+ export declare function assertVbriefWriteTargetSafe(path: string, projectRoot?: string | null): string;
44
+ /**
45
+ * Persist an xBRIEF/vBRIEF JSON document. Gates the write target so a leaf or
46
+ * parent-directory symlink cannot divert the stamped file outside the project (#2869).
47
+ */
48
+ export declare function writeVbrief(path: string, data: Record<string, unknown>, projectRoot?: string | null): void;
9
49
  export declare function vbriefTitle(data: Record<string, unknown>): string;
10
50
  export declare function existingGithubIssueRef(data: Record<string, unknown>): string | null | undefined;
11
51
  export declare function addGithubIssueReference(data: Record<string, unknown>, url: string): Record<string, unknown>;
@@ -23,12 +63,14 @@ export declare function emitSingle(path: string, options: {
23
63
  scmCall?: ScmCallFn;
24
64
  noNetwork?: boolean;
25
65
  displayPath?: string | null;
66
+ projectRoot?: string | null;
26
67
  }): EmitAction;
27
68
  export declare function emitPerVbrief(paths: string[], options: {
28
69
  repo: string;
29
70
  scmCall?: ScmCallFn;
30
71
  noNetwork?: boolean;
31
72
  displayPaths?: string[] | null;
73
+ projectRoot?: string | null;
32
74
  }): EmitAction[];
33
75
  export interface UmbrellaAction {
34
76
  result: "created" | "dryrun" | "skipped";
@@ -45,6 +87,7 @@ export declare function emitUmbrella(paths: string[], options: {
45
87
  noNetwork?: boolean;
46
88
  title?: string | null;
47
89
  displayPaths?: string[] | null;
90
+ projectRoot?: string | null;
48
91
  }): UmbrellaAction;
49
92
  export declare function expandPatterns(patterns: string[], root?: string | null): string[];
50
93
  export declare function isNoNetwork(dryRun: boolean): boolean;