@deftai/directive-core 0.74.0 → 0.76.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 (71) hide show
  1. package/dist/agents-md-budget/evaluate.d.ts +61 -0
  2. package/dist/agents-md-budget/evaluate.js +341 -19
  3. package/dist/agents-md-budget/index.d.ts +1 -0
  4. package/dist/agents-md-budget/index.js +1 -0
  5. package/dist/agents-md-budget/skill-frontmatter.d.ts +35 -0
  6. package/dist/agents-md-budget/skill-frontmatter.js +116 -0
  7. package/dist/cache/operations.d.ts +2 -0
  8. package/dist/cache/operations.js +16 -1
  9. package/dist/content-contracts/skills/helpers.d.ts +0 -1
  10. package/dist/content-contracts/skills/helpers.js +1 -11
  11. package/dist/content-contracts/skills/skill-frontmatter.js +8 -1
  12. package/dist/content-contracts/standards/_helpers.js +4 -2
  13. package/dist/content-contracts/standards/_taskfile-helpers.d.ts +2 -0
  14. package/dist/content-contracts/standards/_taskfile-helpers.js +11 -5
  15. package/dist/doctor/doctor-state.js +2 -1
  16. package/dist/doctor/paths.js +2 -1
  17. package/dist/eval-health-relocation/evaluate.d.ts +71 -0
  18. package/dist/eval-health-relocation/evaluate.js +252 -0
  19. package/dist/eval-health-relocation/index.d.ts +2 -0
  20. package/dist/eval-health-relocation/index.js +2 -0
  21. package/dist/init-deposit/scaffold.js +35 -20
  22. package/dist/intake/issue-emit.js +2 -2
  23. package/dist/intake/issue-ingest.js +23 -4
  24. package/dist/packaging/openpackage-tiers.d.ts +12 -0
  25. package/dist/packaging/openpackage-tiers.js +42 -0
  26. package/dist/policy/agents-md-budget.d.ts +23 -0
  27. package/dist/policy/agents-md-budget.js +75 -4
  28. package/dist/preflight/evaluate.d.ts +5 -2
  29. package/dist/preflight/evaluate.js +9 -4
  30. package/dist/preflight/index.d.ts +1 -1
  31. package/dist/preflight/index.js +1 -1
  32. package/dist/release/pipeline-fixture.d.ts +3 -0
  33. package/dist/release/pipeline-fixture.js +11 -0
  34. package/dist/release/pipeline.js +4 -0
  35. package/dist/release/spawn.js +5 -3
  36. package/dist/scope/decompose.js +4 -3
  37. package/dist/scope/transition.js +17 -2
  38. package/dist/scope/vbrief-ref.d.ts +6 -0
  39. package/dist/scope/vbrief-ref.js +23 -4
  40. package/dist/session/index.d.ts +1 -0
  41. package/dist/session/index.js +1 -0
  42. package/dist/session/posture.d.ts +50 -0
  43. package/dist/session/posture.js +152 -0
  44. package/dist/session/ritual-sentinel.d.ts +2 -0
  45. package/dist/session/ritual-sentinel.js +3 -0
  46. package/dist/session/session-start.d.ts +8 -0
  47. package/dist/session/session-start.js +43 -0
  48. package/dist/session/verify-session-ritual.d.ts +6 -0
  49. package/dist/session/verify-session-ritual.js +67 -4
  50. package/dist/swarm/complete-cohort.js +6 -6
  51. package/dist/swarm/launch.js +2 -2
  52. package/dist/triage/bootstrap/gitignore.js +8 -4
  53. package/dist/triage/cache-path.js +16 -3
  54. package/dist/triage/queue/cache.d.ts +14 -0
  55. package/dist/triage/queue/cache.js +61 -1
  56. package/dist/triage/welcome/writers.js +2 -0
  57. package/dist/ts-check-lane/index.d.ts +1 -1
  58. package/dist/ts-check-lane/index.js +1 -1
  59. package/dist/ts-check-lane/run-lane.d.ts +5 -0
  60. package/dist/ts-check-lane/run-lane.js +15 -0
  61. package/dist/validate-content/validate-links.js +2 -2
  62. package/dist/value/readback.d.ts +1 -0
  63. package/dist/value/readback.js +5 -1
  64. package/dist/vbrief-build/constants.d.ts +3 -3
  65. package/dist/vbrief-build/constants.js +4 -3
  66. package/dist/vbrief-build/index.d.ts +1 -1
  67. package/dist/vbrief-build/index.js +1 -1
  68. package/dist/verify-env/toolchain-check.js +20 -5
  69. package/dist/verify-env/verify-hooks-installed.d.ts +2 -0
  70. package/dist/verify-env/verify-hooks-installed.js +17 -15
  71. package/package.json +7 -3
@@ -10,12 +10,19 @@ 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
12
  import { copyTree } from "../deposit/copy-tree.js";
13
+ import { assertProjectionContained, ProjectionContainmentError, } from "../fs/projection-containment.js";
13
14
  import { agentsRefreshPlan } from "../platform/agents-md.js";
14
15
  import { MIGRATED_ARTIFACT_DIR } from "../xbrief-migrate/constants.js";
15
16
  import { CANONICAL_INSTALL_ROOT } from "./constants.js";
16
17
  import { installerManagedGuardEre } from "./hygiene.js";
17
18
  export { CANONICAL_INSTALL_ROOT };
18
19
  export const CORE_GLOB = ".deft/core/**";
