@deftai/directive-core 0.73.0 → 0.74.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 (58) hide show
  1. package/dist/branch/evaluate.js +2 -1
  2. package/dist/check/orchestrator.d.ts +3 -0
  3. package/dist/check/orchestrator.js +2 -1
  4. package/dist/codebase/map.js +2 -0
  5. package/dist/deposit/copy-tree.js +31 -7
  6. package/dist/doctor/checks.d.ts +9 -0
  7. package/dist/doctor/checks.js +67 -0
  8. package/dist/doctor/main.js +2 -1
  9. package/dist/fs/projection-containment.d.ts +35 -0
  10. package/dist/fs/projection-containment.js +118 -0
  11. package/dist/init-deposit/hygiene.d.ts +6 -0
  12. package/dist/init-deposit/hygiene.js +5 -1
  13. package/dist/init-deposit/refresh.d.ts +16 -2
  14. package/dist/init-deposit/refresh.js +144 -50
  15. package/dist/init-deposit/scaffold.d.ts +21 -0
  16. package/dist/init-deposit/scaffold.js +84 -10
  17. package/dist/policy/disclosure.d.ts +1 -0
  18. package/dist/policy/disclosure.js +1 -0
  19. package/dist/policy/index.d.ts +1 -0
  20. package/dist/policy/index.js +1 -0
  21. package/dist/policy/policy-invocation.d.ts +5 -0
  22. package/dist/policy/policy-invocation.js +9 -0
  23. package/dist/policy/resolve.js +4 -2
  24. package/dist/policy/value-feedback.js +6 -3
  25. package/dist/pr-merge-readiness/constants.d.ts +4 -0
  26. package/dist/pr-merge-readiness/constants.js +4 -0
  27. package/dist/pr-merge-readiness/evaluate.js +3 -0
  28. package/dist/pr-merge-readiness/mergeability.d.ts +8 -6
  29. package/dist/pr-merge-readiness/mergeability.js +15 -10
  30. package/dist/pr-merge-readiness/output.js +12 -6
  31. package/dist/pr-merge-readiness/parse.d.ts +1 -0
  32. package/dist/pr-merge-readiness/parse.js +9 -1
  33. package/dist/pr-merge-readiness/types.d.ts +2 -0
  34. package/dist/preflight-cache/evaluate.d.ts +2 -0
  35. package/dist/preflight-cache/evaluate.js +14 -3
  36. package/dist/preflight-cache/index.d.ts +1 -1
  37. package/dist/preflight-cache/index.js +1 -1
  38. package/dist/release/constants.d.ts +2 -0
  39. package/dist/release/constants.js +2 -0
  40. package/dist/release/preflight.d.ts +2 -0
  41. package/dist/release/preflight.js +14 -1
  42. package/dist/scope/index.d.ts +1 -0
  43. package/dist/scope/index.js +1 -0
  44. package/dist/scope/main.js +24 -1
  45. package/dist/scope/open-umbrella-warning.d.ts +12 -0
  46. package/dist/scope/open-umbrella-warning.js +403 -0
  47. package/dist/swarm/constants.d.ts +1 -1
  48. package/dist/swarm/constants.js +2 -1
  49. package/dist/swarm/subagent-backend.js +2 -1
  50. package/dist/triage/bootstrap/gitignore.d.ts +1 -1
  51. package/dist/triage/bootstrap/gitignore.js +69 -58
  52. package/dist/ts-check-lane/run-lane.d.ts +4 -0
  53. package/dist/ts-check-lane/run-lane.js +20 -6
  54. package/dist/value/feedback-file.js +3 -2
  55. package/dist/value/readback.js +3 -2
  56. package/dist/vbrief-reconcile/umbrellas.js +12 -11
  57. package/dist/xbrief-migrate/migrate-project.js +9 -0
  58. package/package.json +3 -3
@@ -1,5 +1,6 @@
1
1
  import { recordBypassSignal, recordGateCatch } from "../events/attribution-ledger.js";
2
2
  import { disclosureLine } from "../policy/disclosure.js";
