@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
@@ -27,7 +27,14 @@ export const CANDIDATES_RELPATH = join("xbrief", ".triage-cache", "candidates.js
27
27
  export const DEFAULT_MAX_AGE_HOURS = 24;
28
28
  export const ENV_MAX_AGE_HOURS = "DEFT_CACHE_MAX_AGE_HOURS";
29
29
  export const ENV_TRIAGE_REPO = "DEFT_TRIAGE_REPO";
30
+ /** Set by releaseSubprocessEnv() during task release Step 5 (#2386). */
31
+ export const ENV_RELEASE_PREFLIGHT = "DEFT_RELEASE_PREFLIGHT";
30
32
  export const REQUIRED_DECISION = "accept";
33
+ const TRUTHY = new Set(["1", "true", "yes", "on"]);
34
+ function releasePreflightBypassActive() {
35
+ const raw = process.env[ENV_RELEASE_PREFLIGHT] ?? "";
36
+ return TRUTHY.has(raw.trim().toLowerCase());
37
+ }
31
38
  // ---------------------------------------------------------------------------
32
39
  // Repo discovery helpers
33
40
  // ---------------------------------------------------------------------------
@@ -327,6 +334,7 @@ const REMEDIATION_NO_CANDIDATES = [
327
334
  export function evaluate(projectRoot, options = {}) {
328
335
  const source = options.source ?? DEFAULT_SOURCE;
329
336
  const allowStale = options.allowStale ?? false;
337
+ const releasePreflightStaleBypass = releasePreflightBypassActive();
330
338
  const allowMissingBootstrap = options.allowMissingBootstrap ?? false;
331
339
  const nowFn = options.nowFn ?? (() => new Date());
332
340
  const envMaxAge = process.env[ENV_MAX_AGE_HOURS];
@@ -423,11 +431,14 @@ export function evaluate(projectRoot, options = {}) {
423
431
  // empty-cache case (no entries at all) preserves its prior staleness result.
424
432
  const allCandidatesClosed = minFetchedAt === null && rawCandidatePaths.length > 0;
425
433
  const stale = allCandidatesClosed ? false : ageH > maxAgeHours;
426
- // Step 5b: --allow-stale bypass
427
- if (stale && allowStale) {
434
+ // Step 5b: --allow-stale bypass (or release Step-5 age bypass only, #2386)
435
+ if (stale && (allowStale || releasePreflightStaleBypass)) {
436
+ const bypassReason = options.allowStale
437
+ ? "--allow-stale is set"
438
+ : `${ENV_RELEASE_PREFLIGHT} is set (release pre-flight)`;
428
439
  const warning = [
429
440
  `⚠ deft cache-fresh: cache is ${ageH.toFixed(1)}h old (max-age=${maxAgeHours}h) but`,
430
- ` --allow-stale is set -- proceeding with audit-trail warning.`,
441
+ ` ${bypassReason} -- proceeding with audit-trail warning.`,
431
442
  ].join("\n");
432
443
  if (options.forIssue !== undefined && options.forIssue !== null) {
433
444
  const forResult = evaluateForIssue(resolvedRepo, options.forIssue, candidatesPath, cacheRoot, source, scopeRules, projectRoot);
@@ -1,3 +1,3 @@
1
1
  export type { EvaluateOptions, GateResult } from "./evaluate.js";
2
- export { CACHE_DIR_NAME, CANDIDATES_RELPATH, DEFAULT_MAX_AGE_HOURS, DEFAULT_SOURCE, ENV_MAX_AGE_HOURS, ENV_TRIAGE_REPO, evaluate, REQUIRED_DECISION, } from "./evaluate.js";
2
+ export { CACHE_DIR_NAME, CANDIDATES_RELPATH, DEFAULT_MAX_AGE_HOURS, DEFAULT_SOURCE, ENV_MAX_AGE_HOURS, ENV_RELEASE_PREFLIGHT, ENV_TRIAGE_REPO, evaluate, REQUIRED_DECISION, } from "./evaluate.js";
3
3
  //# sourceMappingURL=index.d.ts.map
@@ -1,2 +1,2 @@
1
- export { CACHE_DIR_NAME, CANDIDATES_RELPATH, DEFAULT_MAX_AGE_HOURS, DEFAULT_SOURCE, ENV_MAX_AGE_HOURS, ENV_TRIAGE_REPO, evaluate, REQUIRED_DECISION, } from "./evaluate.js";
1
+ export { CACHE_DIR_NAME, CANDIDATES_RELPATH, DEFAULT_MAX_AGE_HOURS, DEFAULT_SOURCE, ENV_MAX_AGE_HOURS, ENV_RELEASE_PREFLIGHT, ENV_TRIAGE_REPO, evaluate, REQUIRED_DECISION, } from "./evaluate.js";
2
2
  //# sourceMappingURL=index.js.map
@@ -16,6 +16,8 @@ export declare const VERIFY_DRAFT_INTERVAL_SECONDS = 1;
16
16
  export declare const RELEASE_ARTIFACTS: readonly ["CHANGELOG.md", "ROADMAP.md"];
17
17
  export declare const BRANCH_GATE_BYPASS_ENV = "DEFT_ALLOW_DEFAULT_BRANCH_COMMIT";
18
18
  export declare const DESTRUCTIVE_GH_GATE_BYPASS_ENV = "DEFT_ALLOW_DESTRUCTIVE_GH_VERBS";
19
+ /** Set by releaseSubprocessEnv() so Step-5 pre-flight skips triage-cache staleness (#2386). */
20
+ export declare const RELEASE_PREFLIGHT_ENV = "DEFT_RELEASE_PREFLIGHT";
19
21
  export declare const PYPROJECT_VERSION_LINE_RE: RegExp;
20
22
  /** Byte-identical argparse --help from scripts/release.py (Python 3.12). */
21
23
  export declare const RELEASE_HELP: string;
@@ -24,6 +24,8 @@ export const VERIFY_DRAFT_INTERVAL_SECONDS = 1.0;
24
24
  export const RELEASE_ARTIFACTS = ["CHANGELOG.md", "ROADMAP.md"];
25
25
  export const BRANCH_GATE_BYPASS_ENV = "DEFT_ALLOW_DEFAULT_BRANCH_COMMIT";
26
26
  export const DESTRUCTIVE_GH_GATE_BYPASS_ENV = "DEFT_ALLOW_DESTRUCTIVE_GH_VERBS";
27
+ /** Set by releaseSubprocessEnv() so Step-5 pre-flight skips triage-cache staleness (#2386). */
28
+ export const RELEASE_PREFLIGHT_ENV = "DEFT_RELEASE_PREFLIGHT";
27
29
  export const PYPROJECT_VERSION_LINE_RE = /version\s*=\s*"[^"]*"/;
28
30
  /** Byte-identical argparse --help from scripts/release.py (Python 3.12). */
29
31
  export const RELEASE_HELP = "usage: release [-h] [--dry-run] [--skip-tag] [--skip-release] [--allow-dirty]\n" +
@@ -8,6 +8,8 @@
8
8
  * dispatches `check:framework-source`.
9
9
  */
10
10
  import { type CheckOrchestratorSeams } from "../check/orchestrator.js";
11
+ /** Step-5-only env: branch bypass plus release pre-flight cache staleness tolerance (#2386). */
12
+ export declare function releaseCheckEnv(base?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
11
13
  /** Seams for test isolation of the native release pre-flight. */
12
14
  export interface ReleasePreflightSeams {
13
15
  /** Override the check dispatcher (default: dispatchTaskCheck from check/orchestrator). */
@@ -8,6 +8,15 @@
8
8
  * dispatches `check:framework-source`.
9
9
  */
10
10
  import { dispatchTaskCheck } from "../check/orchestrator.js";
11
+ import { RELEASE_PREFLIGHT_ENV } from "./constants.js";
12
+ import { releaseSubprocessEnv } from "./git.js";
13
+ /** Step-5-only env: branch bypass plus release pre-flight cache staleness tolerance (#2386). */
14
+ export function releaseCheckEnv(base = process.env) {
15
+ return {
16
+ ...releaseSubprocessEnv(base),
17
+ [RELEASE_PREFLIGHT_ENV]: "1",
18
+ };
19
+ }
11
20
  /**
12
21
  * Run the native TypeScript `task check` as the release pre-flight.
13
22
  *
@@ -18,7 +27,11 @@ import { dispatchTaskCheck } from "../check/orchestrator.js";
18
27
  */
19
28
  export function runReleaseCheck(projectRoot, seams = {}) {
20
29
  const dispatch = seams.dispatchCheck ?? dispatchTaskCheck;
21
- const code = dispatch(projectRoot, projectRoot, seams.checkSeams);
30
+ const checkSeams = {
31
+ ...seams.checkSeams,
32
+ env: releaseCheckEnv(seams.checkSeams?.env ?? process.env),
33
+ };
34
+ const code = dispatch(projectRoot, projectRoot, checkSeams);
22
35
  if (code === 0) {
23
36
  return [true, "ran native TypeScript task check"];
24
37
  }
@@ -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") {
@@ -3,6 +3,7 @@ export * from "./constants.js";
3
3
  export * from "./decomposed-refs.js";
4
4
  export * from "./demote.js";
5
5
  export * from "./main.js";
6
+ export * from "./open-umbrella-warning.js";
6
7
  export * from "./project-context.js";
7
8
  export * from "./transition.js";
8
9
  export * from "./undo.js";
@@ -3,6 +3,7 @@ export * from "./constants.js";
3
3
  export * from "./decomposed-refs.js";
4
4
  export * from "./demote.js";
5
5
  export * from "./main.js";
6
+ export * from "./open-umbrella-warning.js";
6
7
  export * from "./project-context.js";
7
8
  export * from "./transition.js";
8
9
  export * from "./undo.js";
@@ -1,8 +1,10 @@
1
1
  import { existsSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { dirname, join } from "node:path";
3
+ import { reconcileUmbrellas, renderUmbrellasReport } from "../vbrief-reconcile/umbrellas.js";
3
4
  import { canonicalLogPath, readAll } from "./audit-log.js";
4
5
  import { TRANSITIONS } from "./constants.js";
5
6
  import { batchDemote, DEFAULT_OLDER_THAN_DAYS, demoteOne, resolveDemoteFilePath, resolveFilePath, resolveProjectRootStrict, } from "./demote.js";
7
+ import { completedPathForScopeMove, findOpenUmbrellaReferences, renderOpenUmbrellaWarning, } from "./open-umbrella-warning.js";
6
8
  import { resolveProjectRoot } from "./project-context.js";
7
9
  import { recordWipCapOverride, runTransition } from "./transition.js";
8
10
  import { findByDecisionId, isAlreadyUndone, REVERSIBLE_ACTIONS, undoBatch, undoOne, } from "./undo.js";
@@ -88,6 +90,27 @@ export function lifecycleMain(argv) {
88
90
  "audit: vbrief/.eval/scope-lifecycle.jsonl entry tagged wip_cap_override (#1124).\n");
89
91
  }
90
92
  process.stdout.write(`${result.message}\n`);
93
+ if (action === "complete") {
94
+ try {
95
+ const completedPath = completedPathForScopeMove(filePath);
96
+ const rootForWarning = resolveProjectRoot(projectRoot) ?? dirname(dirname(dirname(completedPath)));
97
+ const refs = findOpenUmbrellaReferences(rootForWarning, completedPath);
98
+ const warning = renderOpenUmbrellaWarning(refs);
99
+ if (warning.length > 0) {
100
+ process.stderr.write(`${warning}\n`);
101
+ const [, outcome] = reconcileUmbrellas(rootForWarning);
102
+ // Only surface a report when reconcile actually changed something or
103
+ // recorded per-epic errors — skip vacuous setup failures (exit 2 with
104
+ // empty buckets) so a missing lifecycle root does not spam stderr.
105
+ if (outcome.changed.length > 0 || outcome.errors.length > 0) {
106
+ process.stderr.write(`${renderUmbrellasReport(outcome)}\n`);
107
+ }
108
+ }
109
+ }
110
+ catch {
111
+ /* best-effort drift warning + reconcile; lifecycle success remains authoritative */
112
+ }
113
+ }
91
114
  return 0;
92
115
  }
93
116
  process.stderr.write(`Error: ${result.message}\n`);
@@ -0,0 +1,12 @@
1
+ export interface OpenUmbrellaReference {
2
+ readonly repo: string | null;
3
+ readonly issueNumber: number | null;
4
+ readonly title: string;
5
+ readonly path: string | null;
6
+ readonly sources: readonly string[];
7
+ }
8
+ /** Find open umbrella/tracker surfaces that still mention a just-completed scope. */
9
+ export declare function findOpenUmbrellaReferences(projectRoot: string, completedScopePath: string): OpenUmbrellaReference[];
10
+ export declare function renderOpenUmbrellaWarning(refs: readonly OpenUmbrellaReference[]): string;
11
+ export declare function completedPathForScopeMove(filePath: string): string;
12
+ //# sourceMappingURL=open-umbrella-warning.d.ts.map
@@ -0,0 +1,403 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { basename, dirname, join, relative, resolve } from "node:path";
3
+ import { referenceTypeMatches } from "@deftai/directive-types";
4
+ import { hasArtifactSuffix } from "../layout/resolve.js";
5
+ import { CACHE_DIR_NAME, CACHE_SOURCE_GITHUB_ISSUE } from "../triage/queue/constants.js";
6
+ import { parseGithubIssueUri } from "../triage/reconcile/parse-uri.js";
7
+ import { collectPlanRefs, resolveVbriefRef } from "./vbrief-ref.js";
8
+ const OPEN_FOLDERS = ["proposed", "pending", "active"];
9
+ const TRACKER_LABELS = new Set(["epic", "meta", "tracker", "type:tracker", "status:tracker"]);
10
+ function readJson(path) {
11
+ try {
12
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
13
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
14
+ ? parsed
15
+ : null;
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ function planOf(data) {
22
+ const plan = data?.plan;
23
+ return typeof plan === "object" && plan !== null && !Array.isArray(plan)
24
+ ? plan
25
+ : null;
26
+ }
27
+ function issueRefsFromPlan(plan) {
28
+ const refs = plan?.references;
29
+ if (!Array.isArray(refs)) {
30
+ return [];
31
+ }
32
+ const out = [];
33
+ const seen = new Set();
34
+ for (const ref of refs) {
35
+ if (typeof ref !== "object" || ref === null || Array.isArray(ref)) {
36
+ continue;
37
+ }
38
+ const typed = ref;
39
+ if (!referenceTypeMatches(String(typed.type ?? ""), "github-issue")) {
40
+ continue;
41
+ }
42
+ const [repo, number] = parseGithubIssueUri(typed.uri);
43
+ if (number === null) {
44
+ continue;
45
+ }
46
+ const key = repo === null ? `bare:${number}` : `${repo}:${number}`;
47
+ if (seen.has(key)) {
48
+ continue;
49
+ }
50
+ seen.add(key);
51
+ out.push({ repo, number });
52
+ }
53
+ return out;
54
+ }
55
+ function labelsFromRaw(raw) {
56
+ if (!Array.isArray(raw)) {
57
+ return [];
58
+ }
59
+ const out = [];
60
+ for (const label of raw) {
61
+ if (typeof label === "string") {
62
+ out.push(label);
63
+ }
64
+ else if (typeof label === "object" && label !== null && !Array.isArray(label)) {
65
+ const name = label.name;
66
+ if (typeof name === "string") {
67
+ out.push(name);
68
+ }
69
+ }
70
+ }
71
+ return out;
72
+ }
73
+ function subIssueTotal(raw) {
74
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
75
+ return 0;
76
+ }
77
+ const total = raw.total;
78
+ return typeof total === "number" && Number.isFinite(total) ? total : 0;
79
+ }
80
+ function readTextIfPresent(path) {
81
+ if (!existsSync(path)) {
82
+ return "";
83
+ }
84
+ try {
85
+ return readFileSync(path, "utf8");
86
+ }
87
+ catch {
88
+ return "";
89
+ }
90
+ }
91
+ function readCachedIssue(projectRoot, repo, number) {
92
+ const [owner, name] = repo.split("/", 2);
93
+ if (!owner || !name) {
94
+ return null;
95
+ }
96
+ const issueDir = join(projectRoot, CACHE_DIR_NAME, CACHE_SOURCE_GITHUB_ISSUE, owner, name, String(number));
97
+ const rawPath = join(issueDir, "raw.json");
98
+ const raw = readJson(rawPath);
99
+ if (raw === null) {
100
+ return null;
101
+ }
102
+ const body = typeof raw.body === "string" ? raw.body : "";
103
+ const content = readTextIfPresent(join(issueDir, "content.md"));
104
+ return {
105
+ repo,
106
+ number: typeof raw.number === "number" && Number.isFinite(raw.number) ? raw.number : number,
107
+ title: typeof raw.title === "string" ? raw.title : `#${number}`,
108
+ state: typeof raw.state === "string" ? raw.state.toLowerCase() : "open",
109
+ body: content.length > 0 ? `${body}\n${content}` : body,
110
+ labels: labelsFromRaw(raw.labels).map((label) => label.toLowerCase()),
111
+ subIssuesTotal: subIssueTotal(raw.sub_issues_summary),
112
+ };
113
+ }
114
+ function cachedState(projectRoot, ref) {
115
+ if (ref.repo === null) {
116
+ return null;
117
+ }
118
+ const cached = readCachedIssue(projectRoot, ref.repo, ref.number);
119
+ if (cached === null) {
120
+ return null;
121
+ }
122
+ return cached.state === "closed" ? "closed" : "open";
123
+ }
124
+ function chooseOpenIssueRef(projectRoot, refs) {
125
+ if (refs.length === 0) {
126
+ return null;
127
+ }
128
+ let firstUnknown = null;
129
+ for (const ref of refs) {
130
+ const state = cachedState(projectRoot, ref);
131
+ if (state === "open") {
132
+ return ref;
133
+ }
134
+ if (state === null && firstUnknown === null) {
135
+ firstUnknown = ref;
136
+ }
137
+ }
138
+ return firstUnknown;
139
+ }
140
+ function refUriMatchesPath(uri, targetPaths, vbriefRoot) {
141
+ if (typeof uri !== "string" || uri.length === 0) {
142
+ return false;
143
+ }
144
+ const resolved = resolveVbriefRef(uri, vbriefRoot);
145
+ if (resolved === null) {
146
+ return false;
147
+ }
148
+ return targetPaths.has(resolve(resolved));
149
+ }
150
+ function planReferenceMatchesTarget(ref, targetPaths, vbriefRoot) {
151
+ if (typeof ref !== "object" || ref === null || Array.isArray(ref)) {
152
+ return false;
153
+ }
154
+ const typed = ref;
155
+ if (!referenceTypeMatches(String(typed.type ?? ""), "plan")) {
156
+ return false;
157
+ }
158
+ return refUriMatchesPath(typed.uri, targetPaths, vbriefRoot);
159
+ }
160
+ function planReferencesTarget(plan, targetPaths, vbriefRoot) {
161
+ const refs = plan.references;
162
+ if (!Array.isArray(refs)) {
163
+ return false;
164
+ }
165
+ return refs.some((ref) => planReferenceMatchesTarget(ref, targetPaths, vbriefRoot));
166
+ }
167
+ function planRefsTarget(plan, targetPaths, vbriefRoot) {
168
+ return collectPlanRefs(plan).some((uri) => refUriMatchesPath(uri, targetPaths, vbriefRoot));
169
+ }
170
+ function relToRoot(path, root) {
171
+ return relative(resolve(root), resolve(path)).replace(/\\/g, "/");
172
+ }
173
+ function addReference(out, ref) {
174
+ const key = ref.repo !== null && ref.issueNumber !== null
175
+ ? `${ref.repo}#${ref.issueNumber}`
176
+ : (ref.path ?? ref.title);
177
+ const existing = out.get(key);
178
+ if (existing !== undefined) {
179
+ existing.sources.add(ref.source);
180
+ if (existing.title.startsWith("#") && !ref.title.startsWith("#")) {
181
+ existing.title = ref.title;
182
+ }
183
+ return;
184
+ }
185
+ out.set(key, {
186
+ repo: ref.repo,
187
+ issueNumber: ref.issueNumber,
188
+ title: ref.title,
189
+ path: ref.path,
190
+ sources: new Set([ref.source]),
191
+ });
192
+ }
193
+ function titleForLocalCandidate(plan, issueRef, projectRoot) {
194
+ if (issueRef?.repo !== null && issueRef?.repo !== undefined) {
195
+ const cached = readCachedIssue(projectRoot, issueRef.repo, issueRef.number);
196
+ if (cached !== null) {
197
+ return cached.title;
198
+ }
199
+ }
200
+ return typeof plan.title === "string" && plan.title.length > 0
201
+ ? plan.title
202
+ : issueRef !== null
203
+ ? `#${issueRef.number}`
204
+ : "open scope";
205
+ }
206
+ function findLocalReferences(projectRoot, completedScopePath, completedPlan, out) {
207
+ const vbriefRoot = dirname(dirname(resolve(completedScopePath)));
208
+ const targetScopePaths = new Set([
209
+ completedScopePath,
210
+ ...OPEN_FOLDERS.map((folder) => join(vbriefRoot, folder, basename(completedScopePath))),
211
+ ].map((path) => resolve(path)));
212
+ const targetParentPaths = collectPlanRefs(completedPlan)
213
+ .map((uri) => resolveVbriefRef(uri, vbriefRoot))
214
+ .filter((path) => path !== null)
215
+ .map((path) => resolve(path));
216
+ for (const folder of OPEN_FOLDERS) {
217
+ const folderPath = join(vbriefRoot, folder);
218
+ if (!existsSync(folderPath)) {
219
+ continue;
220
+ }
221
+ for (const name of readdirSync(folderPath)
222
+ .filter((entry) => hasArtifactSuffix(entry))
223
+ .sort()) {
224
+ const path = join(folderPath, name);
225
+ const plan = planOf(readJson(path));
226
+ if (plan === null) {
227
+ continue;
228
+ }
229
+ const sources = [];
230
+ if (planReferencesTarget(plan, targetScopePaths, vbriefRoot)) {
231
+ sources.push("plan.references");
232
+ }
233
+ if (planRefsTarget(plan, targetScopePaths, vbriefRoot)) {
234
+ sources.push("planRef");
235
+ }
236
+ if (targetParentPaths.includes(resolve(path))) {
237
+ sources.push("completed planRef");
238
+ }
239
+ if (sources.length === 0) {
240
+ continue;
241
+ }
242
+ const issueRefs = issueRefsFromPlan(plan);
243
+ const issueRef = chooseOpenIssueRef(projectRoot, issueRefs);
244
+ if (issueRefs.length > 0 && issueRef === null) {
245
+ continue;
246
+ }
247
+ const relPath = relToRoot(path, projectRoot);
248
+ for (const source of sources) {
249
+ addReference(out, {
250
+ repo: issueRef?.repo ?? null,
251
+ issueNumber: issueRef?.number ?? null,
252
+ title: titleForLocalCandidate(plan, issueRef, projectRoot),
253
+ path: relPath,
254
+ source,
255
+ });
256
+ }
257
+ }
258
+ }
259
+ }
260
+ function cachedRepoDirs(projectRoot) {
261
+ const base = join(projectRoot, CACHE_DIR_NAME, CACHE_SOURCE_GITHUB_ISSUE);
262
+ if (!existsSync(base)) {
263
+ return [];
264
+ }
265
+ const repos = [];
266
+ for (const ownerEntry of readdirSync(base, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
267
+ if (!ownerEntry.isDirectory()) {
268
+ continue;
269
+ }
270
+ const owner = ownerEntry.name;
271
+ const ownerPath = join(base, owner);
272
+ for (const repoEntry of readdirSync(ownerPath, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
273
+ if (!repoEntry.isDirectory()) {
274
+ continue;
275
+ }
276
+ const name = repoEntry.name;
277
+ repos.push(`${owner}/${name}`);
278
+ }
279
+ }
280
+ return repos;
281
+ }
282
+ function issueNumberToken(issueNumber) {
283
+ return Number.isSafeInteger(issueNumber) && issueNumber > 0 ? String(issueNumber) : null;
284
+ }
285
+ function escapeRegExp(value) {
286
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
287
+ }
288
+ function textMentionsIssue(text, ref, cachedRepo) {
289
+ const token = issueNumberToken(ref.number);
290
+ if (token === null) {
291
+ return false;
292
+ }
293
+ const hash = new RegExp(`(^|[^A-Za-z0-9_#])#${token}(?!\\d)`);
294
+ if (ref.repo === cachedRepo && hash.test(text)) {
295
+ return true;
296
+ }
297
+ if (ref.repo === null) {
298
+ return false;
299
+ }
300
+ const repo = escapeRegExp(ref.repo);
301
+ const qualifiedUrl = new RegExp(`(?:github\\.com|api\\.github\\.com/repos)/${repo}/issues/${token}(?:\\D|$)`);
302
+ return qualifiedUrl.test(text);
303
+ }
304
+ function looksLikeTracker(issue) {
305
+ if (issue.subIssuesTotal > 0) {
306
+ return true;
307
+ }
308
+ if (issue.labels.some((label) => TRACKER_LABELS.has(label) || label.includes("umbrella"))) {
309
+ return true;
310
+ }
311
+ return /\b(epic|omnibus|tracker|umbrella)\b/i.test(issue.title);
312
+ }
313
+ function findCachedIssueBodyReferences(projectRoot, targetIssueRefs, out) {
314
+ const targetNumbers = new Set(targetIssueRefs.map((ref) => ref.number));
315
+ if (targetNumbers.size === 0) {
316
+ return;
317
+ }
318
+ if (!targetIssueRefs.some((ref) => ref.repo !== null)) {
319
+ return;
320
+ }
321
+ for (const repo of cachedRepoDirs(projectRoot)) {
322
+ const [owner, name] = repo.split("/", 2);
323
+ if (!owner || !name) {
324
+ continue;
325
+ }
326
+ const repoDir = join(projectRoot, CACHE_DIR_NAME, CACHE_SOURCE_GITHUB_ISSUE, owner, name);
327
+ for (const entry of readdirSync(repoDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
328
+ if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) {
329
+ continue;
330
+ }
331
+ const issueNumber = Number(entry.name);
332
+ if (targetNumbers.has(issueNumber)) {
333
+ continue;
334
+ }
335
+ const issue = readCachedIssue(projectRoot, repo, issueNumber);
336
+ if (issue === null || issue.state !== "open" || !looksLikeTracker(issue)) {
337
+ continue;
338
+ }
339
+ const haystack = `${issue.title}\n${issue.body}`;
340
+ if (!targetIssueRefs.some((ref) => textMentionsIssue(haystack, ref, repo))) {
341
+ continue;
342
+ }
343
+ addReference(out, {
344
+ repo,
345
+ issueNumber: issue.number,
346
+ title: issue.title,
347
+ path: null,
348
+ source: "cached issue body",
349
+ });
350
+ }
351
+ }
352
+ }
353
+ /** Find open umbrella/tracker surfaces that still mention a just-completed scope. */
354
+ export function findOpenUmbrellaReferences(projectRoot, completedScopePath) {
355
+ const data = readJson(completedScopePath);
356
+ const completedPlan = planOf(data);
357
+ if (completedPlan === null) {
358
+ return [];
359
+ }
360
+ const out = new Map();
361
+ findLocalReferences(projectRoot, completedScopePath, completedPlan, out);
362
+ findCachedIssueBodyReferences(projectRoot, issueRefsFromPlan(completedPlan), out);
363
+ return [...out.values()]
364
+ .map((ref) => ({
365
+ repo: ref.repo,
366
+ issueNumber: ref.issueNumber,
367
+ title: ref.title,
368
+ path: ref.path,
369
+ sources: [...ref.sources].sort(),
370
+ }))
371
+ .sort((a, b) => {
372
+ const aKey = a.issueNumber !== null
373
+ ? a.repo === null
374
+ ? `bare:#${a.issueNumber}`
375
+ : `${a.repo}#${a.issueNumber}`
376
+ : (a.path ?? "");
377
+ const bKey = b.issueNumber !== null
378
+ ? b.repo === null
379
+ ? `bare:#${b.issueNumber}`
380
+ : `${b.repo}#${b.issueNumber}`
381
+ : (b.path ?? "");
382
+ return aKey.localeCompare(bKey);
383
+ });
384
+ }
385
+ function displayReference(ref) {
386
+ const issue = ref.issueNumber !== null ? `#${ref.issueNumber}${ref.repo ? ` (${ref.repo})` : ""}` : null;
387
+ const path = ref.path !== null ? ref.path : null;
388
+ const name = issue ?? path ?? ref.title;
389
+ return `${name}: ${ref.title}`;
390
+ }
391
+ export function renderOpenUmbrellaWarning(refs) {
392
+ if (refs.length === 0) {
393
+ return "";
394
+ }
395
+ const names = refs.map(displayReference).join("; ");
396
+ return (`Warning: scope:complete found open umbrella/tracker reference(s) to the completed scope: ${names}. ` +
397
+ "Run `task vbrief:reconcile:umbrellas` to refresh current-shape comments.");
398
+ }
399
+ export function completedPathForScopeMove(filePath) {
400
+ const resolved = resolve(filePath);
401
+ return join(dirname(dirname(resolved)), "completed", basename(resolved));
402
+ }
403
+ //# sourceMappingURL=open-umbrella-warning.js.map
@@ -1,4 +1,4 @@
1
- import { resolve } from "node:path";
1
+ import { resolve, sep } from "node:path";
2
2
  import { referenceTypeMatches } from "@deftai/directive-types";
3
3
  import { resolveLifecycleArtifactRef, } from "../layout/lifecycle-ref.js";
4
4
  import { hasArtifactSuffix, stripArtifactSuffix } from "../layout/resolve.js";
@@ -87,7 +87,7 @@ export function scopeIdsForFilename(filename) {
87
87
  export function relativeToVbrief(path, vbriefRoot) {
88
88
  const resolved = resolve(path);
89
89
  const root = resolve(vbriefRoot);
90
- if (!resolved.startsWith(`${root}/`) && resolved !== root) {
90
+ if (!resolved.startsWith(root + sep) && resolved !== root) {
91
91
  return null;
92
92
  }
93
93
  return resolved.slice(root.length + 1).replace(/\\/g, "/");
@@ -95,7 +95,7 @@ export function relativeToVbrief(path, vbriefRoot) {
95
95
  export function canonicalRelpath(filePath, projectRoot) {
96
96
  const resolved = resolve(filePath);
97
97
  const root = resolve(projectRoot);
98
- if (resolved.startsWith(`${root}/`) || resolved === root) {
98
+ if (resolved.startsWith(root + sep) || resolved === root) {
99
99
  return resolved.slice(root.length + (resolved === root ? 0 : 1)).replace(/\\/g, "/");
100
100
  }
101
101
  return resolved.replace(/\\/g, "/");