20
+ /** Refuse init/update projection writes that escape via repo-controlled symlinks (#2446). */
21
+ function projectionTarget(projectDir, ...relSegments) {
22
+ const target = join(projectDir, ...relSegments);
23
+ assertProjectionContained(projectDir, target);
24
+ return target;
25
+ }
19
26
  const CODEQL_CONFIG_REL = ".github/codeql/codeql-config.yml";
20
27
  const CORE_GUARD_WORKFLOW_REL = ".github/workflows/deft-core-guard.yml";
21
28
  const FRAMEWORK_SELF_TEST_REL = ".deft/core/tests";
@@ -232,7 +239,7 @@ export const PIN_DEPENDENCY_NAME = "@deftai/directive";
232
239
  */
233
240
  export function ensurePackageJsonPin(projectDir, version, io) {
234
241
  const pinVersion = version.trim().replace(/^v/i, "");
235
- const path = join(projectDir, "package.json");
242
+ const path = projectionTarget(projectDir, "package.json");
236
243
  const existed = existsSync(path);
237
244
  let pkg = {};
238
245
  if (existed) {
@@ -284,7 +291,7 @@ export function writeAgentsMd(projectDir, deftDir, io) {
284
291
  if (typeof newContent !== "string") {
285
292
  throw new Error("AGENTS.md render produced no content");
286
293
  }
287
- const path = join(projectDir, "AGENTS.md");
294
+ const path = projectionTarget(projectDir, "AGENTS.md");
288
295
  writeFileSync(path, newContent, "utf8");
289
296
  if (state === "absent") {
290
297
  io.printf("AGENTS.md created.\n");
@@ -294,9 +301,9 @@ export function writeAgentsMd(projectDir, deftDir, io) {
294
301
  }
295
302
  return true;
296
303
  }
297
- async function ensureVbriefLifecycleDirs(consumerVbrief) {
304
+ async function ensureVbriefLifecycleDirs(projectDir) {
298
305
  for (const sub of VBRIEF_LIFECYCLE_DIRS) {
299
- const dir = join(consumerVbrief, sub);
306
+ const dir = projectionTarget(projectDir, MIGRATED_ARTIFACT_DIR, sub);
300
307
  await mkdir(dir, { recursive: true, mode: 0o755 });
301
308
  const gitkeep = join(dir, ".gitkeep");
302
309
  try {
@@ -323,9 +330,9 @@ function vbriefLifecycleDirsPresent(consumerVbrief) {
323
330
  });
324
331
  }
325
332
  export async function writeConsumerVbrief(projectDir, deftDir, io) {
326
- const consumerVbrief = join(projectDir, MIGRATED_ARTIFACT_DIR);
327
- const schemasDst = join(consumerVbrief, "schemas");
328
- const vbriefMdDst = join(consumerVbrief, "vbrief.md");
333
+ const consumerVbrief = projectionTarget(projectDir, MIGRATED_ARTIFACT_DIR);
334
+ const schemasDst = projectionTarget(projectDir, MIGRATED_ARTIFACT_DIR, "schemas");
335
+ const vbriefMdDst = projectionTarget(projectDir, MIGRATED_ARTIFACT_DIR, "vbrief.md");
329
336
  const schemasPresent = existsSync(schemasDst) && statSync(schemasDst).isDirectory();
330
337
  const vbriefMdPresent = existsSync(vbriefMdDst) && statSync(vbriefMdDst).isFile();
331
338
  const lifecyclePresent = vbriefLifecycleDirsPresent(consumerVbrief);
@@ -352,20 +359,21 @@ export async function writeConsumerVbrief(projectDir, deftDir, io) {
352
359
  writeFileSync(vbriefMdDst, VBRIEF_README_BODY, "utf8");
353
360
  }
354
361
  }
355
- await ensureVbriefLifecycleDirs(consumerVbrief);
362
+ await ensureVbriefLifecycleDirs(projectDir);
356
363
  io.printf("vbrief/ deposited at project root (schemas + vbrief.md + lifecycle dirs).\n");
357
364
  return true;
358
365
  }
359
366
  export function writeAgentsSkills(projectDir, io) {
367
+ projectionTarget(projectDir, ".agents");
360
368
  const allExist = AGENTS_SKILLS.every((skill) => existsSync(join(projectDir, ".agents", "skills", skill.dir, "SKILL.md")));
361
369
  if (allExist) {
362
370
  io.printf(".agents/skills/ already present — skipping.\n");
363
371
  return false;
364
372
  }
365
373
  for (const skill of AGENTS_SKILLS) {
366
- const dir = join(projectDir, ".agents", "skills", skill.dir);
374
+ const dir = projectionTarget(projectDir, ".agents", "skills", skill.dir);
367
375
  mkdirSync(dir, { recursive: true });
368
- const path = join(dir, "SKILL.md");
376
+ const path = projectionTarget(projectDir, ".agents", "skills", skill.dir, "SKILL.md");
369
377
  if (existsSync(path))
370
378
  continue;
371
379
  writeFileSync(path, skill.content, "utf8");
@@ -412,7 +420,7 @@ function insertDeftIncludeAfterIncludesLine(content) {
412
420
  return { content, ok: false };
413
421
  }
414
422
  export function ensureTaskfile(projectDir, io) {
415
- const path = join(projectDir, "Taskfile.yml");
423
+ const path = projectionTarget(projectDir, "Taskfile.yml");
416
424
  let existing = "";
417
425
  if (existsSync(path)) {
418
426
  existing = readFileSync(path, "utf8");
@@ -457,28 +465,32 @@ export function ensureTaskfile(projectDir, io) {
457
465
  return true;
458
466
  }
459
467
  const HOOK_FILENAMES = ["pre-commit", "pre-push"];
468
+ const HOOK_SUPPORT_FILENAMES = ["_deft-run.sh"];
460
469
  const HOOK_FILE_MODE = 0o755;
470
+ const HOOK_SUPPORT_FILE_MODE = 0o644;
461
471
  export function writeConsumerGitHooks(projectDir, deftDir, io, seams = {}) {
462
472
  const srcDir = join(deftDir, ".githooks");
463
473
  if (!existsSync(srcDir) || !statSync(srcDir).isDirectory()) {
464
474
  io.printf(`git hooks source ${srcDir} absent — skipping hook wiring.\n`);
465
475
  return false;
466
476
  }
467
- const dstDir = join(projectDir, ".githooks");
477
+ const dstDir = projectionTarget(projectDir, ".githooks");
468
478
  mkdirSync(dstDir, { recursive: true });
469
479
  let filesDeposited = false;
470
- for (const name of HOOK_FILENAMES) {
480
+ for (const name of [...HOOK_FILENAMES, ...HOOK_SUPPORT_FILENAMES]) {
471
481
  const src = join(srcDir, name);
472
482
  if (!existsSync(src))
473
483
  continue;
474
484
  const data = readFileSync(src);
475
- const dst = join(dstDir, name);
485
+ const dst = projectionTarget(projectDir, ".githooks", name);
476
486
  const existing = existsSync(dst) ? readFileSync(dst) : null;
487
+ const isHookScript = HOOK_FILENAMES.includes(name);
477
488
  if (!existing?.equals(data)) {
478
- writeFileSync(dst, data, { mode: HOOK_FILE_MODE });
489
+ const mode = isHookScript ? HOOK_FILE_MODE : HOOK_SUPPORT_FILE_MODE;
490
+ writeFileSync(dst, data, { mode });
479
491
  filesDeposited = true;
480
492
  }
481
- if (platform() !== "win32") {
493
+ if (platform() !== "win32" && isHookScript) {
482
494
  try {
483
495
  const mode = statSync(dst).mode & 0o777;
484
496
  if ((mode & 0o111) === 0) {
@@ -652,7 +664,7 @@ function coreGuardWorkflowContent() {
652
664
  ' echo "OK: no mixed framework + app changes."\n');
653
665
  }
654
666
  export function ensureGitattributes(projectDir, io) {
655
- const path = join(projectDir, ".gitattributes");
667
+ const path = projectionTarget(projectDir, ".gitattributes");
656
668
  const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
657
669
  const present = new Set(existing.split("\n").map((line) => line.trim()));
658
670
  const additions = CORE_GITATTRIBUTES_LINES.filter((line) => !present.has(line));
@@ -687,7 +699,7 @@ function appendGreptilePattern(patterns, glob) {
687
699
  return `${patterns}\n${glob}`;
688
700
  }
689
701
  export function ensureGreptileIgnore(projectDir, io) {
690
- const path = join(projectDir, "greptile.json");
702
+ const path = projectionTarget(projectDir, "greptile.json");
691
703
  const fileExisted = existsSync(path);
692
704
  let raw = fileExisted ? readFileSync(path, "utf8") : "";
693
705
  if (!raw.trim())
@@ -762,7 +774,7 @@ function insertCodeqlPathsIgnore(content, glob) {
762
774
  return { content, ok: false };
763
775
  }
764
776
  export function ensureCodeqlPathsIgnore(projectDir, io) {
765
- const path = join(projectDir, CODEQL_CONFIG_REL);
777
+ const path = projectionTarget(projectDir, CODEQL_CONFIG_REL);
766
778
  if (!existsSync(path)) {
767
779
  mkdirSync(dirname(path), { recursive: true });
768
780
  writeFileSync(path, codeqlConfigDefault(), "utf8");
@@ -783,7 +795,7 @@ export function ensureCodeqlPathsIgnore(projectDir, io) {
783
795
  return true;
784
796
  }
785
797
  export function ensureCoreGuardWorkflow(projectDir, io) {
786
- const path = join(projectDir, CORE_GUARD_WORKFLOW_REL);
798
+ const path = projectionTarget(projectDir, CORE_GUARD_WORKFLOW_REL);
787
799
  const desired = coreGuardWorkflowContent();
788
800
  if (existsSync(path)) {
789
801
  const existing = readFileSync(path, "utf8");
@@ -862,6 +874,9 @@ export async function depositNeutralization(projectDir, io, options = {}) {
862
874
  await step();
863
875
  }
864
876
  catch (cause) {
877
+ if (cause instanceof ProjectionContainmentError) {
878
+ throw cause;
879
+ }
865
880
  io.printf(`Warning: neutralization step failed: ${String(cause)}\n`);
866
881
  }
867
882
  }
@@ -1,6 +1,6 @@
1
1
  import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
- import { join, relative, resolve } from "node:path";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { referenceTypeMatches } from "@deftai/directive-types";
5
5
  import { call } from "../scm/call.js";
6
6
  import { resolveProjectRoot } from "../scope/project-context.js";
@@ -217,7 +217,7 @@ export function expandPatterns(patterns, root = null) {
217
217
  const seen = new Set();
218
218
  const out = [];
219
219
  for (const pattern of patterns) {
220
- const candidate = root !== null && !pattern.startsWith("/") ? join(root, pattern) : pattern;
220
+ const candidate = root !== null && !isAbsolute(pattern) ? join(root, pattern) : pattern;
221
221
  let matches = globSync(candidate).sort();
222
222
  if (matches.length === 0) {
223
223
  try {
@@ -8,7 +8,7 @@ import { resolveProjectRoot } from "../scope/project-context.js";
8
8
  import { resolveProjectRepo } from "../slice/project-context.js";
9
9
  import { slugify, TODAY } from "../vbrief-build/build.js";
10
10
  import { EMITTED_VBRIEF_VERSION } from "../vbrief-build/constants.js";
11
- import { LEGACY_ARTIFACT_SUFFIX, LEGACY_INFO_ROOT_KEY, LEGACY_VBRIEF_VERSION, MIGRATED_ARTIFACT_DIR, MIGRATED_ARTIFACT_SUFFIX, MIGRATED_INFO_ROOT_KEY, VBRIEF_VERSION, } from "../xbrief-migrate/constants.js";
11
+ import { LEGACY_ARTIFACT_SUFFIX, LEGACY_INFO_ROOT_KEY, MIGRATED_ARTIFACT_DIR, MIGRATED_ARTIFACT_SUFFIX, MIGRATED_INFO_ROOT_KEY, VBRIEF_VERSION, } from "../xbrief-migrate/constants.js";
12
12
  import { findAcHeading, parseCheckboxItems, parseListItems, sliceAcSection, stripCodeBlocks, stripFencedCodeBlocks, } from "./markdown-scanners.js";
13
13
  import { detectRepo, extractReferencesFromVbrief, fetchOpenIssues, GITHUB_ISSUE_REF_TYPES, LIFECYCLE_FOLDERS, parseIssueNumber, } from "./reconcile-issues.js";
14
14
  export const INGEST_STATUSES = ["proposed", "pending", "active"];
@@ -78,7 +78,7 @@ function resolveIngestEmissionLayout(vbriefDir) {
78
78
  return {
79
79
  artifactSuffix: LEGACY_ARTIFACT_SUFFIX,
80
80
  infoRootKey: LEGACY_INFO_ROOT_KEY,
81
- infoVersion: LEGACY_VBRIEF_VERSION,
81
+ infoVersion: EMITTED_VBRIEF_VERSION,
82
82
  };
83
83
  }
84
84
  return {
@@ -300,11 +300,25 @@ export function issueCommentThread(issue) {
300
300
  export function issueCommentsAlreadyFetched(issue) {
301
301
  return Object.hasOwn(issue, ISSUE_COMMENT_THREAD_KEY);
302
302
  }
303
+ /**
304
+ * Run quarantine `scan()` on untrusted ingest text (#2447). Fail closed on a
305
+ * credential hard-fail; return the fenced/quarantined transform for soft signals.
306
+ */
307
+ function scanUntrustedIngestText(issueNumber, text) {
308
+ const scanResult = scan(text);
309
+ const hardFails = scanResult.flags.filter((f) => f.severity === "hard-fail");
310
+ if (hardFails.length > 0) {
311
+ throw new ScannerHardFailError(issueNumber, scanResult.flags);
312
+ }
313
+ return scanResult.transformed_content;
314
+ }
303
315
  export function buildIssueVbrief(issue, status, repoUrl, options = {}) {
304
316
  const number = Number(issue.number);
305
- const title = (typeof issue.title === "string" && issue.title.length > 0
317
+ const titleRaw = (typeof issue.title === "string" && issue.title.length > 0
306
318
  ? issue.title
307
319
  : `Issue #${number}`) || `Issue #${number}`;
320
+ // #2447: quarantine-scan issue title before it lands in agent-authoritative fields.
321
+ const title = scanUntrustedIngestText(number, titleRaw);
308
322
  const url = (typeof issue.url === "string" && issue.url.length > 0 ? issue.url : "") ||
309
323
  (repoUrl.length > 0 ? `${repoUrl}/issues/${number}` : "");
310
324
  const bodyRaw = issue.body;
@@ -345,7 +359,12 @@ export function buildIssueVbrief(issue, status, repoUrl, options = {}) {
345
359
  if (labelNames.length > 0) {
346
360
  narratives.Labels = labelNames.join(", ");
347
361
  }
348
- const planItems = bodyStr.length > 0 ? extractPlanItems(bodyStr) : [];
362
+ const planItemsRaw = bodyStr.length > 0 ? extractPlanItems(bodyStr) : [];
363
+ // #2447: scan each derived plan-item title (empty-body issues still scan title above).
364
+ const planItems = planItemsRaw.map((item) => ({
365
+ ...item,
366
+ title: scanUntrustedIngestText(number, String(item.title ?? "")),
367
+ }));
349
368
  const plan = {
350
369
  title,
351
370
  status: planStatus,
@@ -0,0 +1,12 @@
1
+ export type OpenPackageTierName = "daily-core" | "standard" | "advanced";
2
+ export interface OpenPackageTierManifest {
3
+ readonly defaultInstallTier: OpenPackageTierName;
4
+ readonly tiers: Record<OpenPackageTierName, {
5
+ skills: string[];
6
+ }>;
7
+ }
8
+ /** Default OpenPackage install tier from deft-tiers.json (#2494). */
9
+ export declare function getOpenPackageDefaultInstallTier(repoRoot: string): OpenPackageTierName;
10
+ /** Skill names for an OpenPackage tier selection (`all` = every mapped skill). */
11
+ export declare function resolveOpenPackageTierSkills(repoRoot: string, tier: OpenPackageTierName | "all"): string[];
12
+ //# sourceMappingURL=openpackage-tiers.d.ts.map
@@ -0,0 +1,42 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ const TIER_NAMES = ["daily-core", "standard", "advanced"];
4
+ function isOpenPackageTierName(value) {
5
+ return TIER_NAMES.includes(value);
6
+ }
7
+ function loadOpenPackageTierManifest(repoRoot) {
8
+ const path = join(repoRoot, "packaging", "openpackage", "deft-tiers.json");
9
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
10
+ if (parsed === null || typeof parsed !== "object" || !parsed.tiers) {
11
+ throw new Error("deft-tiers.json must be an object with a tiers field");
12
+ }
13
+ for (const tier of TIER_NAMES) {
14
+ const block = parsed.tiers[tier];
15
+ if (!block || !Array.isArray(block.skills)) {
16
+ throw new Error(`deft-tiers.json missing tiers.${tier}.skills`);
17
+ }
18
+ }
19
+ const defaultInstallTier = parsed.defaultInstallTier ?? "daily-core";
20
+ if (!isOpenPackageTierName(defaultInstallTier)) {
21
+ throw new Error(`deft-tiers.json defaultInstallTier must be one of ${TIER_NAMES.join(", ")}; got ${defaultInstallTier}`);
22
+ }
23
+ return {
24
+ defaultInstallTier,
25
+ tiers: parsed.tiers,
26
+ };
27
+ }
28
+ function resolveTierSkills(manifest, tier) {
29
+ if (tier === "all") {
30
+ return [...new Set(TIER_NAMES.flatMap((name) => manifest.tiers[name].skills))];
31
+ }
32
+ return [...manifest.tiers[tier].skills];
33
+ }
34
+ /** Default OpenPackage install tier from deft-tiers.json (#2494). */
35
+ export function getOpenPackageDefaultInstallTier(repoRoot) {
36
+ return loadOpenPackageTierManifest(repoRoot).defaultInstallTier;
37
+ }
38
+ /** Skill names for an OpenPackage tier selection (`all` = every mapped skill). */
39
+ export function resolveOpenPackageTierSkills(repoRoot, tier) {
40
+ return resolveTierSkills(loadOpenPackageTierManifest(repoRoot), tier);
41
+ }
42
+ //# sourceMappingURL=openpackage-tiers.js.map
@@ -9,9 +9,32 @@
9
9
  * it is an explicit, reviewed diff to this typed field -- that diff IS the
10
10
  * "was this growth deliberate?" checkpoint.
11
11
  */
12
+ export type HarnessProfile = "cursor" | "none";
13
+ export type SkillFrontmatterTier = "daily-core" | "all" | "none";
12
14
  export interface AgentsMdBudget {
13
15
  readonly managedMaxLines: number;
14
16
  readonly unmanagedMaxLines: number;
17
+ /**
18
+ * Fail-closed UTF-8 byte ratchet for the managed section (#2452).
19
+ * Seeded at the current measured size so the gate ships green; growth past
20
+ * this ceiling fails. The <=8192 B north-star is reported separately.
21
+ */
22
+ readonly absoluteMaxBytes?: number;
23
+ /**
24
+ * Harness profile for DD-3 skill-frontmatter measurement (#2463).
25
+ * When unset, the gate auto-detects `cursor` when `content/skills/` exists.
26
+ */
27
+ readonly harnessProfile?: HarnessProfile;
28
+ /**
29
+ * Skill tier for DD-3 frontmatter caps (`daily-core` vs `all` vs `none`).
30
+ * Defaults to `all` for reporting when unset.
31
+ */
32
+ readonly skillFrontmatterTier?: SkillFrontmatterTier;
33
+ /**
34
+ * Optional fail-closed ratchet for DD-3 skill-frontmatter bytes (#2463).
35
+ * Advisory when unset; growth past this ceiling fails when set.
36
+ */
37
+ readonly skillFrontmatterMaxBytes?: number;
15
38
  }
16
39
  export type AgentsMdBudgetSource = "typed" | "unset" | "default-on-error";
17
40
  export interface AgentsMdBudgetResult {
@@ -39,6 +39,19 @@ function readRegion(block, key) {
39
39
  }
40
40
  return { value: raw, error: null };
41
41
  }
42
+ function readOptionalRegion(block, key) {
43
+ if (!(key in block)) {
44
+ return { value: undefined, error: null };
45
+ }
46
+ const raw = block[key];
47
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
48
+ return {
49
+ value: undefined,
50
+ error: `plan.policy.agentsMdBudget.${key} must be a non-negative integer; got ${pythonTypeName(raw)} (${pythonRepr(raw)})`,
51
+ };
52
+ }
53
+ return { value: raw, error: null };
54
+ }
42
55
  /** Resolve plan.policy.agentsMdBudget from PROJECT-DEFINITION (#645). */
43
56
  export function resolveAgentsMdBudget(projectRoot) {
44
57
  const [data, err] = loadProjectDefinition(projectRoot);
@@ -77,13 +90,71 @@ export function resolveAgentsMdBudget(projectRoot) {
77
90
  if (unmanaged.error !== null) {
78
91
  return { budget: null, source: "default-on-error", error: unmanaged.error };
79
92
  }
93
+ const absolute = readOptionalRegion(block, "absoluteMaxBytes");
94
+ if (absolute.error !== null) {
95
+ return { budget: null, source: "default-on-error", error: absolute.error };
96
+ }
97
+ const skillFrontmatterMax = readOptionalRegion(block, "skillFrontmatterMaxBytes");
98
+ if (skillFrontmatterMax.error !== null) {
99
+ return { budget: null, source: "default-on-error", error: skillFrontmatterMax.error };
100
+ }
101
+ const harnessProfile = readOptionalHarnessProfile(block, "harnessProfile");
102
+ if (harnessProfile.error !== null) {
103
+ return { budget: null, source: "default-on-error", error: harnessProfile.error };
104
+ }
105
+ const skillFrontmatterTier = readOptionalSkillTier(block, "skillFrontmatterTier");
106
+ if (skillFrontmatterTier.error !== null) {
107
+ return { budget: null, source: "default-on-error", error: skillFrontmatterTier.error };
108
+ }
109
+ const budget = {
110
+ managedMaxLines: managed.value,
111
+ unmanagedMaxLines: unmanaged.value,
112
+ };
113
+ const withOptional = {
114
+ ...budget,
115
+ ...(absolute.value !== undefined ? { absoluteMaxBytes: absolute.value } : {}),
116
+ ...(skillFrontmatterMax.value !== undefined
117
+ ? { skillFrontmatterMaxBytes: skillFrontmatterMax.value }
118
+ : {}),
119
+ ...(harnessProfile.value !== undefined ? { harnessProfile: harnessProfile.value } : {}),
120
+ ...(skillFrontmatterTier.value !== undefined
121
+ ? { skillFrontmatterTier: skillFrontmatterTier.value }
122
+ : {}),
123
+ };
80
124
  return {
81
- budget: {
82
- managedMaxLines: managed.value,
83
- unmanagedMaxLines: unmanaged.value,
84
- },
125
+ budget: withOptional,
85
126
  source: "typed",
86
127
  error: null,
87
128
  };
88
129
  }
130
+ const HARNESS_PROFILES = new Set(["cursor", "none"]);
131
+ const SKILL_FRONTMATTER_TIERS = new Set(["daily-core", "all", "none"]);
132
+ function readOptionalHarnessProfile(block, key) {
133
+ if (!(key in block)) {
134
+ return { value: undefined, error: null };
135
+ }
136
+ const raw = block[key];
137
+ if (typeof raw !== "string" || !HARNESS_PROFILES.has(raw)) {
138
+ return {
139
+ value: undefined,
140
+ error: `plan.policy.agentsMdBudget.${key} must be one of ${[...HARNESS_PROFILES].join(", ")}; ` +
141
+ `got ${pythonTypeName(raw)} (${pythonRepr(raw)})`,
142
+ };
143
+ }
144
+ return { value: raw, error: null };
145
+ }
146
+ function readOptionalSkillTier(block, key) {
147
+ if (!(key in block)) {
148
+ return { value: undefined, error: null };
149
+ }
150
+ const raw = block[key];
151
+ if (typeof raw !== "string" || !SKILL_FRONTMATTER_TIERS.has(raw)) {
152
+ return {
153
+ value: undefined,
154
+ error: `plan.policy.agentsMdBudget.${key} must be one of ${[...SKILL_FRONTMATTER_TIERS].join(", ")}; ` +
155
+ `got ${pythonTypeName(raw)} (${pythonRepr(raw)})`,
156
+ };
157
+ }
158
+ return { value: raw, error: null };
159
+ }
89
160
  //# sourceMappingURL=agents-md-budget.js.map
@@ -2,8 +2,11 @@
2
2
  export declare const ACTIVE_FOLDER = "active";
3
3
  /** Canonical eligibility status — only `running` signals an active handoff. */
4
4
  export declare const ELIGIBLE_STATUS = "running";
5
- /** Actionable redirect appended to every reject path (#810). */
6
- export declare const ACTIVATE_HINT = "Run `task vbrief:activate {path}` before spawning an implementation agent.";
5
+ /** Actionable redirect appended to every reject path (#810 / #2449). */
6
+ export declare const ACTIVATE_HINT = "Run `task scope:activate -- {path}` (or legacy `task vbrief:activate -- {path}`) before spawning an implementation agent.";
7
+ /** Lifecycle folder names eligible for implementation (#810). */
8
+ export declare const ELIGIBLE_LIFECYCLE_DIRS: readonly ["xbrief/active", "vbrief/active"];
9
+ export declare const PREFLIGHT_USAGE_HINT = "Expected: `task xbrief:preflight -- xbrief/active/<story>.xbrief.json` (legacy: `task vbrief:preflight -- <path>`).";
7
10
  /** Result of a vBRIEF preflight evaluation; mirrors the Python `evaluate` tuple. */
8
11
  export interface EvaluateResult {
9
12
  readonly exitCode: 0 | 1;
@@ -4,14 +4,17 @@ import { basename, dirname } from "node:path";
4
4
  export const ACTIVE_FOLDER = "active";
5
5
  /** Canonical eligibility status — only `running` signals an active handoff. */
6
6
  export const ELIGIBLE_STATUS = "running";
7
- /** Actionable redirect appended to every reject path (#810). */
8
- export const ACTIVATE_HINT = "Run `task vbrief:activate {path}` before spawning an implementation agent.";
7
+ /** Actionable redirect appended to every reject path (#810 / #2449). */
8
+ export const ACTIVATE_HINT = "Run `task scope:activate -- {path}` (or legacy `task vbrief:activate -- {path}`) before spawning an implementation agent.";
9
+ /** Lifecycle folder names eligible for implementation (#810). */
10
+ export const ELIGIBLE_LIFECYCLE_DIRS = ["xbrief/active", "vbrief/active"];
11
+ export const PREFLIGHT_USAGE_HINT = "Expected: `task xbrief:preflight -- xbrief/active/<story>.xbrief.json` (legacy: `task vbrief:preflight -- <path>`).";
9
12
  /** Substitute `{path}` without `$`-pattern expansion in user paths (#1721). */
10
13
  export function formatActivateHint(path) {
11
14
  return ACTIVATE_HINT.replace("{path}", () => path);
12
15
  }
13
16
  function buildReject(path, reason) {
14
- return `${reason}\n ${formatActivateHint(path)}`;
17
+ return `${reason}\n ${PREFLIGHT_USAGE_HINT}\n ${formatActivateHint(path)}`;
15
18
  }
16
19
  /** Map Node `JSON.parse` errors to CPython `json.JSONDecodeError.msg` for parity (#1721). */
17
20
  function nodeJsonErrorToPythonMsg(nodeMessage) {
@@ -91,10 +94,12 @@ export function evaluate(vbriefPath) {
91
94
  };
92
95
  }
93
96
  const folder = basename(dirname(vbriefPath));
97
+ const parent = basename(dirname(dirname(vbriefPath)));
98
+ const lifecycleDir = `${parent}/${folder}`;
94
99
  if (folder !== ACTIVE_FOLDER) {
95
100
  return {
96
101
  exitCode: 1,
97
- message: buildReject(path, `vBRIEF is in ${folder}/ -- only vbrief/active/ is eligible for implementation.`),
102
+ message: buildReject(path, `xBRIEF is in ${lifecycleDir}/ -- only xbrief/active/ (or legacy vbrief/active/) is eligible for implementation.`),
98
103
  };
99
104
  }
100
105
  const record = payload;
@@ -1,3 +1,3 @@
1
1
  export type { EvaluateResult } from "./evaluate.js";
2
- export { ACTIVATE_HINT, ACTIVE_FOLDER, ELIGIBLE_STATUS, emitJson, evaluate, formatActivateHint, } from "./evaluate.js";
2
+ export { ACTIVATE_HINT, ACTIVE_FOLDER, ELIGIBLE_LIFECYCLE_DIRS, ELIGIBLE_STATUS, emitJson, evaluate, formatActivateHint, PREFLIGHT_USAGE_HINT, } from "./evaluate.js";
3
3
  //# sourceMappingURL=index.d.ts.map
@@ -1,2 +1,2 @@
1
- export { ACTIVATE_HINT, ACTIVE_FOLDER, ELIGIBLE_STATUS, emitJson, evaluate, formatActivateHint, } from "./evaluate.js";
1
+ export { ACTIVATE_HINT, ACTIVE_FOLDER, ELIGIBLE_LIFECYCLE_DIRS, ELIGIBLE_STATUS, emitJson, evaluate, formatActivateHint, PREFLIGHT_USAGE_HINT, } from "./evaluate.js";
2
2
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,3 @@
1
+ /** Seed a real on-disk project root for pipeline write-path tests (#2470). */
2
+ export declare function seedReleaseProjectDir(changelog?: string): string;
3
+ //# sourceMappingURL=pipeline-fixture.d.ts.map
@@ -0,0 +1,11 @@
1
+ import { mkdtempSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ /** Seed a real on-disk project root for pipeline write-path tests (#2470). */
5
+ export function seedReleaseProjectDir(changelog = `## [Unreleased]\n\n### Added\n- x\n`) {
6
+ const dir = mkdtempSync(join(tmpdir(), "release-proj-"));
7
+ writeFileSync(join(dir, "CHANGELOG.md"), changelog, "utf8");
8
+ writeFileSync(join(dir, "ROADMAP.md"), "# Roadmap\n", "utf8");
9
+ return dir;
10
+ }
11
+ //# sourceMappingURL=pipeline-fixture.js.map
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { assertProjectionContained } from "../fs/projection-containment.js";
3
4
  import { prependUpgradeBanner, promoteChangelog, sectionForVersion } from "./changelog.js";
4
5
  import { EXIT_CONFIG_ERROR, EXIT_OK, EXIT_VIOLATION, RELEASE_ARTIFACTS, TOTAL_STEPS, VERIFY_DRAFT_INTERVAL_SECONDS, VERIFY_DRAFT_MAX_ATTEMPTS, } from "./constants.js";
5
6
  import { checkTagAvailable, createGithubRelease, readTextFile, verifyReleaseDraft } from "./gh.js";
@@ -16,6 +17,7 @@ export function runPipeline(config, seams = {}) {
16
17
  const version = config.version;
17
18
  const today = (seams.todayIso ?? todayIso)();
18
19
  const changelogPath = join(projectRoot, "CHANGELOG.md");
20
+ const roadmapPath = join(projectRoot, "ROADMAP.md");
19
21
  const readFile = seams.readFile ?? ((p) => readFileSync(p, "utf8"));
20
22
  const writeFile = seams.writeFile ?? ((p, c) => writeFileSync(p, c, "utf8"));
21
23
  const fileExists = seams.fileExists ?? ((p) => existsSync(p));
@@ -149,6 +151,7 @@ export function runPipeline(config, seams = {}) {
149
151
  emit(6, label, `DRYRUN (would rewrite CHANGELOG.md: ## [Unreleased] -> ## [${version}] - ${today}; new compare link added;${summaryNote})`);
150
152
  }
151
153
  else {
154
+ assertProjectionContained(projectRoot, changelogPath);
152
155
  writeFile(changelogPath, promotedChangelog);
153
156
  emit(6, label, `OK (## [${version}] - ${today};${summaryNote})`);
154
157
  }
@@ -158,6 +161,7 @@ export function runPipeline(config, seams = {}) {
158
161
  emit(7, label, "DRYRUN (would run task roadmap:render)");
159
162
  }
160
163
  else {
164
+ assertProjectionContained(projectRoot, roadmapPath);
161
165
  const [ok, reason] = refreshRoadmapFn(projectRoot);
162
166
  if (ok) {
163
167
  emit(7, label, `OK (${reason})`);
@@ -1,12 +1,14 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { SUBPROCESS_MAX_BUFFER } from "../subprocess/max-buffer.js";
3
3
  export function defaultWhich(name) {
4
- const result = spawnSync("which", [name], { encoding: "utf8" });
4
+ const locator = process.platform === "win32" ? "where" : "which";
5
+ const result = spawnSync(locator, [name], { encoding: "utf8" });
5
6
  if (result.status !== 0) {
6
7
  return null;
7
8
  }
8
- const path = (result.stdout ?? "").trim();
9
- return path || null;
9
+ // `where` on Windows may return multiple lines; take the first non-empty match.
10
+ const first = (result.stdout ?? "").split(/\r?\n/).find((line) => line.trim().length > 0);
11
+ return first ? first.trim() : null;
10
12
  }
11
13
  export function spawnText(cmd, args, options = {}) {
12
14
  const result = spawnSync(cmd, [...args], {
@@ -7,7 +7,7 @@
7
7
  * that draft, writes the child story vBRIEFs, and updates the parent scope references.
8
8
  */
9
9
  import { accessSync, constants, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
- import { basename, dirname, isAbsolute, join, resolve } from "node:path";
10
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
11
11
  import { referenceTypeMatches } from "@deftai/directive-types";
12
12
  import { hasArtifactSuffix, LEGACY_ARTIFACT_DIR, MIGRATED_ARTIFACT_DIR, resolveLifecycleRoot, } from "../layout/resolve.js";
13
13
  import { referenceWithDefaultTrust, slugify } from "../vbrief-build/build.js";
@@ -415,7 +415,7 @@ function vbriefDir(projectRoot) {
415
415
  function relToVbrief(vbriefDirPath, path) {
416
416
  const resolvedPath = resolve(path);
417
417
  const resolvedVbrief = resolve(vbriefDirPath);
418
- if (resolvedPath.startsWith(`${resolvedVbrief}/`) || resolvedPath === resolvedVbrief) {
418
+ if (resolvedPath.startsWith(resolvedVbrief + sep) || resolvedPath === resolvedVbrief) {
419
419
  return resolvedPath.slice(resolvedVbrief.length + 1).replace(/\\/g, "/");
420
420
  }
421
421
  throw new DecompositionError(`${path}: path must be inside ${vbriefDirPath}`);
@@ -823,7 +823,8 @@ export function applyDecomposition(opts) {
823
823
  if (!LIFECYCLE_FOLDERS.has(outputFolderName)) {
824
824
  throw new DecompositionError("output_dir must be a vbrief lifecycle folder");
825
825
  }
826
- if (!outputDir.startsWith(`${resolve(vbriefDirPath)}/`) && outputDir !== resolve(vbriefDirPath)) {
826
+ const resolvedVbriefDir = resolve(vbriefDirPath);
827
+ if (!outputDir.startsWith(resolvedVbriefDir + sep) && outputDir !== resolvedVbriefDir) {
827
828
  throw new DecompositionError("output_dir must be inside vbrief/");
828
829
  }
829
830
  if (outputFolderName === "active") {
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import { InstrumentedVbriefCrud, persistCrudMetrics } from "../eval/crud-telemetry.js";
4
+ import { assertProjectionContained, ProjectionContainmentError, } from "../fs/projection-containment.js";
4
5
  import { hasArtifactSuffix } from "../layout/resolve.js";
5
6
  import { append, canonicalLogPath, newDecisionId } from "./audit-log.js";
6
7
  import { stampCompletionMetadata } from "./capacity-stamp.js";
@@ -76,11 +77,25 @@ export function runTransition(action, filePath, now = new Date()) {
76
77
  message: `No-op: ${basename} is already in ${currentFolder}/ (status: ${currentStatus})`,
77
78
  };
78
79
  }
80
+ const vbriefRoot = dirname(dirname(resolvedPath));
81
+ const projectRoot = dirname(vbriefRoot);
82
+ if (targetFolder !== null) {
83
+ const destDir = join(vbriefRoot, targetFolder);
84
+ try {
85
+ // #2447: refuse lifecycle moves when the destination folder escapes the checkout.
86
+ // Run before mutating the source file so a refusal leaves lifecycle state intact.
87
+ assertProjectionContained(projectRoot, destDir);
88
+ }
89
+ catch (err) {
90
+ if (err instanceof ProjectionContainmentError) {
91
+ return { ok: false, message: err.message };
92
+ }
93
+ throw err;
94
+ }
95
+ }
79
96
  const nowIso = utcNowIso(now);
80
97
  planObj.status = targetStatus;
81
98
  planObj.updated = nowIso;
82
- const vbriefRoot = dirname(dirname(resolvedPath));
83
- const projectRoot = dirname(vbriefRoot);
84
99
  if (act === "complete") {
85
100
  stampCompletionMetadata(planObj, projectRoot, nowIso);
86
101
  }
@@ -1,4 +1,10 @@
1
+ import { ProjectionContainmentError } from "../fs/projection-containment.js";
1
2
  import { type ResolveLifecycleArtifactRefOptions } from "../layout/lifecycle-ref.js";
3
+ export { ProjectionContainmentError as LifecycleRefContainmentError };
4
+ /** Reject absolute or `..`-escaping lifecycle refs before path resolution (#2470). */
5
+ export declare function rejectEscapingLifecycleRel(rel: string): void;
6
+ /** Assert a resolved lifecycle artifact path stays under the lifecycle root (#2470). */
7
+ export declare function assertLifecycleArtifactContained(lifecycleRoot: string, resolvedPath: string): void;
2
8
  /** Resolve a vBRIEF reference URI to an absolute path, or null. */
3
9
  export declare function resolveVbriefRef(uri: unknown, vbriefDir: string, options?: ResolveLifecycleArtifactRefOptions): string | null;
4
10
  /** Collect planRef values from the plan root and top-level items. */