3
+ import { policyColonInvocation } from "../policy/policy-invocation.js";
3
4
  import { ENV_BYPASS, resolvePolicy } from "../policy/resolve.js";
4
5
  import { currentBranch, GitNotFoundError } from "./git.js";
5
6
  export const DEFAULT_BRANCHES = new Set(["master", "main"]);
@@ -32,7 +33,7 @@ function buildBlockMessage(branch, result) {
32
33
  if (result.deprecationWarning !== null) {
33
34
  parts.push(` Note: ${result.deprecationWarning}`);
34
35
  }
35
- parts.push("", " How to proceed:", " • Create a feature branch: git switch -c feat/<name>", " • Or opt out via the typed surface:", " task policy:allow-direct-commits -- --confirm", ` • Or set the emergency-escape env-var: ${ENV_BYPASS}=1`, "", " See README.md (Branch policy) and skills/deft-directive-setup/", " Phase 2 Step 9 (capability-cost disclosure).");
36
+ parts.push("", " How to proceed:", " • Create a feature branch: git switch -c feat/<name>", " • Or opt out via the typed surface:", ` ${policyColonInvocation("allow-direct-commits", " -- --confirm")}`, ` • Or set the emergency-escape env-var: ${ENV_BYPASS}=1`, "", " See README.md (Branch policy) and skills/deft-directive-setup/", " Phase 2 Step 9 (capability-cost disclosure).");
36
37
  return parts.join("\n");
37
38
  }
38
39
  /**
@@ -19,10 +19,13 @@ export interface CheckOrchestratorSeams {
19
19
  readonly spawnFn?: (cmd: string, args: string[], opts: {
20
20
  cwd: string;
21
21
  stdio: string;
22
+ env?: NodeJS.ProcessEnv;
22
23
  }) => {
23
24
  status: number | null;
24
25
  error?: Error;
25
26
  };
27
+ /** Child-process environment (default: process.env). */
28
+ readonly env?: NodeJS.ProcessEnv;
26
29
  }
27
30
  /**
28
31
  * True when `path` is the directive framework source checkout root (not a
@@ -77,6 +77,7 @@ export function dispatchTaskCheck(frameworkRoot, projectRoot, seams = {}) {
77
77
  const result = spawn(taskBin, [target, "--taskfile", taskfilePath], {
78
78
  cwd,
79
79
  stdio: "inherit",
80
+ env: seams.env,
80
81
  });
81
82
  if (result.error !== undefined) {
82
83
  process.stderr.write(`check: failed to invoke task: ${result.error.message}\n`);
@@ -88,7 +89,7 @@ function defaultSpawn(cmd, args, opts) {
88
89
  const result = spawnSync(cmd, args, {
89
90
  cwd: opts.cwd,
90
91
  stdio: opts.stdio,
91
- env: { ...process.env },
92
+ env: opts.env ?? process.env,
92
93
  });
93
94
  return { status: result.status, error: result.error };
94
95
  }
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { assertProjectionContained } from "../fs/projection-containment.js";
3
4
  import { resolveProjectDefinitionPath } from "../layout/resolve.js";
4
5
  import { extractCodeStructure, loadJsonFile } from "../verify-source/code-structure-validate.js";
5
6
  import { CodeStructureConfigError, configErrorToDict, defaultCodeStructurePath, } from "./default-extractor.js";
@@ -239,6 +240,7 @@ export function writeCodebaseMap(projectRoot, options) {
239
240
  const resolvedOutput = isAbsolute(options.outputPath)
240
241
  ? options.outputPath
241
242
  : join(projectRoot, options.outputPath);
243
+ assertProjectionContained(projectRoot, resolvedOutput);
242
244
  if (!options.force && !isDeftGenerated(resolvedOutput)) {
243
245
  throw new Error(`refusing to overwrite non-generated MAP at ${resolvedOutput}; ` +
244
246
  `missing '${GENERATED_SENTINEL}' banner`);
@@ -7,12 +7,29 @@
7
7
  *
8
8
  * Refs #1942 S1, #1477.
9
9
  */
