@deftai/directive-core 0.68.1 → 0.70.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 (92) hide show
  1. package/dist/deposit/contain.d.ts +41 -0
  2. package/dist/deposit/contain.js +113 -0
  3. package/dist/deposit/copy-tree.js +8 -2
  4. package/dist/doctor/checks.d.ts +11 -0
  5. package/dist/doctor/checks.js +75 -2
  6. package/dist/doctor/constants.d.ts +9 -0
  7. package/dist/doctor/constants.js +11 -0
  8. package/dist/doctor/main.d.ts +47 -1
  9. package/dist/doctor/main.js +406 -3
  10. package/dist/doctor/manifest.d.ts +20 -0
  11. package/dist/doctor/manifest.js +22 -0
  12. package/dist/doctor/payload-staleness.d.ts +8 -0
  13. package/dist/doctor/payload-staleness.js +11 -8
  14. package/dist/doctor/types.d.ts +56 -0
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.js +2 -0
  17. package/dist/init-deposit/gitignore.d.ts +18 -0
  18. package/dist/init-deposit/gitignore.js +94 -15
  19. package/dist/init-deposit/headless-manifest.d.ts +87 -0
  20. package/dist/init-deposit/headless-manifest.js +261 -0
  21. package/dist/init-deposit/index.d.ts +3 -0
  22. package/dist/init-deposit/index.js +3 -0
  23. package/dist/init-deposit/init-deposit.js +6 -0
  24. package/dist/init-deposit/init-dispatch.d.ts +90 -0
  25. package/dist/init-deposit/init-dispatch.js +170 -0
  26. package/dist/init-deposit/refresh.d.ts +79 -2
  27. package/dist/init-deposit/refresh.js +182 -5
  28. package/dist/init-deposit/scaffold.d.ts +25 -0
  29. package/dist/init-deposit/scaffold.js +57 -0
  30. package/dist/init-deposit/untrack-core.d.ts +84 -0
  31. package/dist/init-deposit/untrack-core.js +150 -0
  32. package/dist/intake/issue-ingest.d.ts +11 -0
  33. package/dist/intake/issue-ingest.js +86 -11
  34. package/dist/policy/plan-extensions.d.ts +31 -0
  35. package/dist/policy/plan-extensions.js +45 -0
  36. package/dist/policy/wip.d.ts +2 -2
  37. package/dist/policy/wip.js +2 -2
  38. package/dist/resolution/classify.d.ts +41 -0
  39. package/dist/resolution/classify.js +138 -0
  40. package/dist/resolution/engine-ladder.d.ts +98 -0
  41. package/dist/resolution/engine-ladder.js +185 -0
  42. package/dist/resolution/index.d.ts +19 -0
  43. package/dist/resolution/index.js +19 -0
  44. package/dist/resolution/integrity.d.ts +48 -0
  45. package/dist/resolution/integrity.js +80 -0
  46. package/dist/resolution/package-manager.d.ts +77 -0
  47. package/dist/resolution/package-manager.js +103 -0
  48. package/dist/resolution/pin.d.ts +73 -0
  49. package/dist/resolution/pin.js +169 -0
  50. package/dist/resolution/plan.d.ts +66 -0
  51. package/dist/resolution/plan.js +219 -0
  52. package/dist/resolution/skew-policy.d.ts +46 -0
  53. package/dist/resolution/skew-policy.js +120 -0
  54. package/dist/session/session-start.d.ts +2 -0
  55. package/dist/session/session-start.js +28 -1
  56. package/dist/swarm/routing.js +10 -3
  57. package/dist/triage/bulk/index.d.ts +11 -2
  58. package/dist/triage/bulk/index.js +41 -4
  59. package/dist/triage/help/registry-data.d.ts +5 -5
  60. package/dist/triage/help/registry-data.js +18 -5
  61. package/dist/triage/scope/cli.d.ts +2 -1
  62. package/dist/triage/scope/cli.js +30 -6
  63. package/dist/triage/scope/mutations.d.ts +10 -0
  64. package/dist/triage/scope/mutations.js +22 -0
  65. package/dist/triage/welcome/constants.d.ts +1 -2
  66. package/dist/triage/welcome/constants.js +1 -2
  67. package/dist/triage/welcome/index.d.ts +1 -0
  68. package/dist/triage/welcome/index.js +1 -0
  69. package/dist/triage/welcome/onboard.d.ts +35 -0
  70. package/dist/triage/welcome/onboard.js +94 -0
  71. package/dist/umbrella-current-shape/index.d.ts +25 -1
  72. package/dist/umbrella-current-shape/index.js +84 -4
  73. package/dist/user-config/index.d.ts +2 -0
  74. package/dist/user-config/index.js +2 -0
  75. package/dist/user-config/resolve-user-md.d.ts +76 -0
  76. package/dist/user-config/resolve-user-md.js +120 -0
  77. package/dist/vbrief-build/project-definition-io.d.ts +6 -0
  78. package/dist/vbrief-build/project-definition-io.js +7 -2
  79. package/dist/vbrief-validate/precutover.js +7 -3
  80. package/dist/xbrief-migrate/constants.d.ts +10 -0
  81. package/dist/xbrief-migrate/constants.js +19 -0
  82. package/dist/xbrief-migrate/detect.d.ts +34 -0
  83. package/dist/xbrief-migrate/detect.js +33 -0
  84. package/dist/xbrief-migrate/fs-helpers.d.ts +13 -0
  85. package/dist/xbrief-migrate/fs-helpers.js +46 -1
  86. package/dist/xbrief-migrate/index.d.ts +4 -2
  87. package/dist/xbrief-migrate/index.js +4 -2
  88. package/dist/xbrief-migrate/migrate-project.d.ts +23 -1
  89. package/dist/xbrief-migrate/migrate-project.js +97 -8
  90. package/dist/xbrief-migrate/signpost.d.ts +7 -1
  91. package/dist/xbrief-migrate/signpost.js +18 -3
  92. package/package.json +7 -3
