@deftai/directive-core 0.73.1 → 0.75.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 +321 -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 +33 -0
  6. package/dist/agents-md-budget/skill-frontmatter.js +115 -0
  7. package/dist/check/orchestrator.d.ts +3 -0
  8. package/dist/check/orchestrator.js +2 -1
  9. package/dist/codebase/map.js +2 -0
  10. package/dist/content-contracts/skills/helpers.d.ts +0 -1
  11. package/dist/content-contracts/skills/helpers.js +1 -11
  12. package/dist/content-contracts/skills/skill-frontmatter.js +8 -1
  13. package/dist/content-contracts/standards/_helpers.js +4 -2
  14. package/dist/content-contracts/standards/_taskfile-helpers.d.ts +2 -0
  15. package/dist/content-contracts/standards/_taskfile-helpers.js +11 -5
  16. package/dist/doctor/checks.d.ts +9 -0
  17. package/dist/doctor/checks.js +67 -0
  18. package/dist/doctor/doctor-state.js +2 -1
  19. package/dist/doctor/main.js +2 -1
  20. package/dist/doctor/paths.js +2 -1
  21. package/dist/eval-health-relocation/evaluate.d.ts +71 -0
  22. package/dist/eval-health-relocation/evaluate.js +252 -0
  23. package/dist/eval-health-relocation/index.d.ts +2 -0
  24. package/dist/eval-health-relocation/index.js +2 -0
  25. package/dist/fs/projection-containment.d.ts +35 -0
  26. package/dist/fs/projection-containment.js +118 -0
  27. package/dist/init-deposit/scaffold.d.ts +21 -0
  28. package/dist/init-deposit/scaffold.js +86 -9
  29. package/dist/intake/issue-emit.js +2 -2
  30. package/dist/policy/agents-md-budget.d.ts +23 -0
  31. package/dist/policy/agents-md-budget.js +75 -4
  32. package/dist/pr-merge-readiness/constants.d.ts +4 -0
  33. package/dist/pr-merge-readiness/constants.js +4 -0
  34. package/dist/pr-merge-readiness/evaluate.js +3 -0
  35. package/dist/pr-merge-readiness/mergeability.d.ts +8 -6
  36. package/dist/pr-merge-readiness/mergeability.js +15 -10
  37. package/dist/pr-merge-readiness/output.js +12 -6
  38. package/dist/pr-merge-readiness/parse.d.ts +1 -0
  39. package/dist/pr-merge-readiness/parse.js +9 -1
  40. package/dist/pr-merge-readiness/types.d.ts +2 -0
  41. package/dist/preflight-cache/evaluate.d.ts +2 -0
  42. package/dist/preflight-cache/evaluate.js +14 -3
  43. package/dist/preflight-cache/index.d.ts +1 -1
  44. package/dist/preflight-cache/index.js +1 -1
  45. package/dist/release/constants.d.ts +2 -0
  46. package/dist/release/constants.js +2 -0
  47. package/dist/release/preflight.d.ts +2 -0
  48. package/dist/release/preflight.js +14 -1
  49. package/dist/release/spawn.js +5 -3
  50. package/dist/scope/decompose.js +4 -3
  51. package/dist/scope/index.d.ts +1 -0
  52. package/dist/scope/index.js +1 -0
  53. package/dist/scope/main.js +24 -1
  54. package/dist/scope/open-umbrella-warning.d.ts +12 -0
  55. package/dist/scope/open-umbrella-warning.js +403 -0
  56. package/dist/scope/vbrief-ref.js +3 -3
  57. package/dist/swarm/complete-cohort.js +6 -6
  58. package/dist/swarm/launch.js +2 -2
  59. package/dist/triage/bootstrap/gitignore.d.ts +1 -1
  60. package/dist/triage/bootstrap/gitignore.js +69 -58
  61. package/dist/triage/queue/cache.d.ts +14 -0
  62. package/dist/triage/queue/cache.js +61 -1
  63. package/dist/validate-content/validate-links.js +2 -2
  64. package/dist/value/readback.d.ts +1 -0
  65. package/dist/value/readback.js +5 -1
  66. package/dist/vbrief-reconcile/umbrellas.js +12 -11
  67. package/dist/verify-env/toolchain-check.js +20 -5
  68. package/dist/verify-env/verify-hooks-installed.d.ts +2 -0
  69. package/dist/verify-env/verify-hooks-installed.js +17 -15
  70. package/dist/xbrief-migrate/migrate-project.js +9 -0
  71. package/package.json +7 -3
@@ -21,7 +21,7 @@ export function resolveRepoPath(relPath) {
21
21
  return repoPath(relPath);
22
22
  }
