@deftai/directive-core 0.75.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 (41) hide show
  1. package/dist/agents-md-budget/evaluate.js +23 -3
  2. package/dist/agents-md-budget/skill-frontmatter.d.ts +2 -0
  3. package/dist/agents-md-budget/skill-frontmatter.js +4 -3
  4. package/dist/cache/operations.d.ts +2 -0
  5. package/dist/cache/operations.js +16 -1
  6. package/dist/init-deposit/scaffold.js +28 -17
  7. package/dist/intake/issue-ingest.js +23 -4
  8. package/dist/packaging/openpackage-tiers.d.ts +12 -0
  9. package/dist/packaging/openpackage-tiers.js +42 -0
  10. package/dist/preflight/evaluate.d.ts +5 -2
  11. package/dist/preflight/evaluate.js +9 -4
  12. package/dist/preflight/index.d.ts +1 -1
  13. package/dist/preflight/index.js +1 -1
  14. package/dist/release/pipeline-fixture.d.ts +3 -0
  15. package/dist/release/pipeline-fixture.js +11 -0
  16. package/dist/release/pipeline.js +4 -0
  17. package/dist/scope/transition.js +17 -2
  18. package/dist/scope/vbrief-ref.d.ts +6 -0
  19. package/dist/scope/vbrief-ref.js +21 -2
  20. package/dist/session/index.d.ts +1 -0
  21. package/dist/session/index.js +1 -0
  22. package/dist/session/posture.d.ts +50 -0
  23. package/dist/session/posture.js +152 -0
  24. package/dist/session/ritual-sentinel.d.ts +2 -0
  25. package/dist/session/ritual-sentinel.js +3 -0
  26. package/dist/session/session-start.d.ts +8 -0
  27. package/dist/session/session-start.js +43 -0
  28. package/dist/session/verify-session-ritual.d.ts +6 -0
  29. package/dist/session/verify-session-ritual.js +67 -4
  30. package/dist/triage/bootstrap/gitignore.js +8 -4
  31. package/dist/triage/cache-path.js +16 -3
  32. package/dist/triage/welcome/writers.js +2 -0
  33. package/dist/ts-check-lane/index.d.ts +1 -1
  34. package/dist/ts-check-lane/index.js +1 -1
  35. package/dist/ts-check-lane/run-lane.d.ts +5 -0
  36. package/dist/ts-check-lane/run-lane.js +15 -0
  37. package/dist/vbrief-build/constants.d.ts +3 -3
  38. package/dist/vbrief-build/constants.js +4 -3
  39. package/dist/vbrief-build/index.d.ts +1 -1
  40. package/dist/vbrief-build/index.js +1 -1
  41. package/package.json +3 -3
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
+ import { getOpenPackageDefaultInstallTier, resolveOpenPackageTierSkills, } from "../packaging/openpackage-tiers.js";
3
4
  import { AGENTS_MANAGED_CLOSE } from "../platform/constants.js";
4
5
  import { resolveAgentsMdBudget, } from "../policy/agents-md-budget.js";
5
6
  import { measureSkillFrontmatter } from "./skill-frontmatter.js";
@@ -123,12 +124,23 @@ function resolveHarnessProfile(budget, projectRoot) {
123
124
  }
124
125
  return "none";
125
126
  }