@@ -212,6 +212,63 @@ export function writeInstallManifest(projectDir, deftDir, fields) {
212
212
  writeFileSync(path, body, "utf8");
213
213
  return path;
214
214
  }
215
+ /** Canonical committed pin dependency name (mirrors resolution/pin.ts). */
216
+ export const PIN_DEPENDENCY_NAME = "@deftai/directive";
217
+ /**
218
+ * Write the committed `package.json` version pin on `@deftai/directive`
219
+ * (exact devDependency; `"private": true` preserved) so the resolution spine
220
+ * has a canonical, npm-native pin to read before directive runs. This unblocks
221
+ * #2269 (which is gated on the pin existing).
222
+ *
223
+ * Behavior:
224
+ * - existing `package.json` -> the exact devDependency pin is set/updated;
225
+ * an existing `"private"` value is preserved verbatim (never clobbered).
226
+ * - absent `package.json` -> a minimal `{ "private": true, ... }` is created.
227
+ * - idempotent -> re-running with the same pin makes no change.
228
+ *
229
+ * This is a deposit primitive; wiring it into the `init` verb flow is owned by
230
+ * #2265 (the init-consumes-plan child), per the #2264 scope guard.
231
+ */
232
+ export function ensurePackageJsonPin(projectDir, version, io) {
233
+ const pinVersion = version.trim().replace(/^v/i, "");
234
+ const path = join(projectDir, "package.json");
235
+ const existed = existsSync(path);
236
+ let pkg = {};
237
+ if (existed) {
238
+ try {
239
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
240
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
241
+ pkg = parsed;
242
+ }
243
+ else {
244
+ throw new Error("package.json root must be a JSON object");
245
+ }
246
+ }
247
+ catch (cause) {
248
+ throw new Error(`could not parse package.json (leaving it unchanged): ${String(cause)}`);
249
+ }
250
+ }
251
+ else {
252
+ // Fresh scaffold: a consumer deposit is a non-published workspace.
253
+ pkg.private = true;
254
+ }
255
+ const devDeps = typeof pkg.devDependencies === "object" &&
256
+ pkg.devDependencies !== null &&
257
+ !Array.isArray(pkg.devDependencies)
258
+ ? { ...pkg.devDependencies }
259
+ : {};
260
+ if (devDeps[PIN_DEPENDENCY_NAME] === pinVersion) {
261
+ io.printf(`package.json already pins ${PIN_DEPENDENCY_NAME}@${pinVersion} — skipping.\n`);
262
+ return { changed: false, pinVersion, created: false };
263
+ }
264
+ devDeps[PIN_DEPENDENCY_NAME] = pinVersion;
265
+ pkg.devDependencies = devDeps;
266
+ writeFileSync(path, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
267
+ io.printf(existed
268
+ ? `package.json updated: pinned ${PIN_DEPENDENCY_NAME}@${pinVersion} (exact).\n`
269
+ : `package.json created: pinned ${PIN_DEPENDENCY_NAME}@${pinVersion} (exact, private).\n`);
270
+ return { changed: true, pinVersion, created: !existed };
271
+ }
215
272
  export function writeAgentsMd(projectDir, deftDir, io) {
216
273
  const plan = agentsRefreshPlan(projectDir, { frameworkRoot: deftDir });
217
274
  const state = plan.state;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * `migrate --untrack-core`: the one-time vendored→hybrid `.deft/core` un-commit
3
+ * (#2269). Owns the single destructive `git rm --cached -r .deft/core` index
4
+ * mutation in the framework — `init`/`update` only ever write the
5
+ * non-destructive `.gitignore` entry (boundary asserted by the colocated test).
6
+ *
7
+ * Safety contract:
8
+ * - NEVER touches working-tree content. `git rm --cached` removes files from
9
+ * the index only; the on-disk deposit is left intact.
10
+ * - Gated on a committed `package.json` pin existing (#2264 / Decision 2):
11
+ * once `.deft/core` is un-committed, content can be reconstituted from the
12
+ * pinned engine, so nothing is lost. With no pin, un-committing could leave
13
+ * the deposit unrecoverable, so we refuse.
14
+ * - Idempotent: once the deposit is un-tracked and ignored, re-running is a
15
+ * no-op that mutates nothing.
16
+ *
17
+ * The doctor detection that SURFACES this verb (the "payload tracked but should
18
+ * be ignored" signpost emitting `Next command: directive migrate --untrack-core`)
19
+ * is owned by child D (#2267); this module provides only the verb it points at.
20
+ *
21
+ * Refs #2269, #2264, #2123, #2124, #2203.
22
+ */
23
+ import { type PinReadResult } from "../resolution/pin.js";
24
+ import type { InitDepositIo } from "./constants.js";
25
+ import { type GitLsFiles } from "./gitignore.js";
26
+ /** The deposit path un-committed from the git index (never from the working tree). */
27
+ export declare const UNTRACK_CORE_PATH = ".deft/core";
28
+ export type UntrackCoreOutcome = "untracked" | "already-clean" | "refused-missing-pin" | "git-error";
29
+ export interface UntrackCoreResult {
30
+ readonly outcome: UntrackCoreOutcome;
31
+ /** 0: untracked / already-clean · 1: refused (missing pin) · 2: git error. */
32
+ readonly exitCode: 0 | 1 | 2;
33
+ /** Whether `.deft/core` was tracked in git when the verb ran. */
34
+ readonly deftCoreTracked: boolean;
35
+ /** The committed exact pin version, or null when absent / non-exact. */
36
+ readonly pinVersion: string | null;
37
+ /** Whether the `.gitignore` reconciliation changed the file. */
38
+ readonly gitignoreChanged: boolean;
39
+ readonly message: string;
40
+ }
41
+ /** Outcome of the injected `git rm --cached -r <paths>` runner. */
42
+ export interface GitRmCachedResult {
43
+ readonly ok: boolean;
44
+ /** stdout on success, or the error detail on failure. */
45
+ readonly detail: string;
46
+ }
47
+ /** Injected git runner for the destructive index mutation (default shells out). */
48
+ export type GitRmCached = (projectDir: string, paths: readonly string[]) => GitRmCachedResult;
49
+ export interface UntrackCoreSeams {
50
+ /** Probe whether `.deft/core` is git-tracked (default: `git ls-files`). */
51
+ gitLsFiles?: GitLsFiles;
52
+ /** Run `git rm --cached -r <paths>` (default: shells out to git). */
53
+ gitRmCached?: GitRmCached;
54
+ /** Read the committed `package.json` pin (default: resolution/pin.ts readPin). */
55
+ readPin?: (projectRoot: string) => PinReadResult;
56
+ /** Reconcile `.gitignore` (default: ensureUntrackCoreGitignoreLines). */
57
+ ensureGitignore?: (projectDir: string, io: InitDepositIo) => {
58
+ changed: boolean;
59
+ deftCoreIgnored: boolean;
60
+ };
61
+ /** Output sink for reconciliation messages (default: no-op; the CLI prints the summary). */
62
+ io?: InitDepositIo;
63
+ }
64
+ /**
65
+ * Orchestrate the vendored→hybrid `.deft/core` un-commit for `projectRoot`.
66
+ * Pure of process exit; the CLI wrapper maps {@link UntrackCoreResult.exitCode}
67
+ * to a process code. Three primary outcomes (untracked / already-clean /
68
+ * refused-missing-pin) plus a git-error fallthrough.
69
+ */
70
+ export declare function untrackCore(projectRoot: string, seams?: UntrackCoreSeams): UntrackCoreResult;
71
+ export interface RunUntrackCoreCliOptions {
72
+ readonly projectDir: string;
73
+ readonly jsonOut: boolean;
74
+ readonly writeOut: (text: string) => void;
75
+ readonly writeErr: (text: string) => void;
76
+ readonly seams?: UntrackCoreSeams;
77
+ }
78
+ /**
79
+ * CLI-facing wrapper: runs the un-commit, emits JSON or human output, maps the
80
+ * outcome to a 0/1/2 exit code. Refusals and git errors print to stderr;
81
+ * success prints to stdout.
82
+ */
83
+ export declare function runUntrackCoreCli(options: RunUntrackCoreCliOptions): number;
84
+ //# sourceMappingURL=untrack-core.d.ts.map
@@ -0,0 +1,150 @@
1
+ /**
2
+ * `migrate --untrack-core`: the one-time vendored→hybrid `.deft/core` un-commit
3
+ * (#2269). Owns the single destructive `git rm --cached -r .deft/core` index
4
+ * mutation in the framework — `init`/`update` only ever write the
5
+ * non-destructive `.gitignore` entry (boundary asserted by the colocated test).
6
+ *
7
+ * Safety contract:
8
+ * - NEVER touches working-tree content. `git rm --cached` removes files from
9
+ * the index only; the on-disk deposit is left intact.
10
+ * - Gated on a committed `package.json` pin existing (#2264 / Decision 2):
11
+ * once `.deft/core` is un-committed, content can be reconstituted from the
12
+ * pinned engine, so nothing is lost. With no pin, un-committing could leave
13
+ * the deposit unrecoverable, so we refuse.
14
+ * - Idempotent: once the deposit is un-tracked and ignored, re-running is a
15
+ * no-op that mutates nothing.
16
+ *
17
+ * The doctor detection that SURFACES this verb (the "payload tracked but should
18
+ * be ignored" signpost emitting `Next command: directive migrate --untrack-core`)
19
+ * is owned by child D (#2267); this module provides only the verb it points at.
20
+ *
21
+ * Refs #2269, #2264, #2123, #2124, #2203.
22
+ */
23
+ import { execFileSync } from "node:child_process";
24
+ import { PIN_DEPENDENCY_NAME, readPin } from "../resolution/pin.js";
25
+ import { ensureUntrackCoreGitignoreLines, isDepositTrackedInGit, } from "./gitignore.js";
26
+ /** The deposit path un-committed from the git index (never from the working tree). */
27
+ export const UNTRACK_CORE_PATH = ".deft/core";
28
+ /** Default `git rm --cached -r <paths>`: index-only removal, working tree preserved. */
29
+ function defaultGitRmCached(projectDir, paths) {
30
+ try {
31
+ const out = execFileSync("git", ["rm", "--cached", "-r", "--", ...paths], {
32
+ cwd: projectDir,
33
+ encoding: "utf8",
34
+ stdio: ["ignore", "pipe", "pipe"],
35
+ });
36
+ return { ok: true, detail: out };
37
+ }
38
+ catch (err) {
39
+ const detail = err instanceof Error ? err.message : String(err);
40
+ return { ok: false, detail };
41
+ }
42
+ }
43
+ function refusedMissingPinMessage(pin) {
44
+ const reason = pin.nonExact
45
+ ? `package.json pins ${PIN_DEPENDENCY_NAME} with a non-exact range spec (${pin.rawSpec ?? "?"})`
46
+ : `no committed exact package.json pin on ${PIN_DEPENDENCY_NAME} was found`;
47
+ return ("directive migrate --untrack-core: refusing to run git rm --cached on .deft/core because " +
48
+ `${reason}. Un-committing without a committed exact pin could leave the deposit content ` +
49
+ "unrecoverable (nothing to reconstitute from). Write and commit an exact pin " +
50
+ "(e.g. via `directive init` / `directive update`), then re-run.");
51
+ }
52
+ /**
53
+ * Orchestrate the vendored→hybrid `.deft/core` un-commit for `projectRoot`.
54
+ * Pure of process exit; the CLI wrapper maps {@link UntrackCoreResult.exitCode}
55
+ * to a process code. Three primary outcomes (untracked / already-clean /
56
+ * refused-missing-pin) plus a git-error fallthrough.
57
+ */
58
+ export function untrackCore(projectRoot, seams = {}) {
59
+ const gitRmCached = seams.gitRmCached ?? defaultGitRmCached;
60
+ const readPinFn = seams.readPin ?? readPin;
61
+ const ensureGitignore = seams.ensureGitignore ?? ensureUntrackCoreGitignoreLines;
62
+ const io = seams.io ?? { printf: () => { } };
63
+ const tracked = isDepositTrackedInGit(projectRoot, seams.gitLsFiles);
64
+ // Not tracked (or no git repo / git unavailable): nothing destructive to do.
65
+ // Reconcile the ignore entry so the layout is unambiguous, then report clean.
66
+ if (tracked !== true) {
67
+ const gi = ensureGitignore(projectRoot, io);
68
+ return {
69
+ outcome: "already-clean",
70
+ exitCode: 0,
71
+ deftCoreTracked: false,
72
+ pinVersion: readPinFn(projectRoot).pinVersion,
73
+ gitignoreChanged: gi.changed,
74
+ message: `directive migrate --untrack-core: .deft/core is not tracked in git — nothing to ` +
75
+ `un-commit. .gitignore ${gi.changed ? "reconciled (now ignores .deft/core/)" : "already ignores it"}.`,
76
+ };
77
+ }
78
+ // Tracked: gate on the committed pin so content stays reconstitutable.
79
+ // Refuse on either an absent pin OR a non-exact range spec — a range cannot
80
+ // deterministically reconstitute the exact deposit, so it is not a safe gate.
81
+ // The `|| pin.nonExact` makes the invariant self-documenting and robust to any
82
+ // future `readPin` that resolves a version alongside `nonExact: true` (#2269).
83
+ const pin = readPinFn(projectRoot);
84
+ if (pin.pinVersion === null || pin.nonExact) {
85
+ return {
86
+ outcome: "refused-missing-pin",
87
+ exitCode: 1,
88
+ deftCoreTracked: true,
89
+ pinVersion: null,
90
+ gitignoreChanged: false,
91
+ message: refusedMissingPinMessage(pin),
92
+ };
93
+ }
94
+ const rm = gitRmCached(projectRoot, [UNTRACK_CORE_PATH]);
95
+ if (!rm.ok) {
96
+ return {
97
+ outcome: "git-error",
98
+ exitCode: 2,
99
+ deftCoreTracked: true,
100
+ pinVersion: pin.pinVersion,
101
+ gitignoreChanged: false,
102
+ message: `directive migrate --untrack-core: git rm --cached failed: ${rm.detail.trim()}`,
103
+ };
104
+ }
105
+ const gi = ensureGitignore(projectRoot, io);
106
+ return {
107
+ outcome: "untracked",
108
+ exitCode: 0,
109
+ deftCoreTracked: true,
110
+ pinVersion: pin.pinVersion,
111
+ gitignoreChanged: gi.changed,
112
+ message: `directive migrate --untrack-core: removed ${UNTRACK_CORE_PATH} from the git index ` +
113
+ `(working tree untouched); pin ${pin.pinVersion} lets \`directive update\` reconstitute ` +
114
+ `content. .gitignore ${gi.changed ? "reconciled (now ignores .deft/core/)" : "already ignores it"}. ` +
115
+ "Commit the removal to complete the un-commit.",
116
+ };
117
+ }
118
+ function buildUntrackCoreSummaryJson(result, projectDir) {
119
+ return {
120
+ success: result.exitCode === 0,
121
+ action: "migrate-untrack-core",
122
+ outcome: result.outcome,
123
+ exit_code: result.exitCode,
124
+ project_dir: projectDir,
125
+ deft_core_tracked: result.deftCoreTracked,
126
+ pin_version: result.pinVersion,
127
+ gitignore_changed: result.gitignoreChanged,
128
+ message: result.message,
129
+ };
130
+ }
131
+ /**
132
+ * CLI-facing wrapper: runs the un-commit, emits JSON or human output, maps the
133
+ * outcome to a 0/1/2 exit code. Refusals and git errors print to stderr;
134
+ * success prints to stdout.
135
+ */
136
+ export function runUntrackCoreCli(options) {
137
+ const result = untrackCore(options.projectDir, { ...options.seams, io: { printf: () => { } } });
138
+ if (options.jsonOut) {
139
+ options.writeOut(`${JSON.stringify(buildUntrackCoreSummaryJson(result, options.projectDir), null, 2)}\n`);
140
+ return result.exitCode;
141
+ }
142
+ if (result.exitCode === 0) {
143
+ options.writeOut(`${result.message}\n`);
144
+ }
145
+ else {
146
+ options.writeErr(`${result.message}\n`);
147
+ }
148
+ return result.exitCode;
149
+ }
150
+ //# sourceMappingURL=untrack-core.js.map
@@ -1,7 +1,18 @@
1
+ import { type ScanFlag } from "../cache/scanner.js";
1
2
  import { LEGACY_ARTIFACT_SUFFIX, LEGACY_INFO_ROOT_KEY, MIGRATED_ARTIFACT_SUFFIX, MIGRATED_INFO_ROOT_KEY } from "../xbrief-migrate/constants.js";
2
3
  import { type ScmCallFn } from "./reconcile-issues.js";
3
4
  export declare const INGEST_STATUSES: readonly ["proposed", "pending", "active"];
4
5
  export type IngestStatus = (typeof INGEST_STATUSES)[number];
6
+ /**
7
+ * Thrown when the quarantine scanner hard-fails (credential-shaped content) on
8
+ * an ingested issue body/comment thread (#2306). Ingest MUST fail closed: emit
9
+ * nothing and propagate a non-zero exit rather than persisting the xBRIEF.
10
+ */
11
+ export declare class ScannerHardFailError extends Error {
12
+ readonly issueNumber: number;
13
+ readonly flags: readonly ScanFlag[];
14
+ constructor(issueNumber: number, flags: readonly ScanFlag[]);
15
+ }
5
16
  /** GitHub issue comment thread entry (REST `repos/.../issues/N/comments`). */
6
17
  export interface IssueComment {
7
18
  readonly id?: number;
@@ -1,6 +1,7 @@
1
1
  import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { basename, join, resolve } from "node:path";
3
3
  import { cacheGet } from "../cache/operations.js";
4
+ import { scan } from "../cache/scanner.js";
4
5
  import { hasArtifactSuffix, resolveLifecycleRoot } from "../layout/resolve.js";
5
6
  import { call } from "../scm/call.js";
6
7
  import { resolveProjectRoot } from "../scope/project-context.js";
@@ -11,6 +12,27 @@ import { LEGACY_ARTIFACT_SUFFIX, LEGACY_INFO_ROOT_KEY, LEGACY_VBRIEF_VERSION, MI
11
12
  import { findAcHeading, parseCheckboxItems, parseListItems, sliceAcSection, stripCodeBlocks, stripFencedCodeBlocks, } from "./markdown-scanners.js";
12
13
  import { detectRepo, extractReferencesFromVbrief, fetchOpenIssues, GITHUB_ISSUE_REF_TYPES, LIFECYCLE_FOLDERS, parseIssueNumber, } from "./reconcile-issues.js";
13
14
  export const INGEST_STATUSES = ["proposed", "pending", "active"];
15
+ /**
16
+ * Thrown when the quarantine scanner hard-fails (credential-shaped content) on
17
+ * an ingested issue body/comment thread (#2306). Ingest MUST fail closed: emit
18
+ * nothing and propagate a non-zero exit rather than persisting the xBRIEF.
19
+ */
20
+ export class ScannerHardFailError extends Error {
21
+ issueNumber;
22
+ flags;
23
+ constructor(issueNumber, flags) {
24
+ const details = flags
25
+ .filter((f) => f.severity === "hard-fail")
26
+ .map((f) => f.detail)
27
+ .join("; ");
28
+ super(`issue:ingest refused #${issueNumber}: quarantine scanner hard-fail` +
29
+ (details.length > 0 ? ` (${details})` : "") +
30
+ " -- nothing written.");
31
+ this.name = "ScannerHardFailError";
32
+ this.issueNumber = issueNumber;
33
+ this.flags = flags;
34
+ }
35
+ }
14
36
  /** Enriched on issues after `fetchIssue` when the comment thread is non-empty (#2143). */
15
37
  export const ISSUE_COMMENT_THREAD_KEY = "issueCommentThread";
16
38
  const STATUS_MAP = {
@@ -311,7 +333,14 @@ export function buildIssueVbrief(issue, status, repoUrl, options = {}) {
311
333
  };
312
334
  if (overviewSource.length > 0) {
313
335
  warnBodyControlCharacters(number, overviewSource);
314
- narratives.Overview = overviewSource;
336
+ // #2306: quarantine-scan untrusted body + comment-thread content before it
337
+ // is persisted as agent-facing scope authority. Fail closed on a credential
338
+ // hard-fail; otherwise persist the fenced/quarantined transform.
339
+ const scanResult = scan(overviewSource);
340
+ if (!scanResult.passed) {
341
+ throw new ScannerHardFailError(number, scanResult.flags);
342
+ }
343
+ narratives.Overview = scanResult.transformed_content;
315
344
  }
316
345
  if (labelNames.length > 0) {
317
346
  narratives.Labels = labelNames.join(", ");
@@ -369,6 +398,19 @@ export function fetchFromCache(repo, number, options = {}) {
369
398
  if (typeof issue.html_url === "string" && issue.html_url.length > 0) {
370
399
  issue.url = issue.html_url;
371
400
  }
401
+ // #2306: consume the cache entry's SCANNED content.md (fenced/quarantined at
402
+ // cache-put) rather than the raw body, so the cache read path cannot bypass
403
+ // the quarantine transform. When content.md is absent (e.g. a credential
404
+ // hard-fail deleted it), the raw body falls through and is re-scanned in
405
+ // buildIssueVbrief.
406
+ if (result.contentPath !== null) {
407
+ try {
408
+ issue.body = readFileSync(result.contentPath, "utf8");
409
+ }
410
+ catch {
411
+ // fall back to the raw body (re-scanned downstream)
412
+ }
413
+ }
372
414
  return issue;
373
415
  }
374
416
  catch {
@@ -514,9 +556,25 @@ export function ingestBulk(issues, options) {
514
556
  created: [],
515
557
  duplicate: [],
516
558
  dryrun: [],
559
+ failed: [],
517
560
  };
518
561
  for (const issue of filtered) {
519
- const [result, path, _msg] = ingestOne(issue, { ...options, existingRefs: refs });
562
+ let ingested;
563
+ try {
564
+ ingested = ingestOne(issue, { ...options, existingRefs: refs });
565
+ }
566
+ catch (exc) {
567
+ // #2306: a per-issue quarantine hard-fail must not sink the whole batch;
568
+ // record it, emit nothing for that issue, and surface a non-zero exit
569
+ // upstream via the `failed` bucket.
570
+ if (exc instanceof ScannerHardFailError) {
571
+ summary.failed.push(`#${exc.issueNumber}`);
572
+ process.stderr.write(`${exc.message}\n`);
573
+ continue;
574
+ }
575
+ throw exc;
576
+ }
577
+ const [result, path, _msg] = ingested;
520
578
  const rel = path !== null ? path.replace(`${options.vbriefDir}/`, "").replace(/\\/g, "/") : "";
521
579
  summary[result].push(rel);
522
580
  if (result === "created" && path !== null) {
@@ -581,7 +639,8 @@ export function issueIngestMain(args) {
581
639
  const created = summary.created;
582
640
  const duplicate = summary.duplicate;
583
641
  const dryrun = summary.dryrun;
584
- process.stdout.write(`issue:ingest bulk summary: ${created.length} created, ${duplicate.length} duplicate, ${dryrun.length} dry-run (total considered: ${summary.total})\n`);
642
+ const failed = summary.failed ?? [];
643
+ process.stdout.write(`issue:ingest bulk summary: ${created.length} created, ${duplicate.length} duplicate, ${dryrun.length} dry-run, ${failed.length} refused (total considered: ${summary.total})\n`);
585
644
  for (const entry of created) {
586
645
  process.stdout.write(` CREATED ${entry}\n`);
587
646
  }
@@ -591,19 +650,35 @@ export function issueIngestMain(args) {
591
650
  for (const entry of duplicate) {
592
651
  process.stdout.write(` SKIP ${entry} (already has scope vBRIEF)\n`);
593
652
  }
594
- return 0;
653
+ for (const entry of failed) {
654
+ process.stdout.write(` REFUSED ${entry} (quarantine scanner hard-fail; nothing written)\n`);
655
+ }
656
+ // #2306: fail closed on any quarantine hard-fail in the batch.
657
+ return failed.length > 0 ? 2 : 0;
595
658
  }
596
659
  const issue = fetchIssue(repo, args.number, { cwd: projectRoot });
597
660
  if (issue === null) {
598
661
  return 2;
599
662
  }
600
- const [result, _path, msg] = ingestOne(issue, {
601
- vbriefDir,
602
- status,
603
- repoUrl,
604
- dryRun: args.dryRun,
605
- cwd: projectRoot,
606
- });
663
+ let result;
664
+ let msg;
665
+ try {
666
+ [result, , msg] = ingestOne(issue, {
667
+ vbriefDir,
668
+ status,
669
+ repoUrl,
670
+ dryRun: args.dryRun,
671
+ cwd: projectRoot,
672
+ });
673
+ }
674
+ catch (exc) {
675
+ // #2306: fail closed -- emit nothing, non-zero exit on a quarantine hard-fail.
676
+ if (exc instanceof ScannerHardFailError) {
677
+ process.stderr.write(`${exc.message}\n`);
678
+ return 2;
679
+ }
680
+ throw exc;
681
+ }
607
682
  process.stdout.write(`${msg}\n`);
608
683
  return result === "duplicate" ? 1 : 0;
609
684
  }
@@ -32,4 +32,35 @@ export declare function readPlanCompletedNote(plan: unknown): unknown;
32
32
  * bare block alongside the namespaced one.
33
33
  */
34
34
  export declare function migrateLegacyPolicyKey(plan: Record<string, unknown>): void;
35
+ /** The namespaced/legacy key pairs a bare block can silently shadow (#2301). */
36
+ export declare const SHADOWABLE_PLAN_EXTENSIONS: ReadonlyArray<{
37
+ readonly namespacedKey: string;
38
+ readonly legacyKey: string;
39
+ }>;
40
+ /** A plan-extension key whose bare form is silently shadowed by the namespaced form. */
41
+ export interface ShadowedPlanExtension {
42
+ /** The namespaced key that wins the read (e.g. `x-directive/policy`). */
43
+ readonly namespacedKey: string;
44
+ /** The bare legacy key that is silently ignored (e.g. `policy`). */
45
+ readonly legacyKey: string;
46
+ /**
47
+ * Best-effort list of sub-keys present in the shadowed bare object (e.g.
48
+ * `["triageScope", "wipCap"]`). Empty when the bare value is not an object.
49
+ */
50
+ readonly shadowedSubKeys: readonly string[];
51
+ }
52
+ /**
53
+ * Detect every plan-extension key where a bare (legacy) block coexists with the
54
+ * namespaced form (#2301). Because `readPlanExtension` is namespace-first, the
55
+ * bare block is never read once the namespaced key exists -- edits to it take no
56
+ * effect. Detecting the coexistence lets callers emit a loud diagnostic instead
57
+ * of the silent no-op that the #2295 onboarding trap exhibited.
58
+ */
59
+ export declare function detectShadowedPlanExtensions(plan: unknown): ShadowedPlanExtension[];
60
+ /**
61
+ * Render a human-readable, loud diagnostic for a single shadowed plan-extension
62
+ * key. The message is surface-agnostic (no leading tag) so each caller can
63
+ * prefix it (`[policy:show]`, a doctor finding, ...).
64
+ */
65
+ export declare function describeShadowedPlanExtension(shadow: ShadowedPlanExtension): string;
35
66
  //# sourceMappingURL=plan-extensions.d.ts.map
@@ -54,4 +54,49 @@ export function migrateLegacyPolicyKey(plan) {
54
54
  delete plan[LEGACY_PLAN_POLICY_KEY];
55
55
  }
56
56
  }
57
+ /** The namespaced/legacy key pairs a bare block can silently shadow (#2301). */
58
+ export const SHADOWABLE_PLAN_EXTENSIONS = [
59
+ { namespacedKey: PLAN_POLICY_KEY, legacyKey: LEGACY_PLAN_POLICY_KEY },
60
+ { namespacedKey: PLAN_COMPLETED_NOTE_KEY, legacyKey: LEGACY_PLAN_COMPLETED_NOTE_KEY },
61
+ ];
62
+ /**
63
+ * Detect every plan-extension key where a bare (legacy) block coexists with the
64
+ * namespaced form (#2301). Because `readPlanExtension` is namespace-first, the
65
+ * bare block is never read once the namespaced key exists -- edits to it take no
66
+ * effect. Detecting the coexistence lets callers emit a loud diagnostic instead
67
+ * of the silent no-op that the #2295 onboarding trap exhibited.
68
+ */
69
+ export function detectShadowedPlanExtensions(plan) {
70
+ const planObj = asPlanObject(plan);
71
+ if (planObj === null) {
72
+ return [];
73
+ }
74
+ const shadows = [];
75
+ for (const { namespacedKey, legacyKey } of SHADOWABLE_PLAN_EXTENSIONS) {
76
+ if (planObj[namespacedKey] === undefined || planObj[legacyKey] === undefined) {
77
+ continue;
78
+ }
79
+ const legacyValue = planObj[legacyKey];
80
+ const shadowedSubKeys = typeof legacyValue === "object" && legacyValue !== null && !Array.isArray(legacyValue)
81
+ ? Object.keys(legacyValue)
82
+ : [];
83
+ shadows.push({ namespacedKey, legacyKey, shadowedSubKeys });
84
+ }
85
+ return shadows;
86
+ }
87
+ /**
88
+ * Render a human-readable, loud diagnostic for a single shadowed plan-extension
89
+ * key. The message is surface-agnostic (no leading tag) so each caller can
90
+ * prefix it (`[policy:show]`, a doctor finding, ...).
91
+ */
92
+ export function describeShadowedPlanExtension(shadow) {
93
+ const subKeys = shadow.shadowedSubKeys.length > 0
94
+ ? ` Shadowed field(s): ${shadow.shadowedSubKeys
95
+ .map((k) => `plan.${shadow.legacyKey}.${k}`)
96
+ .join(", ")}.`
97
+ : "";
98
+ return (`bare \`plan.${shadow.legacyKey}\` coexists with namespaced \`plan.${shadow.namespacedKey}\`; ` +
99
+ `the bare block is IGNORED (namespaced-first read) so edits to it silently take no effect.${subKeys} ` +
100
+ `Fold its values into \`plan.${shadow.namespacedKey}\` and delete \`plan.${shadow.legacyKey}\`.`);
101
+ }
57
102
  //# sourceMappingURL=plan-extensions.js.map
@@ -1,5 +1,5 @@
1
- /** Framework default WIP cap (#1124 / umbrella #1119). */
2
- export declare const DEFAULT_WIP_CAP = 10;
1
+ /** Framework default WIP cap (#2319; raised from 10 per #1124 / umbrella #1119). */
2
+ export declare const DEFAULT_WIP_CAP = 20;
3
3
  /** vBRIEF lifecycle folders that count toward the WIP set. */
4
4
  export declare const WIP_LIFECYCLE_DIRS: readonly ["pending", "active"];
5
5
  export type WipCapSource = "typed" | "default" | "default-on-error";
@@ -3,8 +3,8 @@ import { join } from "node:path";
3
3
  import { hasArtifactSuffix, resolveLifecycleRoot } from "../layout/resolve.js";
4
4
  import { readPlanPolicy } from "./plan-extensions.js";
5
5
  import { loadProjectDefinition } from "./resolve.js";
6
- /** Framework default WIP cap (#1124 / umbrella #1119). */
7
- export const DEFAULT_WIP_CAP = 10;
6
+ /** Framework default WIP cap (#2319; raised from 10 per #1124 / umbrella #1119). */
7
+ export const DEFAULT_WIP_CAP = 20;
8
8
  /** vBRIEF lifecycle folders that count toward the WIP set. */
9
9
  export const WIP_LIFECYCLE_DIRS = ["pending", "active"];
10
10
  function pythonTypeName(value) {
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Orthogonal fact-set classifier for the resolution spine (#2264 / epic #2203).
3
+ *
4
+ * `classify()` returns a flat, independent fact-set — never a single collapsed
5
+ * enum. `plan()` owns the collapse into one recommended action, so there is
6
+ * exactly one precedence table in the system.
7
+ *
8
+ * It REUSES the existing detectors rather than re-implementing them:
9
+ * - pre-cutover artifacts -> `../vbrief-validate/precutover.ts`
10
+ * - managed-section sha -> `../platform/agents-md.ts::parseManagedSectionAttrs`
11
+ * - deposited payload version-> `../doctor/manifest.ts` + `../init-deposit/constants.ts`
12
+ * - committed pin -> `./pin.ts`
13
+ */
14
+ import type { ResolutionFacts } from "@deftai/directive-types";
15
+ /** Result of probing whether an engine is reachable in the execution environment. */
16
+ export interface EngineProbeResult {
17
+ readonly reachable: boolean;
18
+ readonly version: string | null;
19
+ }
20
+ export interface ClassifySeams {
21
+ readonly isFile?: (p: string) => boolean;
22
+ readonly isDir?: (p: string) => boolean;
23
+ readonly readText?: (p: string) => string | null;
24
+ /**
25
+ * Probe for a reachable engine IN THE ENVIRONMENT THE CALLER IS RUNNING IN.
26
+ * Injected so classification is deterministic + offline in tests; the default
27
+ * shells out to the `directive` / `deft` CLI (the #2124 execution-env probe).
28
+ */
29
+ readonly engineProbe?: () => EngineProbeResult;
30
+ /** Pre-cutover probe; defaults to the shared `detectPreCutover` detector. */
31
+ readonly preCutoverProbe?: (cwd: string) => boolean;
32
+ }
33
+ /** Default engine probe: try `directive --version`, then `deft --version`. */
34
+ export declare function defaultEngineProbe(): EngineProbeResult;
35
+ /**
36
+ * Classify a project directory into the orthogonal resolution fact-set. All I/O
37
+ * is behind injectable seams so callers (tests, sandboxed runtimes) can supply a
38
+ * deterministic view of the filesystem and the engine-reachability probe.
39
+ */
40
+ export declare function classify(cwd: string, seams?: ClassifySeams): ResolutionFacts;
41
+ //# sourceMappingURL=classify.d.ts.map