@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.
- package/dist/agents-md-budget/evaluate.d.ts +61 -0
- package/dist/agents-md-budget/evaluate.js +321 -19
- package/dist/agents-md-budget/index.d.ts +1 -0
- package/dist/agents-md-budget/index.js +1 -0
- package/dist/agents-md-budget/skill-frontmatter.d.ts +33 -0
- package/dist/agents-md-budget/skill-frontmatter.js +115 -0
- package/dist/check/orchestrator.d.ts +3 -0
- package/dist/check/orchestrator.js +2 -1
- package/dist/codebase/map.js +2 -0
- package/dist/content-contracts/skills/helpers.d.ts +0 -1
- package/dist/content-contracts/skills/helpers.js +1 -11
- package/dist/content-contracts/skills/skill-frontmatter.js +8 -1
- package/dist/content-contracts/standards/_helpers.js +4 -2
- package/dist/content-contracts/standards/_taskfile-helpers.d.ts +2 -0
- package/dist/content-contracts/standards/_taskfile-helpers.js +11 -5
- package/dist/doctor/checks.d.ts +9 -0
- package/dist/doctor/checks.js +67 -0
- package/dist/doctor/doctor-state.js +2 -1
- package/dist/doctor/main.js +2 -1
- package/dist/doctor/paths.js +2 -1
- package/dist/eval-health-relocation/evaluate.d.ts +71 -0
- package/dist/eval-health-relocation/evaluate.js +252 -0
- package/dist/eval-health-relocation/index.d.ts +2 -0
- package/dist/eval-health-relocation/index.js +2 -0
- package/dist/fs/projection-containment.d.ts +35 -0
- package/dist/fs/projection-containment.js +118 -0
- package/dist/init-deposit/scaffold.d.ts +21 -0
- package/dist/init-deposit/scaffold.js +86 -9
- package/dist/intake/issue-emit.js +2 -2
- package/dist/policy/agents-md-budget.d.ts +23 -0
- package/dist/policy/agents-md-budget.js +75 -4
- package/dist/pr-merge-readiness/constants.d.ts +4 -0
- package/dist/pr-merge-readiness/constants.js +4 -0
- package/dist/pr-merge-readiness/evaluate.js +3 -0
- package/dist/pr-merge-readiness/mergeability.d.ts +8 -6
- package/dist/pr-merge-readiness/mergeability.js +15 -10
- package/dist/pr-merge-readiness/output.js +12 -6
- package/dist/pr-merge-readiness/parse.d.ts +1 -0
- package/dist/pr-merge-readiness/parse.js +9 -1
- package/dist/pr-merge-readiness/types.d.ts +2 -0
- package/dist/preflight-cache/evaluate.d.ts +2 -0
- package/dist/preflight-cache/evaluate.js +14 -3
- package/dist/preflight-cache/index.d.ts +1 -1
- package/dist/preflight-cache/index.js +1 -1
- package/dist/release/constants.d.ts +2 -0
- package/dist/release/constants.js +2 -0
- package/dist/release/preflight.d.ts +2 -0
- package/dist/release/preflight.js +14 -1
- package/dist/release/spawn.js +5 -3
- package/dist/scope/decompose.js +4 -3
- package/dist/scope/index.d.ts +1 -0
- package/dist/scope/index.js +1 -0
- package/dist/scope/main.js +24 -1
- package/dist/scope/open-umbrella-warning.d.ts +12 -0
- package/dist/scope/open-umbrella-warning.js +403 -0
- package/dist/scope/vbrief-ref.js +3 -3
- package/dist/swarm/complete-cohort.js +6 -6
- package/dist/swarm/launch.js +2 -2
- package/dist/triage/bootstrap/gitignore.d.ts +1 -1
- package/dist/triage/bootstrap/gitignore.js +69 -58
- package/dist/triage/queue/cache.d.ts +14 -0
- package/dist/triage/queue/cache.js +61 -1
- package/dist/validate-content/validate-links.js +2 -2
- package/dist/value/readback.d.ts +1 -0
- package/dist/value/readback.js +5 -1
- package/dist/vbrief-reconcile/umbrellas.js +12 -11
- package/dist/verify-env/toolchain-check.js +20 -5
- package/dist/verify-env/verify-hooks-installed.d.ts +2 -0
- package/dist/verify-env/verify-hooks-installed.js +17 -15
- package/dist/xbrief-migrate/migrate-project.js +9 -0
- 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([
|
|
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
|
|
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
|
package/dist/doctor/checks.d.ts
CHANGED
|
@@ -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
|
package/dist/doctor/checks.js
CHANGED
|
@@ -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(
|
|
11
|
+
? join(homedir(), override.slice(1).replace(/^[\\/]/, ""))
|
|
11
12
|
: override;
|
|
12
13
|
}
|
|
13
14
|
return resolveTriageCachePath(projectRoot, STATE_FILENAME);
|
package/dist/doctor/main.js
CHANGED
|
@@ -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({
|
package/dist/doctor/paths.js
CHANGED
|
@@ -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(
|
|
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,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
|