23
23
  export function readRepoFile(relPath) {
24
- return readFileSync(resolveRepoPath(relPath), "utf8");
24
+ return readFileSync(resolveRepoPath(relPath), "utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
25
25
  }
26
26
  export function repoFileExists(relPath) {
27
27
  return existsSync(resolveRepoPath(relPath));
@@ -59,15 +59,5 @@ export function listSkillMdFiles() {
59
59
  export const RFC2119_LEGEND = "!=MUST, ~=SHOULD";
60
60
  export const PLATFORM_DETECTION_HEADING = "## Platform Detection";
61
61
  export const USER_MD_GATE_HEADING = "## USER.md Gate";
62
- export const DEPRECATED_SKILL_REDIRECT_STUBS = new Set([
63
- "deft-build",
64
- "deft-interview",
65
- "deft-pre-pr",
66
- "deft-review-cycle",
67
- "deft-roadmap-refresh",
68
- "deft-setup",
69
- "deft-swarm",
70
- "deft-sync",
71
- ]);
72
62
  export const REQUIRED_OSES = new Set(["darwin", "linux", "windows"]);
73
63
  //# sourceMappingURL=helpers.js.map
@@ -4,7 +4,14 @@ import { REPO_ROOT, REQUIRED_OSES } from "./helpers.js";
4
4
  export { REQUIRED_OSES };
5
5
  const OS_LINE = /^os\s*:\s*\[(?<body>[^\]]*)\]\s*$/m;
6
6
  const OS_TOKEN = /['"]([^'"]+)['"]/g;
7
- const EXCLUDED_PARTS = new Set([".git", ".venv", "venv", "node_modules", "__pycache__"]);
7
+ const EXCLUDED_PARTS = new Set([
8
+ ".git",
9
+ ".venv",
10
+ "venv",
11
+ "node_modules",
12
+ "__pycache__",
13
+ ".pytest_cache",
14
+ ]);
8
15
  function frontmatter(text) {
9
16
  if (!text.startsWith("---")) {
10
17
  return null;
@@ -27,10 +27,12 @@ export function resolveContentPath(relPath) {
27
27
  return join(root, relPath);
28
28
  }
29
29
  export function readText(relPath) {
30
- return readFileSync(resolveContentPath(relPath), { encoding: "utf8" });
30
+ return readFileSync(resolveContentPath(relPath), { encoding: "utf8" })
31
+ .replace(/\r\n/g, "\n")
32
+ .replace(/\r/g, "\n");
31
33
  }
32
34
  export function readAbs(absPath) {
33
- return readFileSync(absPath, { encoding: "utf8" });
35
+ return readFileSync(absPath, { encoding: "utf8" }).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
34
36
  }
35
37
  export function pathExists(relPath) {
36
38
  return existsSync(resolveContentPath(relPath));
@@ -1,4 +1,6 @@
1
1
  export declare function taskYamlFiles(): string[];
2
+ /** Normalize CRLF so Taskfile parsers do not treat lone `\r` lines as section exits (#2467). */
3
+ export declare function normalizeYamlNewlines(text: string): string;
2
4
  export declare function iterTaskBlocks(text: string): Array<{
3
5
  name: string;
4
6
  start: number;
@@ -10,8 +10,12 @@ export function taskYamlFiles() {
10
10
  .map((f) => join(dir, f))
11
11
  .sort();
12
12
  }
13
+ /** Normalize CRLF so Taskfile parsers do not treat lone `\r` lines as section exits (#2467). */
14
+ export function normalizeYamlNewlines(text) {
15
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
16
+ }
13
17
  export function iterTaskBlocks(text) {
14
- const lines = text.split("\n");
18
+ const lines = normalizeYamlNewlines(text).split("\n");
15
19
  const taskPositions = [];
16
20
  let inTasksSection = false;
17
21
  for (let idx = 0; idx < lines.length; idx += 1) {
@@ -43,19 +47,21 @@ export function iterTaskBlocks(text) {
43
47
  return blocks;
44
48
  }
45
49
  export function blockBody(text, start, end) {
46
- return text.split("\n").slice(start, end).join("\n");
50
+ return normalizeYamlNewlines(text).split("\n").slice(start, end).join("\n");
47
51
  }
48
52
  export function nonCommentLines(block) {
49
- return block.split("\n").filter((ln) => !ln.trimStart().startsWith("#"));
53
+ return normalizeYamlNewlines(block)
54
+ .split("\n")
55
+ .filter((ln) => !ln.trimStart().startsWith("#"));
50
56
  }
51
57
  export function cachingKeyOnLine(line) {
52
58
  const m = line.match(CACHING_KEY);
53
59
  return m?.[1] ?? null;
54
60
  }
55
61
  export function readTaskfile(name) {
56
- return readFileSync(join(repoRoot(), "tasks", name), { encoding: "utf8" });
62
+ return normalizeYamlNewlines(readFileSync(join(repoRoot(), "tasks", name), { encoding: "utf8" }));
57
63
  }
58
64
  export function readRoot(name) {
59
- return readFileSync(join(repoRoot(), "tasks", "..", name), { encoding: "utf8" });
65
+ return normalizeYamlNewlines(readFileSync(join(repoRoot(), "tasks", "..", name), { encoding: "utf8" }));
60
66
  }
61
67
  //# sourceMappingURL=_taskfile-helpers.js.map
@@ -30,6 +30,15 @@ export declare function checkCanonicalVendoredNpmSignpost(projectRoot: string, s
30
30
  * deposit is still a working install, just an unpinned one.
31
31
  */
32
32
  export declare function checkManifestVersionReportable(projectRoot: string, installRoot: string | null, seams?: CheckSeams): CheckResult;
33
+ /** Relative path to the deposited v0.6 schema file superseded by xbrief-core-0.8.schema.json. */
34
+ export declare const STALE_VBRIEF_CORE_SCHEMA_REL: string;
35
+ /**
36
+ * #2368: on an already-migrated xbrief/ layout, a stale deposited
37
+ * `xbrief/schemas/vbrief-core.schema.json` (vBRIEFInfo v0.6) must NOT route
38
+ * operators to `migrate:xbrief` — that verb requires a vbrief/ tree. Point at
39
+ * `directive update` instead. Advisory only (exit-exempt).
40
+ */
41
+ export declare function checkStaleXbriefSchemaDeposit(projectRoot: string, seams?: CheckSeams): CheckResult;
33
42
  /**
34
43
  * #2206: check that the consumer `.gitignore` carries the canonical Deft baseline
35
44
  * entries. Advisory (exit-exempt) because missing entries are an adoption risk, not
@@ -5,6 +5,8 @@ import { detectCanonicalVendoredManifest, isNpmManaged, NPM_MANAGED_SENTINEL_KEY
5
5
  import { resolveLifecycleRoot } from "../layout/resolve.js";
6
6
  import { findSkillPathsInText } from "../text/redos-safe.js";
7
7
  import { stripGitignoreInlineComment } from "../triage/bootstrap/gitignore.js";
8
+ import { LEGACY_INFO_ROOT_KEY, MIGRATED_ARTIFACT_DIR } from "../xbrief-migrate/constants.js";
9
+ import { detectXbriefConvergence } from "../xbrief-migrate/detect.js";
8
10
  import { CANONICAL_UPGRADE_COMMAND, GO_BRIDGE_RELEASES_URL, UPGRADING_DOC_URL, } from "./constants.js";
9
11
  import { isDeprecationRedirectStub, locateManifest, manifestCandidatePaths, manifestReportableVersion, manifestTagToVersion, parseInstallManifest, parseInstallRootFromAgentsMd, parseManifest, } from "./manifest.js";
10
12
  import { readTextSafe } from "./paths.js";
@@ -459,6 +461,68 @@ function collectGitignorePresent(text) {
459
461
  function gitignoreLineIsCovered(present, line) {
460
462
  return present.has(line);
461
463
  }
464
+ /** Relative path to the deposited v0.6 schema file superseded by xbrief-core-0.8.schema.json. */
465
+ export const STALE_VBRIEF_CORE_SCHEMA_REL = join(MIGRATED_ARTIFACT_DIR, "schemas", "vbrief-core.schema.json");
466
+ const MIGRATED_XBRIEF_LAYOUT_STATES = new Set([
467
+ "xbrief-only",
468
+ "xbrief-marker",
469
+ ]);
470
+ /**
471
+ * #2368: on an already-migrated xbrief/ layout, a stale deposited
472
+ * `xbrief/schemas/vbrief-core.schema.json` (vBRIEFInfo v0.6) must NOT route
473
+ * operators to `migrate:xbrief` — that verb requires a vbrief/ tree. Point at
474
+ * `directive update` instead. Advisory only (exit-exempt).
475
+ */
476
+ export function checkStaleXbriefSchemaDeposit(projectRoot, seams = {}) {
477
+ const checkName = "stale-xbrief-schema-deposit";
478
+ const convergence = detectXbriefConvergence(projectRoot);
479
+ if (!MIGRATED_XBRIEF_LAYOUT_STATES.has(convergence.state)) {
480
+ return {
481
+ name: checkName,
482
+ status: "skip",
483
+ detail: "Project is not on a fully migrated xbrief/ layout; stale schema deposit check does not apply.",
484
+ data: { convergence_state: convergence.state },
485
+ };
486
+ }
487
+ const schemaPath = join(projectRoot, STALE_VBRIEF_CORE_SCHEMA_REL);
488
+ const isFile = seams.isFile ?? ((p) => readText(p, seams) !== null);
489
+ if (!isFile(schemaPath)) {
490
+ return {
491
+ name: checkName,
492
+ status: "skip",
493
+ detail: `No deposited schema at ${STALE_VBRIEF_CORE_SCHEMA_REL}.`,
494
+ data: { schema_path: schemaPath },
495
+ };
496
+ }
497
+ const text = readText(schemaPath, seams);
498
+ if (text === null) {
499
+ return {
500
+ name: checkName,
501
+ status: "skip",
502
+ detail: `Deposited schema at ${STALE_VBRIEF_CORE_SCHEMA_REL} is unreadable.`,
503
+ data: { schema_path: schemaPath },
504
+ };
505
+ }
506
+ if (!text.includes(`"${LEGACY_INFO_ROOT_KEY}"`)) {
507
+ return {
508
+ name: checkName,
509
+ status: "pass",
510
+ detail: `Deposited schema at ${STALE_VBRIEF_CORE_SCHEMA_REL} is current (no ${LEGACY_INFO_ROOT_KEY} root key).`,
511
+ data: { schema_path: schemaPath },
512
+ };
513
+ }
514
+ return {
515
+ name: checkName,
516
+ status: "fail",
517
+ detail: `Stale deposited schema at ${STALE_VBRIEF_CORE_SCHEMA_REL} still carries the legacy ${LEGACY_INFO_ROOT_KEY} root key on an already-migrated xbrief/ layout. ` +
518
+ "Run `directive update` to refresh deposited schema files — not `deft migrate:xbrief` (no vbrief/ tree remains to convert).",
519
+ data: {
520
+ schema_path: schemaPath,
521
+ convergence_state: convergence.state,
522
+ suggested_fix: "directive update",
523
+ },
524
+ };
525
+ }
462
526
  /**
463
527
  * #2206: check that the consumer `.gitignore` carries the canonical Deft baseline
464
528
  * entries. Advisory (exit-exempt) because missing entries are an adoption risk, not
@@ -515,6 +579,7 @@ export function deriveExitCode(checks, errors) {
515
579
  "canonical-vendored-npm-signpost",
516
580
  "manifest-version-reportable",
517
581
  "gitignore-coverage",
582
+ "stale-xbrief-schema-deposit",
518
583
  ]);
519
584
  if (errors.length > 0 || checks.some((c) => c.status === "error")) {
520
585
  return 2;
@@ -554,6 +619,7 @@ export function runChecksImpl(projectRoot, seams = {}) {
554
619
  checks.push(checkManifestVersionReportable(projectRoot, null, seams));
555
620
  checks.push(checkLegacyLayout(projectRoot, seams));
556
621
  checks.push(checkCanonicalVendoredNpmSignpost(projectRoot, seams));
622
+ checks.push(checkStaleXbriefSchemaDeposit(projectRoot, seams));
557
623
  checks.push(checkGitignoreCoverage(projectRoot, seams));
558
624
  return {
559
625
  projectRoot,
@@ -570,6 +636,7 @@ export function runChecksImpl(projectRoot, seams = {}) {
570
636
  checks.push(checkInstallPathConsistency(projectRoot, installRoot, seams));
571
637
  checks.push(checkLegacyLayout(projectRoot, seams));
572
638
  checks.push(checkCanonicalVendoredNpmSignpost(projectRoot, seams));
639
+ checks.push(checkStaleXbriefSchemaDeposit(projectRoot, seams));
573
640
  checks.push(checkGitignoreCoverage(projectRoot, seams));
574
641
  return {
575
642
  projectRoot,
@@ -1,4 +1,5 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
2
3
  import { dirname, join } from "node:path";
3
4
  import { resolveTriageCachePath } from "../triage/cache-path.js";
4
5
  import { CLEAN_WINDOW_HOURS, DIRTY_WINDOW_HOURS, ENV_STATE_PATH } from "./constants.js";
@@ -7,7 +8,7 @@ export function statePath(projectRoot) {
7
8
  const override = process.env[ENV_STATE_PATH]?.trim();
8
9
  if (override) {
9
10
  return override.startsWith("~")
10
- ? join(process.env.HOME ?? projectRoot, override.slice(1))
11
+ ? join(homedir(), override.slice(1).replace(/^[\\/]/, ""))
11
12
  : override;
12
13
  }
13
14
  return resolveTriageCachePath(projectRoot, STATE_FILENAME);
@@ -374,7 +374,8 @@ function runInstallIntegrityChecks(projectRoot, sink, addFinding, seams) {
374
374
  if ((name === "legacy-layout" ||
375
375
  name === "canonical-vendored-npm-signpost" ||
376
376
  name === "manifest-version-reportable" ||
377
- name === "gitignore-coverage") &&
377
+ name === "gitignore-coverage" ||
378
+ name === "stale-xbrief-schema-deposit") &&
378
379
  status === "fail") {
379
380
  sink.warn(`${name}: ${detail}`);
380
381
  addFinding({
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
2
3
  import { dirname, join, resolve } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { DEFT_REPO_POSITIVE_MARKERS } from "./constants.js";
@@ -8,7 +9,7 @@ export function resolvePath(pathStr, cwd = process.cwd()) {
8
9
  return cwd;
9
10
  }
10
11
  const expanded = pathStr.startsWith("~")
11
- ? join(process.env.HOME ?? cwd, pathStr.slice(1))
12
+ ? join(homedir(), pathStr.slice(1).replace(/^[\\/]/, ""))
12
13
  : pathStr;
13
14
  return resolve(cwd, expanded);
14
15
  }
@@ -0,0 +1,71 @@
1
+ import { type HealthReport } from "../eval/health.js";
2
+ export type OutputStream = "stdout" | "stderr" | "none";
3
+ /** Glob patterns that classify a change as epic #2369 rule-relocation (#2373). */
4
+ export declare const RULE_RELOCATION_PATH_PATTERNS: readonly ["AGENTS.md", "content/templates/agents-entry.md", "content/skills/**/SKILL.md", "content/packs/**"];
5
+ /** Committed no-regression baseline relative to `xbrief/.eval/`. */
6
+ export declare const HEALTH_BASELINE_REL = "results/eval-health-baseline.json";
7
+ /** Result of verify:eval-health-relocation; three-state exit contract. */
8
+ export interface EvaluateResult {
9
+ readonly code: 0 | 1 | 2;
10
+ readonly message: string;
11
+ readonly stream: OutputStream;
12
+ /** True when the diff did not touch rule-relocation paths (gate skipped). */
13
+ readonly skipped?: boolean;
14
+ }
15
+ export interface EvaluateOptions {
16
+ readonly projectRoot?: string;
17
+ /** Git ref for `git diff --name-only` (CI / branch comparison). */
18
+ readonly baseRef?: string;
19
+ /** Use `git diff --cached --name-only` (pre-commit / staged). */
20
+ readonly staged?: boolean;
21
+ /** Explicit path list (tests / overrides). */
22
+ readonly paths?: readonly string[];
23
+ readonly quiet?: boolean;
24
+ /** Write the current eval:health report as the committed baseline. */
25
+ readonly seedBaseline?: boolean;
26
+ /** Test hook: override eval:health collection. */
27
+ readonly healthEvaluator?: (projectRoot: string) => HealthReport | null;
28
+ }
29
+ /** Validate a parsed baseline JSON object before use. */
30
+ export declare function parseHealthBaseline(parsed: unknown): HealthReport | null;
31
+ /** True when *path* matches a rule-relocation home. */
32
+ export declare function isRuleRelocationPath(path: string): boolean;
33
+ /** Classify a path list for rule-relocation coverage. */
34
+ export declare function classifyRuleRelocationPaths(paths: readonly string[]): {
35
+ readonly isRelocation: boolean;
36
+ readonly matchedPaths: readonly string[];
37
+ };
38
+ /** Absolute path to the committed eval-health baseline snapshot. */
39
+ export declare function healthBaselinePath(projectRoot: string): string;
40
+ /** Read the committed baseline snapshot, if present. */
41
+ export declare function readHealthBaseline(projectRoot: string): HealthReport | null;
42
+ /** Persist a health report as the committed baseline (#2373 Wave 2 seed). */
43
+ export declare function writeHealthBaseline(projectRoot: string, report: HealthReport): void;
44
+ /** Collect changed paths from git (base ref or staged index). */
45
+ export declare function collectChangedPaths(projectRoot: string, options: {
46
+ baseRef?: string;
47
+ staged?: boolean;
48
+ }): string[] | {
49
+ error: string;
50
+ };
51
+ /**
52
+ * Detect eval:health regression against a committed baseline.
53
+ *
54
+ * Fail-closed rules (#2373):
55
+ * - score must not drop below baseline
56
+ * - gates that passed in the baseline must still pass
57
+ * - no new contradictory-gate ids beyond the baseline set
58
+ */
59
+ export declare function detectHealthRegression(current: HealthReport, baseline: HealthReport): {
60
+ pass: boolean;
61
+ reasons: readonly string[];
62
+ };
63
+ /**
64
+ * Conditional fail-closed gate for epic #2369 rule-relocation PRs (#2373).
65
+ *
66
+ * Skips (exit 0) when the diff does not touch relocation homes. When relocation
67
+ * paths are present, requires eval:health no-regression against the committed
68
+ * baseline at `xbrief/.eval/results/eval-health-baseline.json`.
69
+ */
70
+ export declare function evaluate(options?: EvaluateOptions): EvaluateResult;
71
+ //# sourceMappingURL=evaluate.d.ts.map
@@ -0,0 +1,252 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { evaluateHealth } from "../eval/health.js";
5
+ import { resolveEvalPath } from "../layout/resolve.js";
6
+ import { matchAny } from "../orchestration/pathspec.js";
7
+ /** Glob patterns that classify a change as epic #2369 rule-relocation (#2373). */
8
+ export const RULE_RELOCATION_PATH_PATTERNS = [
9
+ "AGENTS.md",
10
+ "content/templates/agents-entry.md",
11
+ "content/skills/**/SKILL.md",
12
+ "content/packs/**",
13
+ ];
14
+ /** Committed no-regression baseline relative to `xbrief/.eval/`. */
15
+ export const HEALTH_BASELINE_REL = "results/eval-health-baseline.json";
16
+ /** Validate a parsed baseline JSON object before use. */
17
+ export function parseHealthBaseline(parsed) {
18
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
19
+ return null;
20
+ }
21
+ const record = parsed;
22
+ if (typeof record.score !== "number" || !Array.isArray(record.gates)) {
23
+ return null;
24
+ }
25
+ if (!Array.isArray(record.contradictions)) {
26
+ return null;
27
+ }
28
+ return parsed;
29
+ }
30
+ /** True when *path* matches a rule-relocation home. */
31
+ export function isRuleRelocationPath(path) {
32
+ return matchAny(RULE_RELOCATION_PATH_PATTERNS, path);
33
+ }
34
+ /** Classify a path list for rule-relocation coverage. */
35
+ export function classifyRuleRelocationPaths(paths) {
36
+ const matchedPaths = paths.filter(isRuleRelocationPath);
37
+ return { isRelocation: matchedPaths.length > 0, matchedPaths };
38
+ }
39
+ /** Absolute path to the committed eval-health baseline snapshot. */
40
+ export function healthBaselinePath(projectRoot) {
41
+ return resolveEvalPath(projectRoot, HEALTH_BASELINE_REL);
42
+ }
43
+ /** Read the committed baseline snapshot, if present. */
44
+ export function readHealthBaseline(projectRoot) {
45
+ const path = healthBaselinePath(projectRoot);
46
+ if (!existsSync(path)) {
47
+ return null;
48
+ }
49
+ try {
50
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
51
+ return parseHealthBaseline(parsed);
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ /** Persist a health report as the committed baseline (#2373 Wave 2 seed). */
58
+ export function writeHealthBaseline(projectRoot, report) {
59
+ const path = healthBaselinePath(projectRoot);
60
+ mkdirSync(dirname(path), { recursive: true });
61
+ writeFileSync(path, `${JSON.stringify(report)}\n`, "utf8");
62
+ }
63
+ function gitNameOnlyDiff(projectRoot, args) {
64
+ try {
65
+ const stdout = execFileSync("git", ["-C", projectRoot, "diff", "--name-only", ...args], {
66
+ encoding: "utf8",
67
+ maxBuffer: 10 * 1024 * 1024,
68
+ });
69
+ return stdout
70
+ .split("\n")
71
+ .map((line) => line.trim())
72
+ .filter((line) => line.length > 0);
73
+ }
74
+ catch (err) {
75
+ return { error: String(err) };
76
+ }
77
+ }
78
+ /** Collect changed paths from git (base ref or staged index). */
79
+ export function collectChangedPaths(projectRoot, options) {
80
+ if (options.staged) {
81
+ return gitNameOnlyDiff(projectRoot, ["--cached"]);
82
+ }
83
+ if (options.baseRef !== undefined && options.baseRef.length > 0) {
84
+ return gitNameOnlyDiff(projectRoot, [options.baseRef, "HEAD"]);
85
+ }
86
+ return [];
87
+ }
88
+ /**
89
+ * Detect eval:health regression against a committed baseline.
90
+ *
91
+ * Fail-closed rules (#2373):
92
+ * - score must not drop below baseline
93
+ * - gates that passed in the baseline must still pass
94
+ * - no new contradictory-gate ids beyond the baseline set
95
+ */
96
+ export function detectHealthRegression(current, baseline) {
97
+ const reasons = [];
98
+ if (current.score < baseline.score) {
99
+ reasons.push(`framework health score dropped ${baseline.score}->${current.score}`);
100
+ }
101
+ const baselineGates = new Map(baseline.gates.filter((gate) => !gate.skipped).map((gate) => [gate.id, gate]));
102
+ for (const [id, baseGate] of baselineGates) {
103
+ if (!baseGate.pass) {
104
+ continue;
105
+ }
106
+ const currentGate = current.gates.find((gate) => gate.id === id && !gate.skipped);
107
+ if (currentGate === undefined) {
108
+ reasons.push(`gate '${id}' missing from current eval:health report`);
109
+ continue;
110
+ }
111
+ if (!currentGate.pass) {
112
+ reasons.push(`gate '${id}' regressed (baseline pass -> current fail)`);
113
+ }
114
+ }
115
+ const baselineContradictionIds = new Set(baseline.contradictions.map((c) => c.id));
116
+ for (const contradiction of current.contradictions) {
117
+ if (!baselineContradictionIds.has(contradiction.id)) {
118
+ reasons.push(`new contradictory gate '${contradiction.id}'`);
119
+ }
120
+ }
121
+ return { pass: reasons.length === 0, reasons };
122
+ }
123
+ function formatSkipMessage(matchedPaths) {
124
+ return ("✓ verify:eval-health-relocation: no rule-relocation paths in diff " +
125
+ `(checked ${RULE_RELOCATION_PATH_PATTERNS.length} patterns; ` +
126
+ `diff had ${matchedPaths.length} relocation match${matchedPaths.length === 1 ? "" : "es"}).`);
127
+ }
128
+ function formatPassMessage(score, matchedPaths) {
129
+ return (`✓ verify:eval-health-relocation: eval:health no-regression OK ` +
130
+ `(score=${score}/100; relocation paths: ${matchedPaths.join(", ")}).`);
131
+ }
132
+ function formatRegressionMessage(reasons, matchedPaths, baseline, current) {
133
+ const detail = reasons.map((reason) => ` - ${reason}`).join("\n");
134
+ return ("❌ verify:eval-health-relocation: eval:health regression on rule-relocation PR " +
135
+ `(#2373 / epic #2369).\n` +
136
+ ` Baseline score: ${baseline.score}/100; current: ${current.score}/100.\n` +
137
+ ` Matched relocation paths: ${matchedPaths.join(", ")}\n` +
138
+ `${detail}\n` +
139
+ " Remediation: restore gate pass states / score, or bump the committed baseline\n" +
140
+ ` at ${HEALTH_BASELINE_REL} only after a deliberate, reviewed health change.\n` +
141
+ " Run `task eval:health` for the full gate breakdown.");
142
+ }
143
+ function runHealthEvaluator(projectRoot, healthEvaluator) {
144
+ if (healthEvaluator !== undefined) {
145
+ return healthEvaluator(projectRoot);
146
+ }
147
+ const healthResult = evaluateHealth({
148
+ projectRoot,
149
+ persist: false,
150
+ frameworkSource: true,
151
+ });
152
+ return healthResult.report;
153
+ }
154
+ /**
155
+ * Conditional fail-closed gate for epic #2369 rule-relocation PRs (#2373).
156
+ *
157
+ * Skips (exit 0) when the diff does not touch relocation homes. When relocation
158
+ * paths are present, requires eval:health no-regression against the committed
159
+ * baseline at `xbrief/.eval/results/eval-health-baseline.json`.
160
+ */
161
+ export function evaluate(options = {}) {
162
+ const projectRoot = resolve(options.projectRoot ?? process.cwd());
163
+ const quiet = options.quiet ?? false;
164
+ if (options.seedBaseline) {
165
+ const report = runHealthEvaluator(projectRoot, options.healthEvaluator);
166
+ if (report === null) {
167
+ return {
168
+ code: 2,
169
+ message: "❌ verify:eval-health-relocation: eval:health returned no report.",
170
+ stream: "stderr",
171
+ };
172
+ }
173
+ writeHealthBaseline(projectRoot, report);
174
+ return {
175
+ code: 0,
176
+ message: `✓ verify:eval-health-relocation: seeded baseline at ${HEALTH_BASELINE_REL} ` +
177
+ `(score=${report.score}/100).`,
178
+ stream: "stdout",
179
+ };
180
+ }
181
+ let paths;
182
+ if (options.paths !== undefined) {
183
+ paths = [...options.paths];
184
+ }
185
+ else if (options.baseRef !== undefined || options.staged) {
186
+ const collected = collectChangedPaths(projectRoot, {
187
+ baseRef: options.baseRef,
188
+ staged: options.staged,
189
+ });
190
+ if ("error" in collected) {
191
+ return {
192
+ code: 2,
193
+ message: "❌ verify:eval-health-relocation: could not read git diff " +
194
+ `(project_root=${projectRoot}): ${collected.error}`,
195
+ stream: "stderr",
196
+ };
197
+ }
198
+ paths = collected;
199
+ }
200
+ else {
201
+ return { code: 0, message: "", stream: "none", skipped: true };
202
+ }
203
+ const { isRelocation, matchedPaths } = classifyRuleRelocationPaths(paths);
204
+ if (!isRelocation) {
205
+ if (quiet) {
206
+ return { code: 0, message: "", stream: "none", skipped: true };
207
+ }
208
+ return {
209
+ code: 0,
210
+ message: formatSkipMessage(matchedPaths),
211
+ stream: "stdout",
212
+ skipped: true,
213
+ };
214
+ }
215
+ const current = runHealthEvaluator(projectRoot, options.healthEvaluator);
216
+ if (current === null) {
217
+ return {
218
+ code: 2,
219
+ message: "❌ verify:eval-health-relocation: eval:health returned no report.",
220
+ stream: "stderr",
221
+ };
222
+ }
223
+ const baseline = readHealthBaseline(projectRoot);
224
+ if (baseline === null) {
225
+ return {
226
+ code: 2,
227
+ message: "❌ verify:eval-health-relocation: missing committed baseline " +
228
+ `at ${HEALTH_BASELINE_REL}.\n` +
229
+ " Seed once with: task verify:eval-health-relocation -- --seed-baseline\n" +
230
+ " (after `task eval:health` is green on master). Baseline seeding is intentional\n" +
231
+ " and does not weaken verify:agents-md-budget.",
232
+ stream: "stderr",
233
+ };
234
+ }
235
+ const regression = detectHealthRegression(current, baseline);
236
+ if (!regression.pass) {
237
+ return {
238
+ code: 1,
239
+ message: formatRegressionMessage(regression.reasons, matchedPaths, baseline, current),
240
+ stream: "stderr",
241
+ };
242
+ }
243
+ if (quiet) {
244
+ return { code: 0, message: "", stream: "none" };
245
+ }
246
+ return {
247
+ code: 0,
248
+ message: formatPassMessage(current.score, matchedPaths),
249
+ stream: "stdout",
250
+ };
251
+ }
252
+ //# sourceMappingURL=evaluate.js.map
@@ -0,0 +1,2 @@
1
+ export * from "./evaluate.js";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,2 @@
1
+ export * from "./evaluate.js";
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,35 @@
1
+ /**
2
+ * projection-containment.ts — repo-controlled projection symlink guard (#2413).
3
+ *
4
+ * Projection writers (`codebase:map`, `triage:bootstrap`) write to well-known
5
+ * paths such as `.planning/codebase/MAP.md`, `.gitignore`, and `.gitattributes`.
6
+ * If a malicious repo author commits one of those paths (or a parent) as a
7
+ * symlink escaping the project tree, routine operator commands would follow the
8
+ * link and create/overwrite files outside the checkout.
9
+ *
10
+ * `assertProjectionContained` anchors on `realpath(projectDir)`, walks each
11
+ * existing component down to the write target, rejects symlinks that escape the
12
+ * tree, and asserts the deepest existing ancestor stays under the project root.
13
+ *
14
+ * Refs #2413.
15
+ */
16
+ /** Non-zero exit code for a projection-containment refusal (needs-action). */
17
+ export declare const PROJECTION_CONTAINMENT_REFUSED_EXIT_CODE = 2;
18
+ /** Thrown when a projection write target escapes the project tree. */
19
+ export declare class ProjectionContainmentError extends Error {
20
+ readonly projectDir: string;
21
+ readonly targetPath: string;
22
+ readonly offendingPath: string;
23
+ constructor(message: string, details: {
24
+ projectDir: string;
25
+ targetPath: string;
26
+ offendingPath: string;
27
+ });
28
+ }
29
+ /**
30
+ * Refuse projection writes when the target (or a parent) is a symlink that
31
+ * escapes the resolved project tree. Throws {@link ProjectionContainmentError};
32
+ * MUST be called BEFORE any projection read/write/mkdir.
33
+ */
34
+ export declare function assertProjectionContained(projectDir: string, targetPath: string): void;
35
+ //# sourceMappingURL=projection-containment.d.ts.map