@deftai/directive-core 0.74.0 → 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 (36) 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/content-contracts/skills/helpers.d.ts +0 -1
  8. package/dist/content-contracts/skills/helpers.js +1 -11
  9. package/dist/content-contracts/skills/skill-frontmatter.js +8 -1
  10. package/dist/content-contracts/standards/_helpers.js +4 -2
  11. package/dist/content-contracts/standards/_taskfile-helpers.d.ts +2 -0
  12. package/dist/content-contracts/standards/_taskfile-helpers.js +11 -5
  13. package/dist/doctor/doctor-state.js +2 -1
  14. package/dist/doctor/paths.js +2 -1
  15. package/dist/eval-health-relocation/evaluate.d.ts +71 -0
  16. package/dist/eval-health-relocation/evaluate.js +252 -0
  17. package/dist/eval-health-relocation/index.d.ts +2 -0
  18. package/dist/eval-health-relocation/index.js +2 -0
  19. package/dist/init-deposit/scaffold.js +7 -3
  20. package/dist/intake/issue-emit.js +2 -2
  21. package/dist/policy/agents-md-budget.d.ts +23 -0
  22. package/dist/policy/agents-md-budget.js +75 -4
  23. package/dist/release/spawn.js +5 -3
  24. package/dist/scope/decompose.js +4 -3
  25. package/dist/scope/vbrief-ref.js +3 -3
  26. package/dist/swarm/complete-cohort.js +6 -6
  27. package/dist/swarm/launch.js +2 -2
  28. package/dist/triage/queue/cache.d.ts +14 -0
  29. package/dist/triage/queue/cache.js +61 -1
  30. package/dist/validate-content/validate-links.js +2 -2
  31. package/dist/value/readback.d.ts +1 -0
  32. package/dist/value/readback.js +5 -1
  33. package/dist/verify-env/toolchain-check.js +20 -5
  34. package/dist/verify-env/verify-hooks-installed.d.ts +2 -0
  35. package/dist/verify-env/verify-hooks-installed.js +17 -15
  36. package/package.json +7 -3
@@ -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
@@ -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);
@@ -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
@@ -457,7 +457,9 @@ export function ensureTaskfile(projectDir, io) {
457
457
  return true;
458
458
  }
459
459
  const HOOK_FILENAMES = ["pre-commit", "pre-push"];
460
+ const HOOK_SUPPORT_FILENAMES = ["_deft-run.sh"];
460
461
  const HOOK_FILE_MODE = 0o755;