10
- import { createReadStream, createWriteStream } from "node:fs";
11
- import { chmod, lstat, mkdir, readdir, stat } from "node:fs/promises";
10
+ import { constants } from "node:fs";
11
+ import { lstat, mkdir, open, readdir, readFile, stat } from "node:fs/promises";
12
12
  import { dirname, join } from "node:path";
13
- import { pipeline } from "node:stream/promises";
14
13
  const DEFAULT_FILE_MODE = 0o644;
15
14
  const DEFAULT_DIR_MODE = 0o755;
15
+ async function assertDestinationIsNotSymlink(path) {
16
+ try {
17
+ const info = await lstat(path);
18
+ if (info.isSymbolicLink()) {
19
+ throw new Error(`copyTree: refusing to write through destination symlink ${path}`);
20
+ }
21
+ }
22
+ catch (err) {
23
+ if (err instanceof Error && err.message.startsWith("copyTree: refusing")) {
24
+ throw err;
25
+ }
26
+ if (err.code === "ENOENT") {
27
+ // Missing paths are created by the caller; only pre-existing symlinks matter.
28
+ return;
29
+ }
30
+ throw err;
31
+ }
32
+ }
16
33
  async function copyFilePreserveMode(src, dst) {
17
34
  let mode = DEFAULT_FILE_MODE;
18
35
  try {
@@ -23,12 +40,19 @@ async function copyFilePreserveMode(src, dst) {
23
40
  // Stat failure is non-fatal — fall back to 0o644 (mirrors Go copyFile).
24
41
  }
25
42
  await mkdir(dirname(dst), { recursive: true, mode: DEFAULT_DIR_MODE });
26
- await pipeline(createReadStream(src), createWriteStream(dst, { mode }));
27
- // createWriteStream mode applies on create; chmod ensures the final bits match
28
- // the source even when the destination file already existed.
29
- await chmod(dst, mode);
43
+ await assertDestinationIsNotSymlink(dst);
44
+ const handle = await open(dst, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, mode);
45
+ try {
46
+ await handle.writeFile(await readFile(src));
47
+ // Use the opened handle, not the path, so chmod cannot follow a swapped symlink.
48
+ await handle.chmod(mode);
49
+ }
50
+ finally {
51
+ await handle.close();
52
+ }
30
53
  }
31
54
  async function copyDirContents(src, dst) {
55
+ await assertDestinationIsNotSymlink(dst);
32
56
  await mkdir(dst, { recursive: true, mode: DEFAULT_DIR_MODE });
33
57
  const entries = await readdir(src, { withFileTypes: true });
34
58
  for (const entry of entries) {
@@ -30,6 +30,15 @@ export declare function checkCanonicalVendoredNpmSignpost(projectRoot: string, s
30
30
  * deposit is still a working install, just an unpinned one.
31
31
  */
32
32
  export declare function checkManifestVersionReportable(projectRoot: string, installRoot: string | null, seams?: CheckSeams): CheckResult;
33
+ /** Relative path to the deposited v0.6 schema file superseded by xbrief-core-0.8.schema.json. */
34
+ export declare const STALE_VBRIEF_CORE_SCHEMA_REL: string;
35
+ /**
36
+ * #2368: on an already-migrated xbrief/ layout, a stale deposited
37
+ * `xbrief/schemas/vbrief-core.schema.json` (vBRIEFInfo v0.6) must NOT route
38
+ * operators to `migrate:xbrief` — that verb requires a vbrief/ tree. Point at
39
+ * `directive update` instead. Advisory only (exit-exempt).
40
+ */
41
+ export declare function checkStaleXbriefSchemaDeposit(projectRoot: string, seams?: CheckSeams): CheckResult;
33
42
  /**
34
43
  * #2206: check that the consumer `.gitignore` carries the canonical Deft baseline
35
44
  * entries. Advisory (exit-exempt) because missing entries are an adoption risk, not
@@ -5,6 +5,8 @@ import { detectCanonicalVendoredManifest, isNpmManaged, NPM_MANAGED_SENTINEL_KEY
5
5
  import { resolveLifecycleRoot } from "../layout/resolve.js";
6
6
  import { findSkillPathsInText } from "../text/redos-safe.js";
7
7
  import { stripGitignoreInlineComment } from "../triage/bootstrap/gitignore.js";
8
+ import { LEGACY_INFO_ROOT_KEY, MIGRATED_ARTIFACT_DIR } from "../xbrief-migrate/constants.js";
9
+ import { detectXbriefConvergence } from "../xbrief-migrate/detect.js";
8
10
  import { CANONICAL_UPGRADE_COMMAND, GO_BRIDGE_RELEASES_URL, UPGRADING_DOC_URL, } from "./constants.js";
9
11
  import { isDeprecationRedirectStub, locateManifest, manifestCandidatePaths, manifestReportableVersion, manifestTagToVersion, parseInstallManifest, parseInstallRootFromAgentsMd, parseManifest, } from "./manifest.js";
10
12
  import { readTextSafe } from "./paths.js";
@@ -459,6 +461,68 @@ function collectGitignorePresent(text) {
459
461
  function gitignoreLineIsCovered(present, line) {
460
462
  return present.has(line);
461
463
  }
464
+ /** Relative path to the deposited v0.6 schema file superseded by xbrief-core-0.8.schema.json. */
465
+ export const STALE_VBRIEF_CORE_SCHEMA_REL = join(MIGRATED_ARTIFACT_DIR, "schemas", "vbrief-core.schema.json");
466
+ const MIGRATED_XBRIEF_LAYOUT_STATES = new Set([
467
+ "xbrief-only",
468
+ "xbrief-marker",
469
+ ]);
470
+ /**
471
+ * #2368: on an already-migrated xbrief/ layout, a stale deposited
472
+ * `xbrief/schemas/vbrief-core.schema.json` (vBRIEFInfo v0.6) must NOT route
473
+ * operators to `migrate:xbrief` — that verb requires a vbrief/ tree. Point at
474
+ * `directive update` instead. Advisory only (exit-exempt).
475
+ */
476
+ export function checkStaleXbriefSchemaDeposit(projectRoot, seams = {}) {
477
+ const checkName = "stale-xbrief-schema-deposit";
478
+ const convergence = detectXbriefConvergence(projectRoot);
479
+ if (!MIGRATED_XBRIEF_LAYOUT_STATES.has(convergence.state)) {
480
+ return {
481
+ name: checkName,
482
+ status: "skip",
483
+ detail: "Project is not on a fully migrated xbrief/ layout; stale schema deposit check does not apply.",
484
+ data: { convergence_state: convergence.state },
485
+ };
486
+ }
487
+ const schemaPath = join(projectRoot, STALE_VBRIEF_CORE_SCHEMA_REL);
488
+ const isFile = seams.isFile ?? ((p) => readText(p, seams) !== null);
489
+ if (!isFile(schemaPath)) {
490
+ return {
491
+ name: checkName,
492
+ status: "skip",
493
+ detail: `No deposited schema at ${STALE_VBRIEF_CORE_SCHEMA_REL}.`,
494
+ data: { schema_path: schemaPath },
495
+ };
496
+ }
497
+ const text = readText(schemaPath, seams);
498
+ if (text === null) {
499
+ return {
500
+ name: checkName,
501
+ status: "skip",
502
+ detail: `Deposited schema at ${STALE_VBRIEF_CORE_SCHEMA_REL} is unreadable.`,
503
+ data: { schema_path: schemaPath },
504
+ };
505
+ }
506
+ if (!text.includes(`"${LEGACY_INFO_ROOT_KEY}"`)) {
507
+ return {
508
+ name: checkName,
509
+ status: "pass",
510
+ detail: `Deposited schema at ${STALE_VBRIEF_CORE_SCHEMA_REL} is current (no ${LEGACY_INFO_ROOT_KEY} root key).`,
511
+ data: { schema_path: schemaPath },
512
+ };
513
+ }
514
+ return {
515
+ name: checkName,
516
+ status: "fail",
517
+ detail: `Stale deposited schema at ${STALE_VBRIEF_CORE_SCHEMA_REL} still carries the legacy ${LEGACY_INFO_ROOT_KEY} root key on an already-migrated xbrief/ layout. ` +
518
+ "Run `directive update` to refresh deposited schema files — not `deft migrate:xbrief` (no vbrief/ tree remains to convert).",
519
+ data: {
520
+ schema_path: schemaPath,
521
+ convergence_state: convergence.state,
522
+ suggested_fix: "directive update",
523
+ },
524
+ };
525
+ }
462
526
  /**
463
527
  * #2206: check that the consumer `.gitignore` carries the canonical Deft baseline
464
528
  * entries. Advisory (exit-exempt) because missing entries are an adoption risk, not
@@ -515,6 +579,7 @@ export function deriveExitCode(checks, errors) {
515
579
  "canonical-vendored-npm-signpost",
516
580
  "manifest-version-reportable",
517
581
  "gitignore-coverage",
582
+ "stale-xbrief-schema-deposit",
518
583
  ]);
519
584
  if (errors.length > 0 || checks.some((c) => c.status === "error")) {
520
585
  return 2;
@@ -554,6 +619,7 @@ export function runChecksImpl(projectRoot, seams = {}) {
554
619
  checks.push(checkManifestVersionReportable(projectRoot, null, seams));
555
620
  checks.push(checkLegacyLayout(projectRoot, seams));
556
621
  checks.push(checkCanonicalVendoredNpmSignpost(projectRoot, seams));
622
+ checks.push(checkStaleXbriefSchemaDeposit(projectRoot, seams));
557
623
  checks.push(checkGitignoreCoverage(projectRoot, seams));
558
624
  return {
559
625
  projectRoot,
@@ -570,6 +636,7 @@ export function runChecksImpl(projectRoot, seams = {}) {
570
636
  checks.push(checkInstallPathConsistency(projectRoot, installRoot, seams));
571
637
  checks.push(checkLegacyLayout(projectRoot, seams));
572
638
  checks.push(checkCanonicalVendoredNpmSignpost(projectRoot, seams));
639
+ checks.push(checkStaleXbriefSchemaDeposit(projectRoot, seams));
573
640
  checks.push(checkGitignoreCoverage(projectRoot, seams));
574
641
  return {
575
642
  projectRoot,
@@ -374,7 +374,8 @@ function runInstallIntegrityChecks(projectRoot, sink, addFinding, seams) {
374
374
  if ((name === "legacy-layout" ||
375
375
  name === "canonical-vendored-npm-signpost" ||
376
376
  name === "manifest-version-reportable" ||
377
- name === "gitignore-coverage") &&
377
+ name === "gitignore-coverage" ||
378
+ name === "stale-xbrief-schema-deposit") &&
378
379
  status === "fail") {
379
380
  sink.warn(`${name}: ${detail}`);
380
381
  addFinding({
@@ -0,0 +1,35 @@
1
+ /**
2
+ * projection-containment.ts — repo-controlled projection symlink guard (#2413).
3
+ *
4
+ * Projection writers (`codebase:map`, `triage:bootstrap`) write to well-known
5
+ * paths such as `.planning/codebase/MAP.md`, `.gitignore`, and `.gitattributes`.
6
+ * If a malicious repo author commits one of those paths (or a parent) as a
7
+ * symlink escaping the project tree, routine operator commands would follow the
8
+ * link and create/overwrite files outside the checkout.
9
+ *
10
+ * `assertProjectionContained` anchors on `realpath(projectDir)`, walks each
11
+ * existing component down to the write target, rejects symlinks that escape the
12
+ * tree, and asserts the deepest existing ancestor stays under the project root.
13
+ *
14
+ * Refs #2413.
15
+ */
16
+ /** Non-zero exit code for a projection-containment refusal (needs-action). */
17
+ export declare const PROJECTION_CONTAINMENT_REFUSED_EXIT_CODE = 2;
18
+ /** Thrown when a projection write target escapes the project tree. */
19
+ export declare class ProjectionContainmentError extends Error {
20
+ readonly projectDir: string;
21
+ readonly targetPath: string;
22
+ readonly offendingPath: string;
23
+ constructor(message: string, details: {
24
+ projectDir: string;
25
+ targetPath: string;
26
+ offendingPath: string;
27
+ });
28
+ }
29
+ /**
30
+ * Refuse projection writes when the target (or a parent) is a symlink that
31
+ * escapes the resolved project tree. Throws {@link ProjectionContainmentError};
32
+ * MUST be called BEFORE any projection read/write/mkdir.
33
+ */
34
+ export declare function assertProjectionContained(projectDir: string, targetPath: string): void;
35
+ //# sourceMappingURL=projection-containment.d.ts.map
@@ -0,0 +1,118 @@
1
+ /**
2
+ * projection-containment.ts — repo-controlled projection symlink guard (#2413).
3
+ *
4
+ * Projection writers (`codebase:map`, `triage:bootstrap`) write to well-known
5
+ * paths such as `.planning/codebase/MAP.md`, `.gitignore`, and `.gitattributes`.
6
+ * If a malicious repo author commits one of those paths (or a parent) as a
7
+ * symlink escaping the project tree, routine operator commands would follow the
8
+ * link and create/overwrite files outside the checkout.
9
+ *
10
+ * `assertProjectionContained` anchors on `realpath(projectDir)`, walks each
11
+ * existing component down to the write target, rejects symlinks that escape the
12
+ * tree, and asserts the deepest existing ancestor stays under the project root.
13
+ *
14
+ * Refs #2413.
15
+ */
16
+ import { lstatSync, realpathSync } from "node:fs";
17
+ import { isAbsolute, join, relative, resolve } from "node:path";
18
+ /** Non-zero exit code for a projection-containment refusal (needs-action). */
19
+ export const PROJECTION_CONTAINMENT_REFUSED_EXIT_CODE = 2;
20
+ /** Thrown when a projection write target escapes the project tree. */
21
+ export class ProjectionContainmentError extends Error {
22
+ projectDir;
23
+ targetPath;
24
+ offendingPath;
25
+ constructor(message, details) {
26
+ super(message);
27
+ this.name = "ProjectionContainmentError";
28
+ this.projectDir = details.projectDir;
29
+ this.targetPath = details.targetPath;
30
+ this.offendingPath = details.offendingPath;
31
+ }
32
+ }
33
+ /**
34
+ * Path-SEGMENT containment: is `child` equal to `parent` or nested under it?
35
+ * Uses `path.relative` so `/foo` is NOT treated as containing `/foobar`.
36
+ */
37
+ function isContained(parent, child) {
38
+ if (parent === child) {
39
+ return true;
40
+ }
41
+ const rel = relative(parent, child);
42
+ return rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel);
43
+ }
44
+ /**
45
+ * Refuse projection writes when the target (or a parent) is a symlink that
46
+ * escapes the resolved project tree. Throws {@link ProjectionContainmentError};
47
+ * MUST be called BEFORE any projection read/write/mkdir.
48
+ */
49
+ export function assertProjectionContained(projectDir, targetPath) {
50
+ const projectAbs = resolve(projectDir);
51
+ let projectReal;
52
+ try {
53
+ projectReal = realpathSync(projectAbs);
54
+ }
55
+ catch {
56
+ throw new ProjectionContainmentError(`projection write refused: project directory ${projectAbs} does not exist`, { projectDir: projectAbs, targetPath: resolve(targetPath), offendingPath: projectAbs });
57
+ }
58
+ const targetAbs = resolve(targetPath);
59
+ const rel = relative(projectAbs, targetAbs);
60
+ if (rel.length === 0 || rel.startsWith("..") || isAbsolute(rel)) {
61
+ throw new ProjectionContainmentError(`projection write refused: target ${targetAbs} is not nested under the project tree ${projectAbs}`, { projectDir: projectAbs, targetPath: targetAbs, offendingPath: targetAbs });
62
+ }
63
+ const segments = rel.split(/[\\/]+/).filter((segment) => segment.length > 0);
64
+ let current = projectAbs;
65
+ let deepestExistingReal = projectReal;
66
+ for (const segment of segments) {
67
+ current = join(current, segment);
68
+ let info;
69
+ try {
70
+ info = lstatSync(current);
71
+ }
72
+ catch {
73
+ break;
74
+ }
75
+ if (info.isSymbolicLink()) {
76
+ let linkReal;
77
+ try {
78
+ linkReal = realpathSync(current);
79
+ }
80
+ catch {
81
+ throw new ProjectionContainmentError(`projection write refused: ${current} is a broken/dangling symlink on the projection path`, { projectDir: projectAbs, targetPath: targetAbs, offendingPath: current });
82
+ }
83
+ if (!isContained(projectReal, linkReal)) {
84
+ throw new ProjectionContainmentError(`projection write refused: ${current} is a symlink escaping the project tree ` +
85
+ `(resolves to ${linkReal}, outside ${projectReal})`, { projectDir: projectAbs, targetPath: targetAbs, offendingPath: current });
86
+ }
87
+ deepestExistingReal = linkReal;
88
+ current = linkReal;
89
+ }
90
+ else {
91
+ try {
92
+ deepestExistingReal = realpathSync(current);
93
+ }
94
+ catch (err) {
95
+ throw new ProjectionContainmentError(`projection write refused: could not resolve ${current} on the projection path`, { projectDir: projectAbs, targetPath: targetAbs, offendingPath: current });
96
+ }
97
+ }
98
+ }
99
+ try {
100
+ const targetReal = realpathSync(targetAbs);
101
+ if (!isContained(projectReal, targetReal)) {
102
+ throw new ProjectionContainmentError(`projection write refused: projection target resolves outside the project tree ` +
103
+ `(${targetReal} is outside ${projectReal})`, { projectDir: projectAbs, targetPath: targetAbs, offendingPath: targetReal });
104
+ }
105
+ deepestExistingReal = targetReal;
106
+ }
107
+ catch (err) {
108
+ if (err instanceof ProjectionContainmentError) {
109
+ throw err;
110
+ }
111
+ // Target does not exist yet; parent walk above is sufficient.
112
+ }
113
+ if (!isContained(projectReal, deepestExistingReal)) {
114
+ throw new ProjectionContainmentError(`projection write refused: projection path escapes the project tree ` +
115
+ `(${deepestExistingReal} is outside ${projectReal})`, { projectDir: projectAbs, targetPath: targetAbs, offendingPath: deepestExistingReal });
116
+ }
117
+ }
118
+ //# sourceMappingURL=projection-containment.js.map
@@ -17,6 +17,12 @@ export declare function installerManagedMatchers(): InstallerManagedMatcher[];
17
17
  export declare function installerManagedGuardEre(): string;
18
18
  export declare function isInstallerManagedPath(path: string): boolean;
19
19
  export interface FrameworkStagePathsOptions {
20
+ /**
21
+ * Include the vendored `.deft/core` payload in the stage set. Defaults to
22
+ * `true` for init and real payload swaps. No-op updates set this to `false`
23
+ * so CRLF-only core noise cannot be staged while safe projections are repaired.
24
+ */
25
+ readonly includeCore?: boolean;
20
26
  /**
21
27
  * Include the project-root ``Taskfile.yml`` in the stage set. Defaults to
22
28
  * ``false``: unlike the other allowlisted paths, ``Taskfile.yml`` is a
@@ -82,7 +82,10 @@ export function frameworkStagePaths(projectDir, deftDir, options = {}) {
82
82
  paths.push(normalized);
83
83
  };
84
84
  const relDeft = relative(projectDir, deftDir);
85
- if (relDeft && !relDeft.startsWith("..") && !relDeft.startsWith("/")) {
85
+ if (options.includeCore !== false &&
86
+ relDeft &&
87
+ !relDeft.startsWith("..") &&
88
+ !relDeft.startsWith("/")) {
86
89
  add(relDeft);
87
90
  }
88
91
  for (const matcher of installerManagedMatchers()) {
@@ -228,6 +231,7 @@ function actuallyStagedPaths(stagePaths, cachedNames) {
228
231
  export function depositStagePaths(projectDir, options = {}) {
229
232
  const deftDir = join(projectDir, CANONICAL_INSTALL_ROOT);
230
233
  const stagePaths = frameworkStagePaths(projectDir, deftDir, {
234
+ includeCore: options.includeCore,
231
235
  includeTaskfile: options.includeTaskfile ?? false,
232
236
  });
233
237
  const { staged } = stageFrameworkPaths(projectDir, stagePaths, options);
@@ -22,12 +22,15 @@ export interface RefreshDepositResult {
22
22
  readonly contentVersion: string;
23
23
  readonly engineVersion: string;
24
24
  readonly previousDepositVersion: string | null;
25
+ readonly alreadyCurrent: boolean;
26
+ readonly strategy: RefreshDepositStrategy;
25
27
  readonly agentsMdUpdated: boolean;
26
28
  readonly versionSkewNotice: string | null;
27
29
  readonly legacyLayout: boolean;
28
30
  readonly taskfileWired: boolean;
29
31
  readonly stagedPaths: string[];
30
32
  }
33
+ export type RefreshDepositStrategy = "file-swap" | "no-op";
31
34
  export interface RefreshDepositSeams {
32
35
  resolveContentRoot?: () => Promise<string>;
33
36
  copyContent?: (src: string, dst: string) => Promise<void>;
@@ -35,6 +38,7 @@ export interface RefreshDepositSeams {
35
38
  readEngineVersion?: () => string;
36
39
  nowIso?: () => string;
37
40
  gitPorcelain?: (projectRoot: string) => string | null;
41
+ gitSemanticDiffNames?: GitSemanticDiffNames;
38
42
  detectLegacy?: (projectDir: string) => LegacyLayoutDetection;
39
43
  /**
40
44
  * #2266: `git ls-files` probe threaded into the non-destructive `.gitignore`
@@ -101,9 +105,19 @@ export declare function buildDefaultLadderFacts(projectDir: string, facts: Resol
101
105
  * manual npm/PATH steps (#2266 a3).
102
106
  */
103
107
  export declare function selfHealEngine(projectDir: string, facts: ResolutionFacts, io: InitDepositIo, seams?: SelfHealSeams): EngineResolution;
108
+ export type GitSemanticDiffNames = (projectRoot: string, paths: readonly string[]) => readonly string[] | null;
109
+ export interface FrameworkRefreshSideEffectsOptions {
110
+ readonly readPorcelain?: (root: string) => string | null;
111
+ readonly readSemanticDiffNames?: GitSemanticDiffNames;
112
+ }
113
+ export interface RefreshSideEffects {
114
+ readonly files: string[];
115
+ readonly crlfOnlyCoreFiles: string[];
116
+ readonly payloadSwapped?: boolean;
117
+ }
104
118
  /** Framework-managed uncommitted paths after refresh (#1671). */
105
- export declare function frameworkRefreshSideEffects(projectDir: string, readPorcelain?: (root: string) => string | null): string[];
106
- export declare function printRefreshSideEffects(io: InitDepositIo, files: readonly string[]): void;
119
+ export declare function frameworkRefreshSideEffects(projectDir: string, options?: FrameworkRefreshSideEffectsOptions): RefreshSideEffects;
120
+ export declare function printRefreshSideEffects(io: InitDepositIo, effects: RefreshSideEffects): void;
107
121
  export declare function buildVersionSkewNotice(engineVersion: string, contentVersion: string, previousDepositVersion: string | null): string | null;
108
122
  export declare function buildUpdateSummaryJson(result: RefreshDepositResult, options: RefreshDepositArgs, updateState?: UpdateState): Record<string, unknown>;
109
123
  export declare function printUpdateComplete(result: RefreshDepositResult, io: InitDepositIo, updateState?: UpdateState): void;