126
- function resolveSkillFrontmatterTier(budget) {
127
+ function resolveBootstrapSkillTier(budget, projectRoot) {
127
128
  const env = process.env.DEFT_AGENTS_MD_BUDGET_SKILL_TIER?.trim();
128
129
  if (env === "daily-core" || env === "all" || env === "none") {
129
130
  return env;
130
131
  }
131
- return budget?.skillFrontmatterTier ?? "all";
132
+ if (budget?.skillFrontmatterTier !== undefined) {
133
+ return budget.skillFrontmatterTier;
134
+ }
135
+ try {
136
+ if (getOpenPackageDefaultInstallTier(projectRoot) === "daily-core") {
137
+ return "daily-core";
138
+ }
139
+ }
140
+ catch {
141
+ // No OpenPackage manifest at this project root — fall through.
142
+ }
143
+ return "all";
132
144
  }
133
145
  /** Measure managed + DD-3 skill frontmatter + bootstrap hooks. */
134
146
  export function measureBootstrapSurface(projectRoot, managedText, budget) {
@@ -137,11 +149,19 @@ export function measureBootstrapSurface(projectRoot, managedText, budget) {
137
149
  return managedResult;
138
150
  }
139
151
  const harnessProfile = resolveHarnessProfile(budget, projectRoot);
140
- const tier = resolveSkillFrontmatterTier(budget);
152
+ const tier = resolveBootstrapSkillTier(budget, projectRoot);
153
+ let dailyCoreSkills;
154
+ try {
155
+ dailyCoreSkills = resolveOpenPackageTierSkills(projectRoot, "daily-core");
156
+ }
157
+ catch {
158
+ // Maintainer trees without packaging/openpackage fall back to hardcoded daily-core.
159
+ }
141
160
  const skillFrontmatter = measureSkillFrontmatter(projectRoot, {
142
161
  harnessProfile,
143
162
  tier,
144
163
  bytesPerToken: ABSOLUTE_BYTES_PER_TOKEN_ESTIMATE,
164
+ dailyCoreSkills,
145
165
  });
146
166
  const totalBytes = managedResult.bytes + skillFrontmatter.bytes + BOOTSTRAP_HOOK_BYTES;
147
167
  return {
@@ -27,6 +27,8 @@ export interface MeasureSkillFrontmatterOptions {
27
27
  readonly tier?: SkillFrontmatterTier;
28
28
  readonly harnessProfile?: HarnessProfile;
29
29
  readonly bytesPerToken?: number;
30
+ /** Override daily-core skill names (from OpenPackage resolveTierSkills). */
31
+ readonly dailyCoreSkills?: readonly string[];
30
32
  }
31
33
  /** Measure harness-injected skill frontmatter bytes for the configured profile/tier. */
32
34
  export declare function measureSkillFrontmatter(projectRoot: string, options?: MeasureSkillFrontmatterOptions): SkillFrontmatterMeasure;
@@ -61,14 +61,14 @@ function listSkillDirs(skillsRoot) {
61
61
  .map((entry) => entry.name)
62
62
  .sort();
63
63
  }
64
- function skillMatchesTier(skillName, tier) {
64
+ function skillInSelectedTier(skillName, tier, dailyCore) {
65
65
  if (tier === "none") {
66
66
  return false;
67
67
  }
68
68
  if (tier === "all") {
69
69
  return true;
70
70
  }
71
- return DAILY_CORE_SET.has(skillName);
71
+ return dailyCore.has(skillName);
72
72
  }
73
73
  /** Measure harness-injected skill frontmatter bytes for the configured profile/tier. */
74
74
  export function measureSkillFrontmatter(projectRoot, options = {}) {
@@ -76,6 +76,7 @@ export function measureSkillFrontmatter(projectRoot, options = {}) {
76
76
  const tier = options.tier ?? "all";
77
77
  const bytesPerToken = options.bytesPerToken ?? 4;
78
78
  const skillsRoot = options.skillsRoot ?? defaultSkillsRoot(projectRoot);
79
+ const dailyCore = options.dailyCoreSkills !== undefined ? new Set(options.dailyCoreSkills) : DAILY_CORE_SET;
79
80
  if (harnessProfile === "none" || tier === "none") {
80
81
  return {
81
82
  bytes: 0,
@@ -88,7 +89,7 @@ export function measureSkillFrontmatter(projectRoot, options = {}) {
88
89
  }
89
90
  const entries = [];
90
91
  for (const skillName of listSkillDirs(skillsRoot)) {
91
- if (!skillMatchesTier(skillName, tier)) {
92
+ if (!skillInSelectedTier(skillName, tier, dailyCore)) {
92
93
  continue;
93
94
  }
94
95
  const skillPath = join(skillsRoot, skillName, "SKILL.md");
@@ -1,7 +1,9 @@
1
+ import { ProjectionContainmentError } from "../fs/projection-containment.js";
1
2
  import { type CacheCaps, type EntryUsage } from "./quota.js";
2
3
  import { SCANNER_VERSION } from "./scanner.js";
3
4
  import { type Clock } from "./time.js";
4
5
  import type { CacheGetOptions, CachePutOptions, GetResult, PutResult } from "./types.js";
6
+ export { ProjectionContainmentError as CacheContainmentError };
5
7
  /** Write a cache entry (mirrors `cache.cache_put`). */
6
8
  export declare function cachePut(source: string, key: string, raw: Record<string, unknown>, options?: CachePutOptions): PutResult;
7
9
  /** Read a cache entry (mirrors `cache.cache_get`). */
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { assertProjectionContained, ProjectionContainmentError, } from "../fs/projection-containment.js";
3
4
  import { pyRepr } from "../scm/py-format.js";
4
5
  import { ALLOWED_SOURCES, REPO_RE, SOURCE_TTL_SECONDS } from "./constants.js";
5
6
  import { CacheCapBreachedError, CacheError, CacheNotFoundError, CacheValidationError, } from "./errors.js";
@@ -10,6 +11,15 @@ import { enforceCaps, predictEvictionSet, resolveCaps, } from "./quota.js";
10
11
  import { flagsForMeta, SCANNER_VERSION, scan } from "./scanner.js";
11
12
  import { addSeconds, parseIso, systemClock, utcIso } from "./time.js";
12
13
  import { validateMeta } from "./validate.js";
14
+ export { ProjectionContainmentError as CacheContainmentError };
15
+ /** Refuse symlink-escaping `.deft-cache` before mkdir/read/write (#2470). */
16
+ function assertWritableCachePath(cacheRoot, ...segments) {
17
+ const cacheAbs = resolve(cacheRoot);
18
+ const projectDir = dirname(cacheAbs);
19
+ const target = segments.length > 0 ? join(cacheAbs, ...segments) : cacheAbs;
20
+ assertProjectionContained(projectDir, target);
21
+ return target;
22
+ }
13
23
  function renderContent(source, raw) {
14
24
  if (source === "github-issue") {
15
25
  const number = raw.number;
@@ -82,6 +92,7 @@ export function cachePut(source, key, raw, options = {}) {
82
92
  }
83
93
  const expires = addSeconds(fetched, ttl);
84
94
  const cacheRoot = options.cacheRoot ?? ".deft-cache";
95
+ assertWritableCachePath(cacheRoot);
85
96
  const edir = entryDir(source, key, cacheRoot);
86
97
  const rawText = pythonJsonDump(raw);
87
98
  const rawSize = Buffer.byteLength(rawText, "utf8");
@@ -182,6 +193,7 @@ export function cachePut(source, key, raw, options = {}) {
182
193
  export function cacheGet(source, key, options = {}) {
183
194
  const clock = options.clock ?? systemClock;
184
195
  const cacheRoot = options.cacheRoot ?? ".deft-cache";
196
+ assertWritableCachePath(cacheRoot);
185
197
  const allowStale = options.allowStale ?? true;
186
198
  const edir = entryDir(source, key, cacheRoot);
187
199
  const metaRelPath = `${source}/${key}/meta.json`;
@@ -220,6 +232,7 @@ export function cacheInvalidate(source, key, options = {}) {
220
232
  const clock = options.clock ?? systemClock;
221
233
  validateKey(source, key);
222
234
  const cacheRoot = options.cacheRoot ?? ".deft-cache";
235
+ assertWritableCachePath(cacheRoot);
223
236
  const edir = entryDir(source, key, cacheRoot);
224
237
  const existed = existsSync(edir);
225
238
  if (existed)
@@ -252,6 +265,7 @@ export function cachePrune(options = {}) {
252
265
  throw new CacheError(`--older-than-days must be >= 0 (got ${JSON.stringify(olderThanDays)})`);
253
266
  }
254
267
  const cacheRoot = options.cacheRoot ?? ".deft-cache";
268
+ assertWritableCachePath(cacheRoot);
255
269
  if (!existsSync(cacheRoot))
256
270
  return [];
257
271
  const cutoff = addSeconds(clock.now(), -olderThanDays * 24 * 60 * 60);
@@ -313,6 +327,7 @@ function collectMetaPathsUnder(srcRoot) {
313
327
  export function cachePruneToCap(options = {}) {
314
328
  const clock = options.clock ?? systemClock;
315
329
  const cacheRoot = options.cacheRoot ?? ".deft-cache";
330
+ assertWritableCachePath(cacheRoot);
316
331
  const resolved = options.caps ?? resolveCaps();
317
332
  if (!resolved.maxBytes && !resolved.maxEntries)
318
333
  return [];
@@ -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");
@@ -466,7 +474,7 @@ export function writeConsumerGitHooks(projectDir, deftDir, io, seams = {}) {
466
474
  io.printf(`git hooks source ${srcDir} absent — skipping hook wiring.\n`);
467
475
  return false;
468
476
  }
469
- const dstDir = join(projectDir, ".githooks");
477
+ const dstDir = projectionTarget(projectDir, ".githooks");
470
478
  mkdirSync(dstDir, { recursive: true });
471
479
  let filesDeposited = false;
472
480
  for (const name of [...HOOK_FILENAMES, ...HOOK_SUPPORT_FILENAMES]) {
@@ -474,7 +482,7 @@ export function writeConsumerGitHooks(projectDir, deftDir, io, seams = {}) {
474
482
  if (!existsSync(src))
475
483
  continue;
476
484
  const data = readFileSync(src);
477
- const dst = join(dstDir, name);
485
+ const dst = projectionTarget(projectDir, ".githooks", name);
478
486
  const existing = existsSync(dst) ? readFileSync(dst) : null;
479
487
  const isHookScript = HOOK_FILENAMES.includes(name);
480
488
  if (!existing?.equals(data)) {
@@ -656,7 +664,7 @@ function coreGuardWorkflowContent() {
656
664
  ' echo "OK: no mixed framework + app changes."\n');
657
665
  }
658
666
  export function ensureGitattributes(projectDir, io) {
659
- const path = join(projectDir, ".gitattributes");
667
+ const path = projectionTarget(projectDir, ".gitattributes");
660
668
  const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
661
669
  const present = new Set(existing.split("\n").map((line) => line.trim()));
662
670
  const additions = CORE_GITATTRIBUTES_LINES.filter((line) => !present.has(line));
@@ -691,7 +699,7 @@ function appendGreptilePattern(patterns, glob) {
691
699
  return `${patterns}\n${glob}`;
692
700
  }
693
701
  export function ensureGreptileIgnore(projectDir, io) {
694
- const path = join(projectDir, "greptile.json");
702
+ const path = projectionTarget(projectDir, "greptile.json");
695
703
  const fileExisted = existsSync(path);
696
704
  let raw = fileExisted ? readFileSync(path, "utf8") : "";
697
705
  if (!raw.trim())
@@ -766,7 +774,7 @@ function insertCodeqlPathsIgnore(content, glob) {
766
774
  return { content, ok: false };
767
775
  }
768
776
  export function ensureCodeqlPathsIgnore(projectDir, io) {
769
- const path = join(projectDir, CODEQL_CONFIG_REL);
777
+ const path = projectionTarget(projectDir, CODEQL_CONFIG_REL);
770
778
  if (!existsSync(path)) {
771
779
  mkdirSync(dirname(path), { recursive: true });
772
780
  writeFileSync(path, codeqlConfigDefault(), "utf8");
@@ -787,7 +795,7 @@ export function ensureCodeqlPathsIgnore(projectDir, io) {
787
795
  return true;
788
796
  }
789
797
  export function ensureCoreGuardWorkflow(projectDir, io) {
790
- const path = join(projectDir, CORE_GUARD_WORKFLOW_REL);
798
+ const path = projectionTarget(projectDir, CORE_GUARD_WORKFLOW_REL);
791
799
  const desired = coreGuardWorkflowContent();
792
800
  if (existsSync(path)) {
793
801
  const existing = readFileSync(path, "utf8");
@@ -866,6 +874,9 @@ export async function depositNeutralization(projectDir, io, options = {}) {
866
874
  await step();
867
875
  }
868
876
  catch (cause) {
877
+ if (cause instanceof ProjectionContainmentError) {
878
+ throw cause;
879
+ }
869
880
  io.printf(`Warning: neutralization step failed: ${String(cause)}\n`);
870
881
  }
871
882
  }
@@ -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
@@ -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,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. */
@@ -1,7 +1,23 @@
1
- import { resolve, sep } from "node:path";
1
+ import { isAbsolute, resolve, sep } from "node:path";
2
2
  import { referenceTypeMatches } from "@deftai/directive-types";
3
+ import { assertProjectionContained, ProjectionContainmentError, } from "../fs/projection-containment.js";
3
4
  import { resolveLifecycleArtifactRef, } from "../layout/lifecycle-ref.js";
4
5
  import { hasArtifactSuffix, stripArtifactSuffix } from "../layout/resolve.js";
6
+ export { ProjectionContainmentError as LifecycleRefContainmentError };
7
+ /** Reject absolute or `..`-escaping lifecycle refs before path resolution (#2470). */
8
+ export function rejectEscapingLifecycleRel(rel) {
9
+ if (isAbsolute(rel)) {
10
+ throw new ProjectionContainmentError(`lifecycle ref refused: absolute path ${rel} is not allowed under the lifecycle root`, { projectDir: "", targetPath: rel, offendingPath: rel });
11
+ }
12
+ const segments = rel.split(/[/\\]+/).filter((segment) => segment.length > 0);
13
+ if (segments.includes("..")) {
14
+ throw new ProjectionContainmentError(`lifecycle ref refused: path ${rel} contains parent traversal (..)`, { projectDir: "", targetPath: rel, offendingPath: rel });
15
+ }
16
+ }
17
+ /** Assert a resolved lifecycle artifact path stays under the lifecycle root (#2470). */
18
+ export function assertLifecycleArtifactContained(lifecycleRoot, resolvedPath) {
19
+ assertProjectionContained(lifecycleRoot, resolvedPath);
20
+ }
5
21
  /** Resolve a vBRIEF reference URI to an absolute path, or null. */
6
22
  export function resolveVbriefRef(uri, vbriefDir, options = {}) {
7
23
  if (typeof uri !== "string" || uri.length === 0) {
@@ -17,7 +33,10 @@ export function resolveVbriefRef(uri, vbriefDir, options = {}) {
17
33
  else {
18
34
  rel = uri;
19
35
  }
20
- return resolveLifecycleArtifactRef(rel, vbriefDir, options);
36
+ rejectEscapingLifecycleRel(rel);
37
+ const resolved = resolveLifecycleArtifactRef(rel, vbriefDir, options);
38
+ assertLifecycleArtifactContained(vbriefDir, resolved);
39
+ return resolved;
21
40
  }
22
41
  /** Collect planRef values from the plan root and top-level items. */
23
42
  export function collectPlanRefs(plan) {