462
+ const HOOK_SUPPORT_FILE_MODE = 0o644;
461
463
  export function writeConsumerGitHooks(projectDir, deftDir, io, seams = {}) {
462
464
  const srcDir = join(deftDir, ".githooks");
463
465
  if (!existsSync(srcDir) || !statSync(srcDir).isDirectory()) {
@@ -467,18 +469,20 @@ export function writeConsumerGitHooks(projectDir, deftDir, io, seams = {}) {
467
469
  const dstDir = join(projectDir, ".githooks");
468
470
  mkdirSync(dstDir, { recursive: true });
469
471
  let filesDeposited = false;
470
- for (const name of HOOK_FILENAMES) {
472
+ for (const name of [...HOOK_FILENAMES, ...HOOK_SUPPORT_FILENAMES]) {
471
473
  const src = join(srcDir, name);
472
474
  if (!existsSync(src))
473
475
  continue;
474
476
  const data = readFileSync(src);
475
477
  const dst = join(dstDir, name);
476
478
  const existing = existsSync(dst) ? readFileSync(dst) : null;
479
+ const isHookScript = HOOK_FILENAMES.includes(name);
477
480
  if (!existing?.equals(data)) {
478
- writeFileSync(dst, data, { mode: HOOK_FILE_MODE });
481
+ const mode = isHookScript ? HOOK_FILE_MODE : HOOK_SUPPORT_FILE_MODE;
482
+ writeFileSync(dst, data, { mode });
479
483
  filesDeposited = true;
480
484
  }
481
- if (platform() !== "win32") {
485
+ if (platform() !== "win32" && isHookScript) {
482
486
  try {
483
487
  const mode = statSync(dst).mode & 0o777;
484
488
  if ((mode & 0o111) === 0) {
@@ -1,6 +1,6 @@
1
1
  import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
- import { join, relative, resolve } from "node:path";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { referenceTypeMatches } from "@deftai/directive-types";
5
5
  import { call } from "../scm/call.js";
6
6
  import { resolveProjectRoot } from "../scope/project-context.js";
@@ -217,7 +217,7 @@ export function expandPatterns(patterns, root = null) {
217
217
  const seen = new Set();
218
218
  const out = [];
219
219
  for (const pattern of patterns) {
220
- const candidate = root !== null && !pattern.startsWith("/") ? join(root, pattern) : pattern;
220
+ const candidate = root !== null && !isAbsolute(pattern) ? join(root, pattern) : pattern;
221
221
  let matches = globSync(candidate).sort();
222
222
  if (matches.length === 0) {
223
223
  try {
@@ -9,9 +9,32 @@
9
9
  * it is an explicit, reviewed diff to this typed field -- that diff IS the
10
10
  * "was this growth deliberate?" checkpoint.
11
11
  */
12
+ export type HarnessProfile = "cursor" | "none";
13
+ export type SkillFrontmatterTier = "daily-core" | "all" | "none";
12
14
  export interface AgentsMdBudget {
13
15
  readonly managedMaxLines: number;
14
16
  readonly unmanagedMaxLines: number;
17
+ /**
18
+ * Fail-closed UTF-8 byte ratchet for the managed section (#2452).
19
+ * Seeded at the current measured size so the gate ships green; growth past
20
+ * this ceiling fails. The <=8192 B north-star is reported separately.
21
+ */
22
+ readonly absoluteMaxBytes?: number;
23
+ /**
24
+ * Harness profile for DD-3 skill-frontmatter measurement (#2463).
25
+ * When unset, the gate auto-detects `cursor` when `content/skills/` exists.
26
+ */
27
+ readonly harnessProfile?: HarnessProfile;
28
+ /**
29
+ * Skill tier for DD-3 frontmatter caps (`daily-core` vs `all` vs `none`).
30
+ * Defaults to `all` for reporting when unset.
31
+ */
32
+ readonly skillFrontmatterTier?: SkillFrontmatterTier;
33
+ /**
34
+ * Optional fail-closed ratchet for DD-3 skill-frontmatter bytes (#2463).
35
+ * Advisory when unset; growth past this ceiling fails when set.
36
+ */
37
+ readonly skillFrontmatterMaxBytes?: number;
15
38
  }
16
39
  export type AgentsMdBudgetSource = "typed" | "unset" | "default-on-error";
17
40
  export interface AgentsMdBudgetResult {
@@ -39,6 +39,19 @@ function readRegion(block, key) {
39
39
  }
40
40
  return { value: raw, error: null };
41
41
  }
42
+ function readOptionalRegion(block, key) {
43
+ if (!(key in block)) {
44
+ return { value: undefined, error: null };
45
+ }
46
+ const raw = block[key];
47
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
48
+ return {
49
+ value: undefined,
50
+ error: `plan.policy.agentsMdBudget.${key} must be a non-negative integer; got ${pythonTypeName(raw)} (${pythonRepr(raw)})`,
51
+ };
52
+ }
53
+ return { value: raw, error: null };
54
+ }
42
55
  /** Resolve plan.policy.agentsMdBudget from PROJECT-DEFINITION (#645). */
43
56
  export function resolveAgentsMdBudget(projectRoot) {
44
57
  const [data, err] = loadProjectDefinition(projectRoot);
@@ -77,13 +90,71 @@ export function resolveAgentsMdBudget(projectRoot) {
77
90
  if (unmanaged.error !== null) {
78
91
  return { budget: null, source: "default-on-error", error: unmanaged.error };
79
92
  }
93
+ const absolute = readOptionalRegion(block, "absoluteMaxBytes");
94
+ if (absolute.error !== null) {
95
+ return { budget: null, source: "default-on-error", error: absolute.error };
96
+ }
97
+ const skillFrontmatterMax = readOptionalRegion(block, "skillFrontmatterMaxBytes");
98
+ if (skillFrontmatterMax.error !== null) {
99
+ return { budget: null, source: "default-on-error", error: skillFrontmatterMax.error };
100
+ }
101
+ const harnessProfile = readOptionalHarnessProfile(block, "harnessProfile");
102
+ if (harnessProfile.error !== null) {
103
+ return { budget: null, source: "default-on-error", error: harnessProfile.error };
104
+ }
105
+ const skillFrontmatterTier = readOptionalSkillTier(block, "skillFrontmatterTier");
106
+ if (skillFrontmatterTier.error !== null) {
107
+ return { budget: null, source: "default-on-error", error: skillFrontmatterTier.error };
108
+ }
109
+ const budget = {
110
+ managedMaxLines: managed.value,
111
+ unmanagedMaxLines: unmanaged.value,
112
+ };
113
+ const withOptional = {
114
+ ...budget,
115
+ ...(absolute.value !== undefined ? { absoluteMaxBytes: absolute.value } : {}),
116
+ ...(skillFrontmatterMax.value !== undefined
117
+ ? { skillFrontmatterMaxBytes: skillFrontmatterMax.value }
118
+ : {}),
119
+ ...(harnessProfile.value !== undefined ? { harnessProfile: harnessProfile.value } : {}),
120
+ ...(skillFrontmatterTier.value !== undefined
121
+ ? { skillFrontmatterTier: skillFrontmatterTier.value }
122
+ : {}),
123
+ };
80
124
  return {
81
- budget: {
82
- managedMaxLines: managed.value,
83
- unmanagedMaxLines: unmanaged.value,
84
- },
125
+ budget: withOptional,
85
126
  source: "typed",
86
127
  error: null,
87
128
  };
88
129
  }
130
+ const HARNESS_PROFILES = new Set(["cursor", "none"]);
131
+ const SKILL_FRONTMATTER_TIERS = new Set(["daily-core", "all", "none"]);
132
+ function readOptionalHarnessProfile(block, key) {
133
+ if (!(key in block)) {
134
+ return { value: undefined, error: null };
135
+ }
136
+ const raw = block[key];
137
+ if (typeof raw !== "string" || !HARNESS_PROFILES.has(raw)) {
138
+ return {
139
+ value: undefined,
140
+ error: `plan.policy.agentsMdBudget.${key} must be one of ${[...HARNESS_PROFILES].join(", ")}; ` +
141
+ `got ${pythonTypeName(raw)} (${pythonRepr(raw)})`,
142
+ };
143
+ }
144
+ return { value: raw, error: null };
145
+ }
146
+ function readOptionalSkillTier(block, key) {
147
+ if (!(key in block)) {
148
+ return { value: undefined, error: null };
149
+ }
150
+ const raw = block[key];
151
+ if (typeof raw !== "string" || !SKILL_FRONTMATTER_TIERS.has(raw)) {
152
+ return {
153
+ value: undefined,
154
+ error: `plan.policy.agentsMdBudget.${key} must be one of ${[...SKILL_FRONTMATTER_TIERS].join(", ")}; ` +
155
+ `got ${pythonTypeName(raw)} (${pythonRepr(raw)})`,
156
+ };
157
+ }
158
+ return { value: raw, error: null };
159
+ }
89
160
  //# sourceMappingURL=agents-md-budget.js.map
@@ -1,12 +1,14 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { SUBPROCESS_MAX_BUFFER } from "../subprocess/max-buffer.js";
3
3
  export function defaultWhich(name) {
4
- const result = spawnSync("which", [name], { encoding: "utf8" });
4
+ const locator = process.platform === "win32" ? "where" : "which";
5
+ const result = spawnSync(locator, [name], { encoding: "utf8" });
5
6
  if (result.status !== 0) {
6
7
  return null;
7
8
  }
8
- const path = (result.stdout ?? "").trim();
9
- return path || null;
9
+ // `where` on Windows may return multiple lines; take the first non-empty match.
10
+ const first = (result.stdout ?? "").split(/\r?\n/).find((line) => line.trim().length > 0);
11
+ return first ? first.trim() : null;
10
12
  }
11
13
  export function spawnText(cmd, args, options = {}) {
12
14
  const result = spawnSync(cmd, [...args], {
@@ -7,7 +7,7 @@
7
7
  * that draft, writes the child story vBRIEFs, and updates the parent scope references.
8
8
  */
9
9
  import { accessSync, constants, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
- import { basename, dirname, isAbsolute, join, resolve } from "node:path";
10
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
11
11
  import { referenceTypeMatches } from "@deftai/directive-types";
12
12
  import { hasArtifactSuffix, LEGACY_ARTIFACT_DIR, MIGRATED_ARTIFACT_DIR, resolveLifecycleRoot, } from "../layout/resolve.js";
13
13
  import { referenceWithDefaultTrust, slugify } from "../vbrief-build/build.js";
@@ -415,7 +415,7 @@ function vbriefDir(projectRoot) {
415
415
  function relToVbrief(vbriefDirPath, path) {
416
416
  const resolvedPath = resolve(path);
417
417
  const resolvedVbrief = resolve(vbriefDirPath);
418
- if (resolvedPath.startsWith(`${resolvedVbrief}/`) || resolvedPath === resolvedVbrief) {
418
+ if (resolvedPath.startsWith(resolvedVbrief + sep) || resolvedPath === resolvedVbrief) {
419
419
  return resolvedPath.slice(resolvedVbrief.length + 1).replace(/\\/g, "/");
420
420
  }
421
421
  throw new DecompositionError(`${path}: path must be inside ${vbriefDirPath}`);
@@ -823,7 +823,8 @@ export function applyDecomposition(opts) {
823
823
  if (!LIFECYCLE_FOLDERS.has(outputFolderName)) {
824
824
  throw new DecompositionError("output_dir must be a vbrief lifecycle folder");
825
825
  }
826
- if (!outputDir.startsWith(`${resolve(vbriefDirPath)}/`) && outputDir !== resolve(vbriefDirPath)) {
826
+ const resolvedVbriefDir = resolve(vbriefDirPath);
827
+ if (!outputDir.startsWith(resolvedVbriefDir + sep) && outputDir !== resolvedVbriefDir) {
827
828
  throw new DecompositionError("output_dir must be inside vbrief/");
828
829
  }
829
830
  if (outputFolderName === "active") {