@deftai/directive-core 0.73.0 → 0.73.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  /**
@@ -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) {
@@ -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;
@@ -7,6 +7,7 @@
7
7
  *
8
8
  * Refs #1942, #1430, #1671.
9
9
  */
10
+ import { execFileSync } from "node:child_process";
10
11
  import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
11
12
  import { platform as osPlatform } from "node:os";
12
13
  import { join, resolve } from "node:path";
@@ -218,11 +219,12 @@ function unquoteGitPath(path) {
218
219
  }
219
220
  return path;
220
221
  }
221
- function porcelainStatusPaths(porcelain) {
222
- const paths = [];
222
+ function porcelainStatusEntries(porcelain) {
223
+ const entries = [];
223
224
  for (const line of porcelain.split("\n")) {
224
225
  if (line.length < 4)
225
226
  continue;
227
+ const status = line.slice(0, 2);
226
228
  let rest = line.slice(3);
227
229
  const arrow = rest.indexOf(" -> ");
228
230
  if (arrow >= 0)
@@ -230,43 +232,102 @@ function porcelainStatusPaths(porcelain) {
230
232
  const trimmed = unquoteGitPath(rest.trim());
231
233
  if (!trimmed)
232
234
  continue;
233
- paths.push(trimmed.replace(/\\/g, "/"));
235
+ entries.push({ status, path: trimmed.replace(/\\/g, "/") });
234
236
  }
235
- return paths;
237
+ return entries;
236
238
  }
237
- function classifyChangedPaths(changed) {
239
+ function classifyChangedEntries(changed) {
238
240
  const core = [];
239
241
  const installerManaged = [];
240
- for (const path of changed) {
242
+ for (const entry of changed) {
243
+ const { path } = entry;
241
244
  if (!path)
242
245
  continue;
243
246
  if (path.startsWith(".deft/core/") || path === ".deft/core") {
244
- core.push(path);
247
+ core.push(entry);
245
248
  }
246
249
  else if (isInstallerManagedPath(path)) {
247
- installerManaged.push(path);
250
+ installerManaged.push(entry);
248
251
  }
249
252
  }
250
253
  return { core, installerManaged };
251
254
  }
255
+ function isCrlfNoiseCandidate(status) {
256
+ return status.includes("M") && /^[ M]+$/.test(status);
257
+ }
258
+ function normalizeGitNameList(output) {
259
+ return output
260
+ .split("\n")
261
+ .map((line) => line.trim().replace(/\\/g, "/"))
262
+ .filter(Boolean);
263
+ }
264
+ function gitSemanticDiffNames(projectRoot, paths) {
265
+ if (paths.length === 0)
266
+ return [];
267
+ try {
268
+ const args = ["diff", "--ignore-cr-at-eol", "--name-only", "--", ...paths];
269
+ const worktree = execFileSync("git", args, {
270
+ cwd: projectRoot,
271
+ encoding: "utf8",
272
+ stdio: ["ignore", "pipe", "pipe"],
273
+ });
274
+ const cached = execFileSync("git", ["diff", "--cached", "--ignore-cr-at-eol", "--name-only", "--", ...paths], {
275
+ cwd: projectRoot,
276
+ encoding: "utf8",
277
+ stdio: ["ignore", "pipe", "pipe"],
278
+ });
279
+ return [...new Set([...normalizeGitNameList(worktree), ...normalizeGitNameList(cached)])];
280
+ }
281
+ catch {
282
+ return null;
283
+ }
284
+ }
252
285
  /** Framework-managed uncommitted paths after refresh (#1671). */
253
- export function frameworkRefreshSideEffects(projectDir, readPorcelain = gitPorcelain) {
286
+ export function frameworkRefreshSideEffects(projectDir, options) {
287
+ const readPorcelain = options?.readPorcelain ?? gitPorcelain;
288
+ const readSemanticDiffNames = options?.readSemanticDiffNames ?? gitSemanticDiffNames;
254
289
  const porcelain = readPorcelain(projectDir);
255
290
  if (porcelain === null)
256
- return [];
257
- const changed = porcelainStatusPaths(porcelain);
258
- const { core, installerManaged } = classifyChangedPaths(changed);
259
- const files = [...core, ...installerManaged].sort();
260
- return files.length > 0 ? files : [];
291
+ return { files: [], crlfOnlyCoreFiles: [] };
292
+ const changed = porcelainStatusEntries(porcelain);
293
+ const { core, installerManaged } = classifyChangedEntries(changed);
294
+ const semanticCoreNames = core.length > 0
295
+ ? readSemanticDiffNames(projectDir, core.map((entry) => entry.path))
296
+ : [];
297
+ const semanticCoreSet = semanticCoreNames === null ? null : new Set(semanticCoreNames);
298
+ const coreFiles = [];
299
+ const crlfOnlyCoreFiles = [];
300
+ for (const entry of core) {
301
+ if (semanticCoreSet !== null &&
302
+ isCrlfNoiseCandidate(entry.status) &&
303
+ !semanticCoreSet.has(entry.path)) {
304
+ crlfOnlyCoreFiles.push(entry.path);
305
+ continue;
306
+ }
307
+ coreFiles.push(entry.path);
308
+ }
309
+ const files = [...coreFiles, ...installerManaged.map((entry) => entry.path)].sort();
310
+ return { files, crlfOnlyCoreFiles: crlfOnlyCoreFiles.sort() };
261
311
  }
262
- export function printRefreshSideEffects(io, files) {
263
- if (files.length === 0)
312
+ export function printRefreshSideEffects(io, effects) {
313
+ if (effects.crlfOnlyCoreFiles.length > 0) {
314
+ io.printf("\nWindows line-ending note (#2118): suppressed .deft/core CRLF/LF-only noise; " +
315
+ "ensure .gitattributes contains `.deft/core/** text eol=lf`.\n");
316
+ }
317
+ if (effects.files.length === 0)
318
+ return;
319
+ if (effects.payloadSwapped === false) {
320
+ io.printf("\nFramework-managed files still have semantic uncommitted changes:\n");
321
+ for (const file of effects.files) {
322
+ io.printf(` ${file}\n`);
323
+ }
264
324
  return;
325
+ }
265
326
  io.printf("\nAGENTS.md refresh side effects (#1671): the refresh and framework payload swap\n");
266
327
  io.printf("left these framework files with uncommitted changes -- they belong in the\n");
267
328
  io.printf("framework deposit commit (the installer stages them before printing the\n");
268
329
  io.printf("`git add` list below, so there are no post-stage stragglers):\n");
269
- for (const file of files) {
330
+ for (const file of effects.files) {
270
331
  io.printf(` ${file}\n`);
271
332
  }
272
333
  }
@@ -307,7 +368,8 @@ export function buildUpdateSummaryJson(result, options, updateState) {
307
368
  user_config_dir: "",
308
369
  skills_created: false,
309
370
  payload_layout: "vendored",
310
- strategy: "file-swap",
371
+ strategy: result.strategy,
372
+ already_current: result.alreadyCurrent,
311
373
  dirty_tree: false,
312
374
  dirty_files: [],
313
375
  staged_paths: result.stagedPaths,
@@ -319,9 +381,12 @@ export function buildUpdateSummaryJson(result, options, updateState) {
319
381
  };
320
382
  }
321
383
  export function printUpdateComplete(result, io, updateState) {
322
- io.printf("\n✓ Deft framework payload refreshed.\n\n");
384
+ io.printf(result.alreadyCurrent
385
+ ? "\nOK Deft framework payload already current.\n\n"
386
+ : "\nOK Deft framework payload refreshed.\n\n");
323
387
  io.printf(` Location : ${result.deftDir}\n`);
324
388
  io.printf(` Content : v${normalizeVersion(result.contentVersion)}\n`);
389
+ io.printf(` Strategy : ${result.strategy}\n`);
325
390
  if (updateState) {
326
391
  io.printf(` State : ${updateState}\n`);
327
392
  }
@@ -356,31 +421,41 @@ export async function runRefreshDeposit(args, io, seams = {}) {
356
421
  const engineVersion = readEngine();
357
422
  const contentVersion = readContentPackageVersion(contentRoot, readPackageVersion);
358
423
  const versionSkewNotice = buildVersionSkewNotice(engineVersion, contentVersion, previousDepositVersion);
359
- await copyContent(contentRoot, deftDir);
360
- await prunePythonArtifactsFromDeposit(deftDir, projectDir, io);
361
- // #2347: prune framework-source paths that the content package does not ship.
362
- // The additive file-swap never removes them, causing the deposit-hygiene
363
- // advisory to persist across every upgrade until manually cleaned.
364
- await pruneStrayDepositPaths(deftDir, contentRoot, io);
365
- const nowIso = seams.nowIso ?? (() => new Date().toISOString().replace(/\.\d{3}Z$/, "Z"));
366
- const manifestFields = {
367
- ref: contentVersion.startsWith("v") ? contentVersion : `v${contentVersion}`,
368
- sha: "content-package",
369
- tag: contentVersion.startsWith("v") ? contentVersion : `v${contentVersion}`,
370
- installRoot: CANONICAL_INSTALL_ROOT,
371
- fetchedAt: nowIso(),
372
- fetchedBy: "directive-update",
373
- ...(previousManagedBy ? { managedBy: previousManagedBy } : {}),
374
- };
375
- const writtenManifestPath = writeInstallManifest(projectDir, deftDir, manifestFields);
376
- // #2064: retire a stale legacy .deft/VERSION now that the canonical
377
- // .deft/core/VERSION has been rewritten (folded in from install-upgrade so no
378
- // manifest behavior is lost by the redirect). Best-effort; never fatal.
379
- migrateLegacyInstallManifest(projectDir, writtenManifestPath);
380
- // #2055: regenerate the bare .deft-version derivative so it agrees with the
381
- // freshly written manifest tag (otherwise doctor's manifest-agreement check
382
- // fails and the operator must hand-edit the marker).
383
- syncBareVersionMarker(projectDir, contentVersion);
424
+ const alreadyCurrent = previousDepositVersion !== null &&
425
+ normalizeVersion(previousDepositVersion) === normalizeVersion(contentVersion);
426
+ const strategy = alreadyCurrent ? "no-op" : "file-swap";
427
+ if (alreadyCurrent) {
428
+ io.printf("[deft update] Framework payload already current; skipping payload copy.\n");
429
+ migrateLegacyInstallManifest(projectDir, join(deftDir, "VERSION"));
430
+ }
431
+ else {
432
+ await copyContent(contentRoot, deftDir);
433
+ await prunePythonArtifactsFromDeposit(deftDir, projectDir, io);
434
+ // #2347: prune framework-source paths that the content package does not ship.
435
+ // The additive file-swap never removes them, causing the deposit-hygiene
436
+ // advisory to persist across every upgrade until manually cleaned.
437
+ await pruneStrayDepositPaths(deftDir, contentRoot, io);
438
+ const nowIso = seams.nowIso ?? (() => new Date().toISOString().replace(/\.\d{3}Z$/, "Z"));
439
+ const manifestFields = {
440
+ ref: contentVersion.startsWith("v") ? contentVersion : `v${contentVersion}`,
441
+ sha: "content-package",
442
+ tag: contentVersion.startsWith("v") ? contentVersion : `v${contentVersion}`,
443
+ installRoot: CANONICAL_INSTALL_ROOT,
444
+ fetchedAt: nowIso(),
445
+ fetchedBy: "directive-update",
446
+ ...(previousManagedBy ? { managedBy: previousManagedBy } : {}),
447
+ };
448
+ const writtenManifestPath = writeInstallManifest(projectDir, deftDir, manifestFields);
449
+ // #2064: retire a stale legacy .deft/VERSION now that the canonical
450
+ // .deft/core/VERSION has been rewritten (folded in from install-upgrade so no
451
+ // manifest behavior is lost by the redirect). Best-effort; never fatal.
452
+ migrateLegacyInstallManifest(projectDir, writtenManifestPath);
453
+ // #2055: regenerate the bare .deft-version derivative so it agrees with the
454
+ // freshly written manifest tag (otherwise doctor's manifest-agreement check
455
+ // fails and the operator must hand-edit the marker). No-op refreshes skip
456
+ // this projection because the manifest tag was not rewritten (#2118).
457
+ syncBareVersionMarker(projectDir, contentVersion);
458
+ }
384
459
  const agentsMdUpdated = writeAgentsMd(projectDir, deftDir, io);
385
460
  // #2148: the deft-core-guard CI workflow is only meaningful when the deposit
386
461
  // is git-tracked (committed vendor layout). On an npm-managed (gitignored)
@@ -399,12 +474,27 @@ export async function runRefreshDeposit(args, io, seams = {}) {
399
474
  if (args.nonInteractive) {
400
475
  taskfileWired = ensureTaskfile(projectDir, io);
401
476
  }
402
- const { stagePaths, staged, stagedPaths } = depositStagePaths(projectDir, {
403
- includeTaskfile: taskfileWired,
404
- });
405
- printCommitGuidance(io, stagePaths, staged);
406
477
  const readPorcelain = seams.gitPorcelain ?? gitPorcelain;
407
- printRefreshSideEffects(io, frameworkRefreshSideEffects(projectDir, readPorcelain));
478
+ const effects = frameworkRefreshSideEffects(projectDir, {
479
+ readPorcelain,
480
+ readSemanticDiffNames: seams.gitSemanticDiffNames ?? gitSemanticDiffNames,
481
+ });
482
+ let stagedPaths = [];
483
+ if (!alreadyCurrent || effects.files.length > 0) {
484
+ const stagedResult = depositStagePaths(projectDir, {
485
+ includeTaskfile: taskfileWired,
486
+ includeCore: !alreadyCurrent,
487
+ });
488
+ stagedPaths = stagedResult.stagedPaths;
489
+ if (!alreadyCurrent) {
490
+ printCommitGuidance(io, stagedResult.stagePaths, stagedResult.staged);
491
+ }
492
+ else if (stagedResult.stagedPaths.length > 0) {
493
+ io.printf("\nUpdated installer-managed projections for the current framework deposit:\n");
494
+ io.printf(` git add ${stagedResult.stagedPaths.join(" ")}\n`);
495
+ }
496
+ }
497
+ printRefreshSideEffects(io, { ...effects, payloadSwapped: !alreadyCurrent });
408
498
  if (versionSkewNotice) {
409
499
  io.printf(`${versionSkewNotice}\n`);
410
500
  }
@@ -414,6 +504,8 @@ export async function runRefreshDeposit(args, io, seams = {}) {
414
504
  contentVersion,
415
505
  engineVersion,
416
506
  previousDepositVersion,
507
+ alreadyCurrent,
508
+ strategy,
417
509
  agentsMdUpdated,
418
510
  versionSkewNotice,
419
511
  legacyLayout: false,
@@ -515,7 +607,9 @@ export async function runRefreshDepositCli(options) {
515
607
  }
516
608
  try {
517
609
  const result = await runRefreshDeposit(options, io, options.seams);
518
- const state = classification?.state;
610
+ const state = result.alreadyCurrent
611
+ ? "current"
612
+ : classification?.state;
519
613
  if (options.jsonOut) {
520
614
  options.writeOut(`${JSON.stringify(buildUpdateSummaryJson(result, options, state), null, 2)}\n`);
521
615
  printUpdateComplete(result, { printf: options.writeErr }, state);
@@ -22,6 +22,7 @@ const FRAMEWORK_SELF_TEST_REL = ".deft/core/tests";
22
22
  const VENDORED_TS_PACKAGES_REL = ".deft/core/packages";
23
23
  const VENDORED_TS_TEST_RE = /\.(test|spec)\.(c|m)?[jt]sx?$/i;
24
24
  const CORE_GITATTRIBUTES_LINES = [
25
+ `${CORE_GLOB} text eol=lf`,
25
26
  `${CORE_GLOB} linguist-generated=true`,
26
27
  `${CORE_GLOB} linguist-vendored=true`,
27
28
  ];
@@ -584,7 +585,7 @@ export function ensureGitattributes(projectDir, io) {
584
585
  const present = new Set(existing.split("\n").map((line) => line.trim()));
585
586
  const additions = CORE_GITATTRIBUTES_LINES.filter((line) => !present.has(line));
586
587
  if (additions.length === 0) {
587
- io.printf(`.gitattributes already marks ${CORE_GLOB} as generated/vendored — skipping.\n`);
588
+ io.printf(`.gitattributes already marks ${CORE_GLOB} as LF-pinned/generated/vendored — skipping.\n`);
588
589
  return false;
589
590
  }
590
591
  let body = existing;
@@ -594,13 +595,13 @@ export function ensureGitattributes(projectDir, io) {
594
595
  body += "\n";
595
596
  body +=
596
597
  "# Deft framework: the vendored payload is packaged framework code, not\n" +
597
- "# consumer source. Mark it generated + vendored so language stats and\n" +
598
- "# diffs treat .deft/core/** as machine-managed (#1430).\n";
598
+ "# consumer source. Pin LF endings and mark it generated + vendored so\n" +
599
+ "# Git does not rewrite it and diffs treat .deft/core/** as machine-managed (#1430, #2118).\n";
599
600
  for (const add of additions) {
600
601
  body += `${add}\n`;
601
602
  }
602
603
  writeFileSync(path, body, "utf8");
603
- io.printf(`.gitattributes updated with linguist markers: ${additions.join(", ")}\n`);
604
+ io.printf(`.gitattributes updated with Deft core markers: ${additions.join(", ")}\n`);
604
605
  return true;
605
606
  }
606
607
  function greptilePatternPresent(patterns, glob) {
@@ -1,4 +1,5 @@
1
1
  import type { PolicyResult } from "./resolve.js";
2
+ export { policyColonInvocation, policySetInvocation } from "./policy-invocation.js";
2
3
  /** One-liner disclosure phrasing for AGENTS.md / setup interview echo. */
3
4
  export declare function disclosureLine(result: PolicyResult): string;
4
5
  //# sourceMappingURL=disclosure.d.ts.map
@@ -1,4 +1,5 @@
1
1
  import { ENV_BYPASS } from "./resolve.js";
2
+ export { policyColonInvocation, policySetInvocation } from "./policy-invocation.js";
2
3
  /** One-liner disclosure phrasing for AGENTS.md / setup interview echo. */
3
4
  export function disclosureLine(result) {
4
5
  if (result.allowDirectCommits) {
@@ -4,6 +4,7 @@ export * from "./capacity.js";
4
4
  export * from "./decisions.js";
5
5
  export * from "./disclosure.js";
6
6
  export * from "./plan-extensions.js";
7
+ export * from "./policy-invocation.js";
7
8
  export * from "./resolve.js";
8
9
  export * from "./value-feedback.js";
9
10
  export * from "./wip.js";
@@ -8,6 +8,7 @@ export * from "./capacity.js";
8
8
  export * from "./decisions.js";
9
9
  export * from "./disclosure.js";
10
10
  export * from "./plan-extensions.js";
11
+ export * from "./policy-invocation.js";
11
12
  export * from "./resolve.js";
12
13
  export * from "./value-feedback.js";
13
14
  export * from "./wip.js";
@@ -0,0 +1,5 @@
1
+ /** User-facing colon-form policy verb for consumer `deft` disclosures (#2367). */
2
+ export declare function policyColonInvocation(subcommand: string, trailing?: string): string;
3
+ /** User-facing `deft policy set <cmd>` form for policy-set disclosures (#2367). */
4
+ export declare function policySetInvocation(subcommand: string, trailing?: string): string;
5
+ //# sourceMappingURL=policy-invocation.d.ts.map
@@ -0,0 +1,9 @@
1
+ /** User-facing colon-form policy verb for consumer `deft` disclosures (#2367). */
2
+ export function policyColonInvocation(subcommand, trailing = "") {
3
+ return `deft policy:${subcommand}${trailing}`;
4
+ }
5
+ /** User-facing `deft policy set <cmd>` form for policy-set disclosures (#2367). */
6
+ export function policySetInvocation(subcommand, trailing = "") {
7
+ return `deft policy set ${subcommand}${trailing}`;
8
+ }
9
+ //# sourceMappingURL=policy-invocation.js.map
@@ -3,6 +3,7 @@ import { join, resolve as pathResolve } from "node:path";
3
3
  import { resolveProjectDefinitionPath } from "../layout/resolve.js";
4
4
  import { atomicWriteProjectDefinition, projectDefinitionMutationLock, } from "../vbrief-build/project-definition-io.js";
5
5
  import { migrateLegacyPolicyKey, PLAN_POLICY_KEY, readPlanPolicy } from "./plan-extensions.js";
6
+ import { policyColonInvocation } from "./policy-invocation.js";
6
7
  /** Filesystem-relative location of the project-definition xBRIEF (display/back-compat). */
7
8
  export const PROJECT_DEFINITION_REL_PATH = "xbrief/PROJECT-DEFINITION.xbrief.json";
8
9
  /** Environment variable emergency bypass for branch protection (#747). */
@@ -154,8 +155,9 @@ export function resolvePolicy(projectRoot) {
154
155
  const warn = `DEPRECATED: PROJECT-DEFINITION uses the legacy narrative key ` +
155
156
  `'${LEGACY_NARRATIVE_KEY}' (${pythonRepr(raw)}). Migrate to typed ` +
156
157
  `plan.policy.allowDirectCommitsToMaster (#746). Run ` +
157
- `\`task policy:enforce-branches\` or \`task policy:allow-direct-commits ` +
158
- `-- --confirm\` to set the typed flag explicitly.`;
158
+ `\`${policyColonInvocation("enforce-branches")}\` or ` +
159
+ `\`${policyColonInvocation("allow-direct-commits", " -- --confirm")}\` ` +
160
+ `to set the typed flag explicitly.`;
159
161
  return {
160
162
  allowDirectCommits: allow,
161
163
  source: "legacy-narrative",
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { atomicWriteProjectDefinition, projectDefinitionMutationLock, } from "../vbrief-build/project-definition-io.js";
3
3
  import { migrateLegacyPolicyKey, PLAN_POLICY_KEY, readPlanPolicy } from "./plan-extensions.js";
4
+ import { policyColonInvocation } from "./policy-invocation.js";
4
5
  import { appendAuditLog, loadProjectDefinition, projectDefinitionPath } from "./resolve.js";
5
6
  import { isTrustedOrgAutoEnable } from "./value-feedback-autoenable.js";
6
7
  /** Canonical registered policy field name (matches other FIELD_* dotted paths). */
@@ -21,7 +22,9 @@ export const VALUE_FEEDBACK_CAPABILITY_COST_DISCLOSURE = "\u26a0 Capability-cost
21
22
  " \u2022 A budgeted session one-liner may appear when concrete attributed value exists.\n" +
22
23
  " \u2022 Upstream gap-escalation prompts stay OFF unless you explicitly enable " +
23
24
  "`upstreamPrompt` (GitHub attention + token cost).\n" +
24
- " \u2022 Inspect current state: `task policy:show --field=valueFeedback`.\n" +
25
+ " \u2022 Inspect current state: `" +
26
+ policyColonInvocation("show", " --field=valueFeedback") +
27
+ "`.\n" +
25
28
  " \u2022 Reversible: set `enabled: false` under the typed policy block in PROJECT-DEFINITION.\n" +
26
29
  " \u2022 Changes are recorded to meta/policy-changes.log for auditability.";
27
30
  function defaultResolved(source, error = null) {
@@ -202,7 +205,7 @@ export function enableValueFeedback(projectRoot, options) {
202
205
  return {
203
206
  exitCode: 1,
204
207
  stdout: `${VALUE_FEEDBACK_CAPABILITY_COST_DISCLOSURE}\n\n` +
205
- "Re-run with --confirm to apply: task policy:enable-value-feedback -- --confirm\n",
208
+ `Re-run with --confirm to apply: ${policyColonInvocation("enable-value-feedback", " -- --confirm")}\n`,
206
209
  changed: false,
207
210
  };
208
211
  }
@@ -265,7 +268,7 @@ export function enableValueFeedback(projectRoot, options) {
265
268
  if (changedFlag) {
266
269
  atomicWriteProjectDefinition(path, data);
267
270
  }
268
- const actor = options.actor ?? "task policy:enable-value-feedback";
271
+ const actor = options.actor ?? policyColonInvocation("enable-value-feedback");
269
272
  const note = options.note ?? "";
270
273
  const parts = [
271
274
  `actor=${actor}`,
@@ -7,7 +7,7 @@ export declare const EXIT_UNCLEAN = 1;
7
7
  export declare const EXIT_EXTERNAL_ERROR = 2;
8
8
  export declare const DEFAULT_BASE_BRANCH = "master";
9
9
  export declare const LEAF_CODING_WORKER_ROLE = "leaf-implementation";
10
- export declare const SUBAGENT_BACKEND_SET_CMD = "task policy:subagent-backend -- --set {backend_id}";
10
+ export declare const SUBAGENT_BACKEND_SET_CMD: string;
11
11
  export declare const GATE_ADVISE = "advise";
12
12
  export declare const GATE_ENFORCE = "enforce";
13
13
  export declare const READY = "ready";
@@ -1,3 +1,4 @@
1
+ import { policySetInvocation } from "../policy/policy-invocation.js";
1
2
  export const EXIT_OK = 0;
2
3
  export const EXIT_GATE_FAILED = 1;
3
4
  export const EXIT_CONFIG_ERROR = 2;
@@ -7,7 +8,7 @@ export const EXIT_UNCLEAN = 1;
7
8
  export const EXIT_EXTERNAL_ERROR = 2;
8
9
  export const DEFAULT_BASE_BRANCH = "master";
9
10
  export const LEAF_CODING_WORKER_ROLE = "leaf-implementation";
10
- export const SUBAGENT_BACKEND_SET_CMD = "task policy:subagent-backend -- --set {backend_id}";
11
+ export const SUBAGENT_BACKEND_SET_CMD = `${policySetInvocation("subagent-backend", " -- --set {backend_id}")}`;
11
12
  export const GATE_ADVISE = "advise";
12
13
  export const GATE_ENFORCE = "enforce";
13
14
  export const READY = "ready";
@@ -12,6 +12,7 @@
12
12
  * @see {@link https://github.com/deftai/directive/issues/1860} Hard deletion tracking
13
13
  */
14
14
  import { readPlanPolicy } from "../policy/plan-extensions.js";
15
+ import { policySetInvocation } from "../policy/policy-invocation.js";
15
16
  import { loadProjectDefinition } from "../policy/resolve.js";
16
17
  import { LEAF_CODING_WORKER_ROLE, SUBAGENT_BACKEND_SET_CMD } from "./constants.js";
17
18
  const TRUTHY = new Set(["1", "true", "yes", "on"]);
@@ -132,7 +133,7 @@ export function enforceSubagentBackendPolicy(projectRoot, environ = process.env)
132
133
  error: `${detail}\n` +
133
134
  "Select a coding sub-agent backend before headless dispatch:\n" +
134
135
  `${listing}\n` +
135
- "Probe harness availability: task policy:subagent-backends\n" +
136
+ `Probe harness availability: ${policySetInvocation("subagent-backends")}\n` +
136
137
  `Persist a choice: ${SUBAGENT_BACKEND_SET_CMD.replace("{backend_id}", "<id>")}`,
137
138
  };
138
139
  }
@@ -25,6 +25,8 @@ export declare const SKIP_NOTICE: string;
25
25
  /** Result of a single lane command invocation. Mirrors a subset of SpawnSyncReturns. */
26
26
  export interface RunnerResult {
27
27
  readonly status: number | null;
28
+ readonly signal?: NodeJS.Signals | null;
29
+ readonly error?: Error;
28
30
  }
29
31
  export type LaneRunner = (argv: readonly string[], cwd: string) => RunnerResult;
30
32
  export interface RunTsLaneOptions {
@@ -35,6 +37,8 @@ export interface RunTsLaneOptions {
35
37
  /** Injected sink for human-facing notices (defaults to stdout). */
36
38
  readonly out?: (message: string) => void;
37
39
  }
40
+ /** Windows command shims (.cmd/.bat) need a shell; native executables do not. */
41
+ export declare function shouldUseShellForCommand(command: string, platform?: NodeJS.Platform): boolean;
38
42
  export interface ResolvePnpmOptions {
39
43
  readonly env?: NodeJS.ProcessEnv;
40
44
  readonly platform?: NodeJS.Platform;
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { spawnSync } from "node:child_process";
20
20
  import { existsSync } from "node:fs";
21
- import { join } from "node:path";
21
+ import { posix, win32 } from "node:path";
22
22
  /**
23
23
  * Run order is deliberate: lint (cheapest, catches the biome class first),
24
24
  * then build, then the test suite.
@@ -31,11 +31,20 @@ export const LANE_COMMANDS = [
31
31
  export const SKIP_NOTICE = "[ts:check-lane] pnpm not found on PATH -- skipping the TypeScript lane " +
32
32
  "(build/lint/test). The TS engine stays validated by the dedicated CI job. " +
33
33
  "Install the Node toolchain (pnpm) to run the TS lane locally.";
34
- /** Default runner: a non-shell, inherited-stdio pnpm invocation. */
34
+ /** Windows command shims (.cmd/.bat) need a shell; native executables do not. */
35
+ export function shouldUseShellForCommand(command, platform = process.platform) {
36
+ return platform === "win32" && /\.(?:cmd|bat)$/i.test(command);
37
+ }
38
+ /** Default runner: an inherited-stdio pnpm invocation. */
35
39
  function defaultRunner(argv, cwd) {
36
40
  const [command, ...rest] = argv;
37
- const result = spawnSync(command ?? "", rest, { cwd, stdio: "inherit" });
38
- return { status: result.status };
41
+ const commandPath = command ?? "";
42
+ const result = spawnSync(commandPath, rest, {
43
+ cwd,
44
+ stdio: "inherit",
45
+ shell: shouldUseShellForCommand(commandPath),
46
+ });
47
+ return { error: result.error, signal: result.signal, status: result.status };
39
48
  }
40
49
  /** Resolve the pnpm executable path, or null when it is not installed. */
41
50
  export function resolvePnpm(options = {}) {
@@ -49,11 +58,12 @@ export function resolvePnpm(options = {}) {
49
58
  const isWindows = platform === "win32";
50
59
  const exts = isWindows ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";") : [""];
51
60
  const sep = isWindows ? ";" : ":";
61
+ const joinPath = isWindows ? win32.join : posix.join;
52
62
  for (const dir of pathValue.split(sep)) {
53
63
  if (dir === "")
54
64
  continue;
55
65
  for (const ext of exts) {
56
- const candidate = join(dir, `pnpm${ext}`);
66
+ const candidate = joinPath(dir, `pnpm${ext}`);
57
67
  if (exists(candidate)) {
58
68
  return candidate;
59
69
  }
@@ -85,7 +95,11 @@ export function runTsLane(projectRoot, options) {
85
95
  // failure -- this mirrors the Python oracle, whose returncode is negative
86
96
  // (non-zero) for a signal-killed process.
87
97
  if (code === null) {
88
- out(`[ts:check-lane] \`pnpm ${command.join(" ")}\` was killed by a signal before exit -- treating as failure.`);
98
+ if (result.error) {
99
+ out(`[ts:check-lane] \`pnpm ${command.join(" ")}\` failed to start: ${result.error.message}`);
100
+ return 1;
101
+ }
102
+ out(`[ts:check-lane] \`pnpm ${command.join(" ")}\` was killed by ${result.signal ?? "a signal"} before exit -- treating as failure.`);
89
103
  return 1;
90
104
  }
91
105
  if (code !== 0) {
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { isFrameworkRepoRoot } from "../check/orchestrator.js";
4
+ import { policyColonInvocation } from "../policy/policy-invocation.js";
4
5
  import { isValueFeedbackPathAllowed, resolveValueFeedback } from "../policy/value-feedback.js";
5
6
  import { GhRestError, restCreateIssue, restIssueListPaginated, } from "../scm/gh-rest.js";
6
7
  import { resolveProjectRoot } from "../scope/project-context.js";
@@ -146,8 +147,8 @@ export function runFeedbackFile(options) {
146
147
  body,
147
148
  repo,
148
149
  message: "[deft feedback] Blocked: plan.policy.valueFeedback upstreamPrompt is OFF. " +
149
- "Enable with `task policy:enable-value-feedback -- --confirm` and set upstreamPrompt, " +
150
- "or inspect via `task policy:show --field=valueFeedback`.\n",
150
+ `Enable with \`${policyColonInvocation("enable-value-feedback", " -- --confirm")}\` and set upstreamPrompt, ` +
151
+ `or inspect via \`${policyColonInvocation("show", " --field=valueFeedback")}\`.\n`,
151
152
  };
152
153
  }
153
154
  const offline = isOffline();
@@ -3,6 +3,7 @@ import { join, resolve } from "node:path";
3
3
  import { runningInsideDeftRepo } from "../doctor/paths.js";
4
4
  import { ALL_ATTRIBUTION_EVENT_NAMES } from "../events/attribution-constants.js";
5
5
  import { DEFAULT_EVENT_LOG, readEvents } from "../lifecycle/events.js";
6
+ import { policyColonInvocation } from "../policy/policy-invocation.js";
6
7
  import { isValueFeedbackPathAllowed, resolveValueFeedback, } from "../policy/value-feedback.js";
7
8
  import { resolveProjectRoot } from "../scope/project-context.js";
8
9
  import { MAX_LINE_CHARS } from "../triage/welcome/constants.js";
@@ -364,7 +365,7 @@ export function formatValueShowReport(trend) {
364
365
  if (trend.total === 0) {
365
366
  return (`[value] No attributed signals in the last ${trend.windowLabel} ` +
366
367
  "(ledger empty for this window).\n" +
367
- "Inspect policy: `task policy:show --field=valueFeedback`.\n");
368
+ `Inspect policy: \`${policyColonInvocation("show", " --field=valueFeedback")}\`.\n`);
368
369
  }
369
370
  const classParts = ["value", "bypass", "adoption", "friction"]
370
371
  .filter((key) => trend.byClass[key] > 0)
@@ -409,7 +410,7 @@ export function runValueShow(options) {
409
410
  gated: true,
410
411
  empty: true,
411
412
  text: "[value] Blocked: plan.policy.valueFeedback is OFF. " +
412
- "Enable with `task policy:enable-value-feedback -- --confirm`.\n",
413
+ `Enable with \`${policyColonInvocation("enable-value-feedback", " -- --confirm")}\`.\n`,
413
414
  trend: null,
414
415
  };
415
416
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deftai/directive-core",
3
- "version": "0.73.0",
3
+ "version": "0.73.1",
4
4
  "description": "TypeScript engine core for the Directive framework.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -281,8 +281,8 @@
281
281
  "provenance": true
282
282
  },
283
283
  "dependencies": {
284
- "@deftai/directive-content": "^0.73.0",
285
- "@deftai/directive-types": "^0.73.0",
284
+ "@deftai/directive-content": "^0.73.1",
285
+ "@deftai/directive-types": "^0.73.1",
286
286
  "archiver": "^8.0.0"
287
287
  },
288
288
  "scripts": {