@deftai/directive-core 0.99.0 → 0.100.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 (102) hide show
  1. package/dist/authz/actions.d.ts +6 -0
  2. package/dist/authz/actions.js +3 -0
  3. package/dist/authz/classify.js +157 -23
  4. package/dist/authz/decompose-apply.d.ts +67 -0
  5. package/dist/authz/decompose-apply.js +302 -0
  6. package/dist/authz/index.d.ts +3 -1
  7. package/dist/authz/index.js +3 -1
  8. package/dist/authz/store.d.ts +53 -0
  9. package/dist/authz/store.js +219 -1
  10. package/dist/authz/types.d.ts +19 -2
  11. package/dist/authz/types.js +5 -1
  12. package/dist/doctor/checks.d.ts +44 -0
  13. package/dist/doctor/checks.js +428 -2
  14. package/dist/doctor/main.d.ts +6 -7
  15. package/dist/doctor/main.js +72 -79
  16. package/dist/doctor/which.d.ts +20 -1
  17. package/dist/doctor/which.js +67 -1
  18. package/dist/finish-loop/pr-finish-loop.d.ts +13 -2
  19. package/dist/finish-loop/pr-finish-loop.js +116 -5
  20. package/dist/finish-loop/types.d.ts +1 -1
  21. package/dist/hooks/dispatcher.d.ts +9 -5
  22. package/dist/hooks/dispatcher.js +23 -15
  23. package/dist/hooks/readonly.d.ts +6 -3
  24. package/dist/hooks/readonly.js +44 -25
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +1 -0
  27. package/dist/init-deposit/agent-hooks.d.ts +20 -0
  28. package/dist/init-deposit/agent-hooks.js +51 -31
  29. package/dist/intake/issue-ingest.js +29 -0
  30. package/dist/lifecycle/completed-consistency.d.ts +60 -0
  31. package/dist/lifecycle/completed-consistency.js +443 -0
  32. package/dist/lifecycle/completed-tracked-on-delivery.d.ts +47 -0
  33. package/dist/lifecycle/completed-tracked-on-delivery.js +415 -0
  34. package/dist/lifecycle/index.d.ts +2 -0
  35. package/dist/lifecycle/index.js +2 -0
  36. package/dist/literal-acceptance/capture.d.ts +52 -0
  37. package/dist/literal-acceptance/capture.js +658 -0
  38. package/dist/literal-acceptance/evaluate.d.ts +47 -0
  39. package/dist/literal-acceptance/evaluate.js +193 -0
  40. package/dist/literal-acceptance/index.d.ts +12 -0
  41. package/dist/literal-acceptance/index.js +12 -0
  42. package/dist/literal-acceptance/run.d.ts +38 -0
  43. package/dist/literal-acceptance/run.js +216 -0
  44. package/dist/literal-acceptance/safety.d.ts +24 -0
  45. package/dist/literal-acceptance/safety.js +251 -0
  46. package/dist/literal-acceptance/types.d.ts +82 -0
  47. package/dist/literal-acceptance/types.js +26 -0
  48. package/dist/policy/ceremony-dial.d.ts +67 -11
  49. package/dist/policy/ceremony-dial.js +174 -23
  50. package/dist/policy/index.d.ts +1 -0
  51. package/dist/policy/index.js +1 -0
  52. package/dist/policy/merge-approval-head.d.ts +129 -0
  53. package/dist/policy/merge-approval-head.js +461 -0
  54. package/dist/policy/require-human-merge.d.ts +5 -0
  55. package/dist/policy/require-human-merge.js +5 -0
  56. package/dist/pr-merge-readiness/ci-gate.d.ts +20 -3
  57. package/dist/pr-merge-readiness/ci-gate.js +65 -5
  58. package/dist/pr-merge-readiness/compute.d.ts +13 -0
  59. package/dist/pr-merge-readiness/compute.js +99 -14
  60. package/dist/pr-merge-readiness/gh.d.ts +49 -0
  61. package/dist/pr-merge-readiness/gh.js +268 -0
  62. package/dist/pr-merge-readiness/index.d.ts +2 -2
  63. package/dist/pr-merge-readiness/index.js +2 -2
  64. package/dist/pr-merge-readiness/test-gh-fixtures.helpers.js +4 -0
  65. package/dist/pr-wait-mergeable/cascade.d.ts +11 -0
  66. package/dist/pr-wait-mergeable/cascade.js +115 -1
  67. package/dist/pr-wait-mergeable/main.d.ts +4 -0
  68. package/dist/pr-wait-mergeable/main.js +4 -0
  69. package/dist/pr-wait-mergeable/types.d.ts +3 -1
  70. package/dist/pr-wait-mergeable/wrappers.d.ts +6 -1
  71. package/dist/pr-wait-mergeable/wrappers.js +7 -1
  72. package/dist/preflight/evaluate.d.ts +15 -1
  73. package/dist/preflight/evaluate.js +33 -2
  74. package/dist/render/framework-commands.js +4 -0
  75. package/dist/scope/acceptance-evidence.d.ts +76 -0
  76. package/dist/scope/acceptance-evidence.js +348 -0
  77. package/dist/scope/coverage-map.d.ts +98 -0
  78. package/dist/scope/coverage-map.js +558 -0
  79. package/dist/scope/decompose.js +125 -11
  80. package/dist/scope/index.d.ts +3 -0
  81. package/dist/scope/index.js +3 -0
  82. package/dist/scope/parent-lineage.d.ts +125 -0
  83. package/dist/scope/parent-lineage.js +626 -0
  84. package/dist/scope/transition.d.ts +8 -0
  85. package/dist/scope/transition.js +61 -2
  86. package/dist/session/active-cli.d.ts +79 -0
  87. package/dist/session/active-cli.js +382 -0
  88. package/dist/session/effort-budget.d.ts +130 -0
  89. package/dist/session/effort-budget.js +334 -0
  90. package/dist/session/index.d.ts +2 -0
  91. package/dist/session/index.js +2 -0
  92. package/dist/session/session-start.d.ts +6 -0
  93. package/dist/session/session-start.js +53 -2
  94. package/dist/session/verify-session-ritual.d.ts +14 -0
  95. package/dist/session/verify-session-ritual.js +33 -0
  96. package/dist/story-ready/evaluate.d.ts +11 -0
  97. package/dist/story-ready/evaluate.js +41 -3
  98. package/dist/triage/actions/candidates-log.d.ts +1 -2
  99. package/dist/triage/actions/candidates-log.js +37 -6
  100. package/dist/xbrief-migrate/migrate-project.d.ts +4 -0
  101. package/dist/xbrief-migrate/migrate-project.js +92 -3
  102. package/package.json +7 -3
@@ -5,7 +5,9 @@
5
5
  * agent authors must not independently satisfy implementation-approval gates.
6
6
  * Local-file forgery by a credential-compromised agent remains #983-class OOS.
7
7
  */
8
- /** Operation classes bound on a grant (Wave 1 structural binding + Wave 4 closed verbs). */
8
+ /** Structural apply op for scope:decompose (#3239 / epic #3237 Q2). Always gated. */
9
+ export const SCOPE_DECOMPOSE_APPLY_STRUCTURAL = "scope.decompose.apply.structural";
10
+ /** Operation classes bound on a grant (Wave 1 structural binding + Wave 4 closed verbs + #3239). */
9
11
  export const AUTHZ_OPERATIONS = [
10
12
  "edit",
11
13
  "push",
@@ -18,6 +20,8 @@ export const AUTHZ_OPERATIONS = [
18
20
  "release-cut",
19
21
  "release-publish",
20
22
  "release-rollback",
23
+ /** Human-origin exact-draft-digest gate for scope:decompose apply (#3239). */
24
+ SCOPE_DECOMPOSE_APPLY_STRUCTURAL,
21
25
  ];
22
26
  /**
23
27
  * Human-origin kinds accepted as approval provenance.
@@ -1,9 +1,42 @@
1
+ import type { XbriefSchemaDistance } from "../staleness-tickler/types.js";
1
2
  import type { CheckResult } from "./types.js";
3
+ /** Remediation verb for project envelope behind-major (#2971 / #3243 / #3236). */
4
+ export declare const XBRIEF_ENVELOPE_MIGRATE_COMMAND: "deft migrate:xbrief";
5
+ /** Doctor check name for envelope major mismatch (Q5 Option 2, #3243). */
6
+ export declare const XBRIEF_ENVELOPE_MAJOR_CHECK: "xbrief-envelope-version";
2
7
  export interface CheckSeams {
3
8
  readonly readText?: (path: string) => string | null;
4
9
  readonly isFile?: (path: string) => boolean;
5
10
  readonly isDir?: (path: string) => boolean;
11
+ /** List directory entries; throws on enum failure (fail-closed for live lifecycle dirs). */
12
+ readonly readdir?: (path: string) => string[];
6
13
  }
14
+ /** One scanned project envelope and its schema distance vs the framework target. */
15
+ export interface XbriefEnvelopeScanEntry {
16
+ readonly relativePath: string;
17
+ readonly declaredVersion: string | null;
18
+ readonly distance: XbriefSchemaDistance;
19
+ }
20
+ /**
21
+ * Scan live project xbrief envelopes (PROJECT-DEFINITION + pending/active) for
22
+ * schema distance vs the installed framework envelope major (#3243). Complements
23
+ * #2971 PROJECT-DEFINITION-only probe; remediation aligns with migrate hybrid
24
+ * rewrite (#3236).
25
+ */
26
+ export declare function scanXbriefEnvelopeVersions(projectRoot: string, seams?: CheckSeams, targetVersion?: string): {
27
+ readonly targetVersion: string;
28
+ readonly entries: readonly XbriefEnvelopeScanEntry[];
29
+ readonly worstDistance: XbriefSchemaDistance | null;
30
+ readonly behindMajor: readonly XbriefEnvelopeScanEntry[];
31
+ };
32
+ /**
33
+ * Fail closed when any scanned project xBRIEF envelope declares a major behind
34
+ * the framework target (#3243 / epic #3237 Q5 Option 2). Behind-minor does not
35
+ * fail this check. Remediation: `deft migrate:xbrief` (rewrites hybrid 0.6 on
36
+ * already-xbrief trees — #3236). Distinct from `stale-xbrief-schema-deposit`
37
+ * which routes schema-only drift to `directive update`.
38
+ */
39
+ export declare function checkXbriefEnvelopeMajorVersion(projectRoot: string, seams?: CheckSeams): CheckResult;
7
40
  export declare function checkQuickStartResolves(projectRoot: string, installRoot: string | null, seams?: CheckSeams): CheckResult;
8
41
  export declare function checkSkillPathsResolve(projectRoot: string, agentsMdText: string, seams?: CheckSeams): CheckResult;
9
42
  export declare function checkManifestAgreement(projectRoot: string, installRoot: string | null, seams?: CheckSeams): CheckResult;
@@ -60,6 +93,17 @@ export declare function checkGitignoreCoverage(projectRoot: string, seams?: Chec
60
93
  * Decided-off is quiet; dismiss-with-reason is pass with reason in detail.
61
94
  * Invalid typed blocks resolve fail-closed and surface via source=default-on-error.
62
95
  */
96
+ /**
97
+ * Fail closed when completed/ plan.status disagrees with the folder (#3242 AC1 / epic #3237 Q4).
98
+ * Open plan.items under completed/ are reported by `checkCompletedOpenItems` (exit-exempt
99
+ * until historical corpora are backfilled; scope:complete enforces items hard).
100
+ */
101
+ export declare function checkCompletedLifecycleConsistency(projectRoot: string): CheckResult;
102
+ /**
103
+ * Surface non-terminal plan.items under completed/ (#3242). Exit-exempt so
104
+ * historical pre-#2862 corpora do not red-light doctor; scope:complete fails closed.
105
+ */
106
+ export declare function checkCompletedOpenItems(projectRoot: string): CheckResult;
63
107
  export declare function checkCoverageCheckResumePolicy(projectRoot: string): CheckResult;
64
108
  export declare function deriveExitCode(checks: readonly CheckResult[], errors: readonly string[]): number;
65
109
  export declare function runChecksImpl(projectRoot: string, seams?: CheckSeams & {
@@ -1,21 +1,363 @@
1
- import { join } from "node:path";
1
+ import { readdirSync, statSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import { VBRIEF_VERSION } from "@deftai/directive-types";
2
4
  import { CANONICAL_GITIGNORE_BASELINE } from "../init-deposit/gitignore.js";
3
5
  import { detectDualLayout, detectLegacyLayout, dualLayoutSignpostLine, legacyLayoutSignpostLine, } from "../init-deposit/legacy-detect.js";
4
6
  import { detectCanonicalVendoredManifest, isNpmManaged, NPM_MANAGED_SENTINEL_KEY, NPM_MANAGED_SENTINEL_VALUE, } from "../init-deposit/migrate.js";
5
7
  import { resolveLifecycleRoot } from "../layout/resolve.js";
8
+ import { scanCompletedLifecycleConsistency } from "../lifecycle/completed-consistency.js";
6
9
  import { resolveCheckResume } from "../policy/check-resume.js";
7
10
  import { resolveCoverageDebt } from "../policy/coverage-debt.js";
8
11
  import { policyColonInvocation } from "../policy/policy-invocation.js";
12
+ import { classifyXbriefSchemaDistance } from "../staleness-tickler/probe-xbrief.js";
9
13
  import { findSkillPathsInText } from "../text/redos-safe.js";
10
14
  import { stripGitignoreInlineComment } from "../triage/bootstrap/gitignore.js";
11
- import { LEGACY_INFO_ROOT_KEY, MIGRATED_ARTIFACT_DIR } from "../xbrief-migrate/constants.js";
15
+ import { LEGACY_ARTIFACT_DIR, LEGACY_INFO_ROOT_KEY, LEGACY_VBRIEF_VERSION, MIGRATED_ARTIFACT_DIR, MIGRATED_ARTIFACT_SUFFIX, } from "../xbrief-migrate/constants.js";
12
16
  import { detectXbriefConvergence } from "../xbrief-migrate/detect.js";
17
+ import { readDeclaredArtifactVersion } from "../xbrief-migrate/transforms.js";
13
18
  import { CANONICAL_UPGRADE_COMMAND, GO_BRIDGE_RELEASES_URL, UPGRADING_DOC_URL, } from "./constants.js";
14
19
  import { isDeprecationRedirectStub, locateManifest, manifestCandidatePaths, manifestReportableVersion, manifestTagToVersion, parseInstallManifest, parseInstallRootFromAgentsMd, parseManifest, } from "./manifest.js";
15
20
  import { readTextSafe } from "./paths.js";
21
+ /** Remediation verb for project envelope behind-major (#2971 / #3243 / #3236). */
22
+ export const XBRIEF_ENVELOPE_MIGRATE_COMMAND = "deft migrate:xbrief";
23
+ /** Doctor check name for envelope major mismatch (Q5 Option 2, #3243). */
24
+ export const XBRIEF_ENVELOPE_MAJOR_CHECK = "xbrief-envelope-version";
25
+ /** True when an fs error means the path is cleanly absent (not unreadable). */
26
+ function isEnoentError(err) {
27
+ return (typeof err === "object" &&
28
+ err !== null &&
29
+ "code" in err &&
30
+ err.code === "ENOENT");
31
+ }
16
32
  function readText(path, seams) {
17
33
  return (seams.readText ?? readTextSafe)(path);
18
34
  }
35
+ function isDirectoryPath(path, seams) {
36
+ if (seams.isDir) {
37
+ return seams.isDir(path);
38
+ }
39
+ try {
40
+ return statSync(path).isDirectory();
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ /** Probe whether `path` is a file, absent, or unstatable (include for fail-closed). */
47
+ function probeFilePresence(path, seams) {
48
+ if (seams.isFile) {
49
+ try {
50
+ return seams.isFile(path) ? "present" : "absent";
51
+ }
52
+ catch (err) {
53
+ return isEnoentError(err) ? "absent" : "unreadable";
54
+ }
55
+ }
56
+ try {
57
+ return statSync(path).isFile() ? "present" : "absent";
58
+ }
59
+ catch (err) {
60
+ // ENOENT → cleanly missing; EACCES/EPERM/other → treat as live but unreadable
61
+ // so Doctor cannot skip/pass past a definition it cannot inspect (#3243).
62
+ return isEnoentError(err) ? "absent" : "unreadable";
63
+ }
64
+ }
65
+ /**
66
+ * Probe whether `path` is a directory, absent, or unstatable.
67
+ * Unreadable pending/active must fail closed via enumFailures, not silent skip.
68
+ */
69
+ function probeDirectoryPresence(path, seams) {
70
+ if (seams.isDir) {
71
+ try {
72
+ return seams.isDir(path) ? "present" : "absent";
73
+ }
74
+ catch (err) {
75
+ return isEnoentError(err) ? "absent" : "unreadable";
76
+ }
77
+ }
78
+ try {
79
+ return statSync(path).isDirectory() ? "present" : "absent";
80
+ }
81
+ catch (err) {
82
+ return isEnoentError(err) ? "absent" : "unreadable";
83
+ }
84
+ }
85
+ /**
86
+ * Live project envelopes for fail-closed major mismatch (#3243 first ship).
87
+ * PROJECT-DEFINITION + pending/active in-flight work. Proposed backlog and
88
+ * completed/cancelled archives are not fail-closed here (historical debt; full
89
+ * tree rewrite remains on `deft migrate:xbrief` / #3236).
90
+ */
91
+ const ENVELOPE_MAJOR_SCAN_FOLDERS = ["pending", "active"];
92
+ /**
93
+ * Collect live-path `*.xbrief.json` envelopes under xbrief/ for major check.
94
+ * Existing lifecycle dirs that cannot be listed are returned as
95
+ * `enumFailures` (directory relative paths) so the scan fails closed rather
96
+ * than treating the tree as empty (#3243 review).
97
+ */
98
+ function collectLiveXbriefEnvelopePaths(projectRoot, seams) {
99
+ const migratedRoot = join(projectRoot, MIGRATED_ARTIFACT_DIR);
100
+ const paths = [];
101
+ const enumFailures = [];
102
+ const definitionPath = join(migratedRoot, `PROJECT-DEFINITION${MIGRATED_ARTIFACT_SUFFIX}`);
103
+ // Existence must not require a successful read/stat — unreadable definitions
104
+ // still enter the scan so the fail-closed unreadable branch can fire
105
+ // (#3243 review: ENOENT = absent; EACCES/EPERM/other = include).
106
+ const readdir = seams.readdir ?? readdirSync;
107
+ const definitionPresence = probeFilePresence(definitionPath, seams);
108
+ if (definitionPresence === "present" || definitionPresence === "unreadable") {
109
+ paths.push(definitionPath);
110
+ }
111
+ for (const folder of ENVELOPE_MAJOR_SCAN_FOLDERS) {
112
+ const dir = join(migratedRoot, folder);
113
+ const dirPresence = probeDirectoryPresence(dir, seams);
114
+ if (dirPresence === "absent") {
115
+ // Clean absence (ENOENT / seam false) — greenfield-ok for that folder.
116
+ continue;
117
+ }
118
+ if (dirPresence === "unreadable") {
119
+ // Stat failed with non-ENOENT before readdir — fail closed (do not pretend empty).
120
+ enumFailures.push(relative(projectRoot, dir).replace(/\\/g, "/"));
121
+ continue;
122
+ }
123
+ let names;
124
+ try {
125
+ names = readdir(dir);
126
+ }
127
+ catch {
128
+ // Dir exists but cannot be listed — fail closed via synthetic scan entry
129
+ // (directory path; no invented filenames).
130
+ enumFailures.push(relative(projectRoot, dir).replace(/\\/g, "/"));
131
+ continue;
132
+ }
133
+ for (const name of names) {
134
+ if (name.endsWith(MIGRATED_ARTIFACT_SUFFIX)) {
135
+ paths.push(join(dir, name));
136
+ }
137
+ }
138
+ }
139
+ return { paths, enumFailures };
140
+ }
141
+ /**
142
+ * Scan live project xbrief envelopes (PROJECT-DEFINITION + pending/active) for
143
+ * schema distance vs the installed framework envelope major (#3243). Complements
144
+ * #2971 PROJECT-DEFINITION-only probe; remediation aligns with migrate hybrid
145
+ * rewrite (#3236).
146
+ */
147
+ export function scanXbriefEnvelopeVersions(projectRoot, seams = {}, targetVersion = VBRIEF_VERSION) {
148
+ const { paths, enumFailures } = collectLiveXbriefEnvelopePaths(projectRoot, seams);
149
+ const entries = [];
150
+ // Existing pending/active dirs that cannot be enumerated fail closed as
151
+ // behind-major (null declared) — same class as unreadable files (#3243 review).
152
+ for (const relativePath of enumFailures) {
153
+ entries.push({
154
+ relativePath,
155
+ declaredVersion: null,
156
+ distance: "behind-major",
157
+ });
158
+ }
159
+ for (const filePath of paths) {
160
+ const relativePath = relative(projectRoot, filePath).replace(/\\/g, "/");
161
+ const text = readText(filePath, seams);
162
+ // Unreadable / malformed live envelopes fail closed as behind-major (null
163
+ // declared) so Doctor cannot skip to no-envelopes/pass (#3243 review).
164
+ if (text === null) {
165
+ entries.push({
166
+ relativePath,
167
+ declaredVersion: null,
168
+ distance: "behind-major",
169
+ });
170
+ continue;
171
+ }
172
+ let declared = null;
173
+ try {
174
+ const parsed = JSON.parse(text);
175
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
176
+ declared = readDeclaredArtifactVersion(parsed);
177
+ }
178
+ else {
179
+ declared = null;
180
+ }
181
+ }
182
+ catch {
183
+ declared = null;
184
+ }
185
+ const distance = classifyXbriefSchemaDistance(declared, targetVersion);
186
+ entries.push({
187
+ relativePath,
188
+ declaredVersion: declared,
189
+ distance,
190
+ });
191
+ }
192
+ const behindMajor = entries.filter((e) => e.distance === "behind-major");
193
+ let worstDistance = null;
194
+ if (behindMajor.length > 0) {
195
+ worstDistance = "behind-major";
196
+ }
197
+ else if (entries.some((e) => e.distance === "behind-minor")) {
198
+ worstDistance = "behind-minor";
199
+ }
200
+ else if (entries.length > 0) {
201
+ worstDistance = "current";
202
+ }
203
+ return { targetVersion, entries, worstDistance, behindMajor };
204
+ }
205
+ /**
206
+ * Fail closed when any scanned project xBRIEF envelope declares a major behind
207
+ * the framework target (#3243 / epic #3237 Q5 Option 2). Behind-minor does not
208
+ * fail this check. Remediation: `deft migrate:xbrief` (rewrites hybrid 0.6 on
209
+ * already-xbrief trees — #3236). Distinct from `stale-xbrief-schema-deposit`
210
+ * which routes schema-only drift to `directive update`.
211
+ */
212
+ export function checkXbriefEnvelopeMajorVersion(projectRoot, seams = {}) {
213
+ const checkName = XBRIEF_ENVELOPE_MAJOR_CHECK;
214
+ const targetVersion = VBRIEF_VERSION;
215
+ const migratedRoot = join(projectRoot, MIGRATED_ARTIFACT_DIR);
216
+ const legacyRoot = join(projectRoot, LEGACY_ARTIFACT_DIR);
217
+ const hasMigrated = isDirectoryPath(migratedRoot, seams);
218
+ const hasLegacy = isDirectoryPath(legacyRoot, seams);
219
+ // Pure legacy vbrief/ trees need layout migrate first; skip rather than
220
+ // false-positive on missing xbrief envelopes.
221
+ if (hasLegacy && !hasMigrated) {
222
+ return {
223
+ name: checkName,
224
+ status: "skip",
225
+ detail: "Legacy-only layout (use layout migrate first); envelope major check skipped.",
226
+ data: { reason: "legacy-only-layout", target_version: targetVersion },
227
+ };
228
+ }
229
+ const scan = scanXbriefEnvelopeVersions(projectRoot, seams, targetVersion);
230
+ if (scan.entries.length === 0) {
231
+ return {
232
+ name: checkName,
233
+ status: "skip",
234
+ detail: "No project xBRIEF envelopes scanned (greenfield or empty lifecycle).",
235
+ data: {
236
+ reason: "no-envelopes",
237
+ target_version: targetVersion,
238
+ scanned: 0,
239
+ },
240
+ };
241
+ }
242
+ // Fail closed for behind-major live envelopes (#3243 / #3236):
243
+ // - exact 0.6 → migratable via `deft migrate:xbrief`
244
+ // - null / 0.5 / unreadable / … → structural rewrite (migrate does not clear)
245
+ // When both classes are present, report BOTH remediations in one fail so
246
+ // migrate-only guidance cannot hide the non-migratable repair (#3243 review).
247
+ const migratableBehindMajor = scan.behindMajor.filter((e) => e.declaredVersion === LEGACY_VBRIEF_VERSION);
248
+ const nonMigratableBehindMajor = scan.behindMajor.filter((e) => e.declaredVersion !== LEGACY_VBRIEF_VERSION);
249
+ if (migratableBehindMajor.length > 0 && nonMigratableBehindMajor.length > 0) {
250
+ const migSample = migratableBehindMajor.slice(0, 3);
251
+ const nonSample = nonMigratableBehindMajor.slice(0, 3);
252
+ const nonDeclared = [
253
+ ...new Set(nonSample.map((e) => e.declaredVersion ?? "missing/unreadable")),
254
+ ].join(", ");
255
+ return {
256
+ name: checkName,
257
+ status: "fail",
258
+ detail: `behind-major (mixed) -- framework ${targetVersion}: ` +
259
+ `${migratableBehindMajor.length} migratable (${LEGACY_VBRIEF_VERSION}: ` +
260
+ `${migSample.map((e) => e.relativePath).join(", ")}) and ` +
261
+ `${nonMigratableBehindMajor.length} non-migratable (declared ${nonDeclared}: ` +
262
+ `${nonSample.map((e) => e.relativePath).join(", ")}). ` +
263
+ `Next: (1) run \`${XBRIEF_ENVELOPE_MIGRATE_COMMAND}\` for exact ${LEGACY_VBRIEF_VERSION}; ` +
264
+ `(2) rewrite non-migratable live envelopes to full xBRIEFInfo@${targetVersion} structure ` +
265
+ `(not version-only; migrate does not clear them).`,
266
+ data: {
267
+ status: "behind-major-mixed",
268
+ target_version: targetVersion,
269
+ migratable_count: migratableBehindMajor.length,
270
+ non_migratable_count: nonMigratableBehindMajor.length,
271
+ behind_major_count: scan.behindMajor.length,
272
+ sample_paths: [
273
+ ...migSample.map((e) => e.relativePath),
274
+ ...nonSample.map((e) => e.relativePath),
275
+ ],
276
+ next_command: XBRIEF_ENVELOPE_MIGRATE_COMMAND,
277
+ suggestion: `${XBRIEF_ENVELOPE_MIGRATE_COMMAND} for 0.6; rewrite non-migratable to full ` +
278
+ `xBRIEFInfo@${targetVersion} structure (not version-only)`,
279
+ },
280
+ };
281
+ }
282
+ if (migratableBehindMajor.length > 0) {
283
+ const sample = migratableBehindMajor.slice(0, 5);
284
+ const samplePaths = sample.map((e) => e.relativePath).join(", ");
285
+ const more = migratableBehindMajor.length > sample.length
286
+ ? ` (+${migratableBehindMajor.length - sample.length} more)`
287
+ : "";
288
+ return {
289
+ name: checkName,
290
+ status: "fail",
291
+ detail: `behind-major -- declared ${LEGACY_VBRIEF_VERSION}, framework ${targetVersion} ` +
292
+ `(${migratableBehindMajor.length} artifact(s): ${samplePaths}${more}). ` +
293
+ `Next action: run \`${XBRIEF_ENVELOPE_MIGRATE_COMMAND}\` to bump project JSON envelopes ` +
294
+ `to xBRIEFInfo@${targetVersion} (layout rename alone is not enough; #3236 rewrites hybrid 0.6 in place).`,
295
+ data: {
296
+ status: "behind-major",
297
+ declared_versions: sample.map((e) => e.declaredVersion),
298
+ target_version: targetVersion,
299
+ behind_major_count: migratableBehindMajor.length,
300
+ sample_paths: sample.map((e) => e.relativePath),
301
+ next_command: XBRIEF_ENVELOPE_MIGRATE_COMMAND,
302
+ suggestion: XBRIEF_ENVELOPE_MIGRATE_COMMAND,
303
+ },
304
+ };
305
+ }
306
+ if (nonMigratableBehindMajor.length > 0) {
307
+ // Fail closed without claiming migrate:xbrief alone clears these — that verb
308
+ // rewrites exact 0.6 only (#3236). Give executable next actions per class
309
+ // (permissions / re-emit / delete-replace) so Doctor is not permanently blocked
310
+ // after a non-applicable remediation (#3243 review).
311
+ const sample = nonMigratableBehindMajor.slice(0, 5);
312
+ const declaredVersions = [
313
+ ...new Set(sample.map((e) => e.declaredVersion ?? "missing/unreadable")),
314
+ ].join(", ");
315
+ const samplePaths = sample.map((e) => e.relativePath).join(", ");
316
+ const more = nonMigratableBehindMajor.length > sample.length
317
+ ? ` (+${nonMigratableBehindMajor.length - sample.length} more)`
318
+ : "";
319
+ return {
320
+ name: checkName,
321
+ status: "fail",
322
+ detail: `behind-major (non-migratable) -- declared ${declaredVersions}, framework ${targetVersion} ` +
323
+ `(${nonMigratableBehindMajor.length} artifact(s): ${samplePaths}${more}). ` +
324
+ `Next actions: (1) if a path is unreadable, fix FS permissions then re-run doctor; ` +
325
+ `(2) re-emit full xBRIEFInfo@${targetVersion} via scope tools ` +
326
+ `(\`${XBRIEF_ENVELOPE_MIGRATE_COMMAND}\` only when declared is exact ${LEGACY_VBRIEF_VERSION}); ` +
327
+ `(3) delete or replace invalid lifecycle artifacts after human confirm — ` +
328
+ `do not only bump the version field.`,
329
+ data: {
330
+ status: "behind-major-non-migratable",
331
+ declared_versions: sample.map((e) => e.declaredVersion),
332
+ target_version: targetVersion,
333
+ behind_major_count: nonMigratableBehindMajor.length,
334
+ sample_paths: sample.map((e) => e.relativePath),
335
+ next_command: null,
336
+ suggestion: `fix FS permissions if unreadable; re-emit full xBRIEFInfo@${targetVersion} via scope tools ` +
337
+ `(migrate:xbrief only for exact ${LEGACY_VBRIEF_VERSION}); or delete/replace invalid artifacts after human confirm (not version-only)`,
338
+ },
339
+ };
340
+ }
341
+ // current or behind-minor: this check is major-only (#3243).
342
+ const declaredSummary = scan.entries
343
+ .map((e) => e.declaredVersion)
344
+ .filter((v) => typeof v === "string")
345
+ .slice(0, 3)
346
+ .join(", ") || targetVersion;
347
+ return {
348
+ name: checkName,
349
+ status: "pass",
350
+ detail: `current -- scanned ${scan.entries.length} envelope(s) at framework major ` +
351
+ `(declared sample ${declaredSummary}; framework ${targetVersion})` +
352
+ (scan.worstDistance === "behind-minor" ? "; behind-minor is non-failing for this check" : ""),
353
+ data: {
354
+ status: scan.worstDistance ?? "current",
355
+ target_version: targetVersion,
356
+ scanned: scan.entries.length,
357
+ worst_distance: scan.worstDistance,
358
+ },
359
+ };
360
+ }
19
361
  export function checkQuickStartResolves(projectRoot, installRoot, seams = {}) {
20
362
  if (installRoot === null) {
21
363
  return {
@@ -734,6 +1076,82 @@ export function checkGitignoreCoverage(projectRoot, seams = {}) {
734
1076
  * Decided-off is quiet; dismiss-with-reason is pass with reason in detail.
735
1077
  * Invalid typed blocks resolve fail-closed and surface via source=default-on-error.
736
1078
  */
1079
+ /**
1080
+ * Fail closed when completed/ plan.status disagrees with the folder (#3242 AC1 / epic #3237 Q4).
1081
+ * Open plan.items under completed/ are reported by `checkCompletedOpenItems` (exit-exempt
1082
+ * until historical corpora are backfilled; scope:complete enforces items hard).
1083
+ */
1084
+ export function checkCompletedLifecycleConsistency(projectRoot) {
1085
+ const checkName = "completed-lifecycle-consistency";
1086
+ const result = scanCompletedLifecycleConsistency(projectRoot);
1087
+ // Hard fail: folder/status drift and unreadable completed artifacts (not open items).
1088
+ const hardFindings = result.findings.filter((f) => f.kind === "status_mismatch" || f.kind === "unreadable");
1089
+ if (hardFindings.length === 0) {
1090
+ return {
1091
+ name: checkName,
1092
+ status: "pass",
1093
+ detail: "Completed folder/status consistency OK (#3242): no plan.status drift under completed/",
1094
+ data: {
1095
+ finding_count: 0,
1096
+ open_items_deferred_to: "completed-open-items",
1097
+ },
1098
+ };
1099
+ }
1100
+ const message = `Completed lifecycle folder/status consistency failed (#3242). ` +
1101
+ `${hardFindings.length} finding(s):\n` +
1102
+ hardFindings.map((f) => ` - ${f.detail}`).join("\n") +
1103
+ `\nRule: completed/ requires plan.status in [completed|failed]; artifacts must be readable.`;
1104
+ return {
1105
+ name: checkName,
1106
+ status: "fail",
1107
+ detail: message,
1108
+ data: {
1109
+ finding_count: hardFindings.length,
1110
+ findings: hardFindings.map((f) => ({
1111
+ rel_path: f.relPath,
1112
+ plan_status: f.planStatus,
1113
+ kind: f.kind,
1114
+ detail: f.detail,
1115
+ })),
1116
+ },
1117
+ };
1118
+ }
1119
+ /**
1120
+ * Surface non-terminal plan.items under completed/ (#3242). Exit-exempt so
1121
+ * historical pre-#2862 corpora do not red-light doctor; scope:complete fails closed.
1122
+ */
1123
+ export function checkCompletedOpenItems(projectRoot) {
1124
+ const checkName = "completed-open-items";
1125
+ const result = scanCompletedLifecycleConsistency(projectRoot);
1126
+ const openFindings = result.findings.filter((f) => f.kind === "open_items");
1127
+ if (openFindings.length === 0) {
1128
+ return {
1129
+ name: checkName,
1130
+ status: "pass",
1131
+ detail: "Completed open-items consistency OK (#3242): no non-terminal plan.items under completed/",
1132
+ data: { finding_count: 0 },
1133
+ };
1134
+ }
1135
+ const message = `Completed lifecycle open plan.items (#3242, advisory). ` +
1136
+ `${openFindings.length} artifact(s) under completed/ still list non-terminal items:\n` +
1137
+ openFindings.map((f) => ` - ${f.detail}`).join("\n") +
1138
+ `\nscope:complete fails closed on this class; historical corpus is advisory until backfill.`;
1139
+ return {
1140
+ name: checkName,
1141
+ status: "fail",
1142
+ detail: message,
1143
+ data: {
1144
+ finding_count: openFindings.length,
1145
+ advisory: true,
1146
+ findings: openFindings.map((f) => ({
1147
+ rel_path: f.relPath,
1148
+ plan_status: f.planStatus,
1149
+ kind: f.kind,
1150
+ detail: f.detail,
1151
+ })),
1152
+ },
1153
+ };
1154
+ }
737
1155
  export function checkCoverageCheckResumePolicy(projectRoot) {
738
1156
  const debt = resolveCoverageDebt(projectRoot);
739
1157
  const resume = resolveCheckResume(projectRoot);
@@ -813,6 +1231,8 @@ export function deriveExitCode(checks, errors) {
813
1231
  "stale-xbrief-schema-deposit",
814
1232
  "typescript-7-side-by-side",
815
1233
  "coverage-check-resume-policy",
1234
+ // Historical pre-#2862 completed/ corpora; hard fail is scope:complete (#3242).
1235
+ "completed-open-items",
816
1236
  ]);
817
1237
  if (errors.length > 0 || checks.some((c) => c.status === "error")) {
818
1238
  return 2;
@@ -853,9 +1273,12 @@ export function runChecksImpl(projectRoot, seams = {}) {
853
1273
  checks.push(checkLegacyLayout(projectRoot, seams));
854
1274
  checks.push(checkCanonicalVendoredNpmSignpost(projectRoot, seams));
855
1275
  checks.push(checkStaleXbriefSchemaDeposit(projectRoot, seams));
1276
+ checks.push(checkXbriefEnvelopeMajorVersion(projectRoot, seams));
856
1277
  checks.push(checkGitignoreCoverage(projectRoot, seams));
857
1278
  checks.push(checkTypescript7SideBySide(projectRoot, seams));
858
1279
  checks.push(checkCoverageCheckResumePolicy(projectRoot));
1280
+ checks.push(checkCompletedLifecycleConsistency(projectRoot));
1281
+ checks.push(checkCompletedOpenItems(projectRoot));
859
1282
  return {
860
1283
  projectRoot,
861
1284
  installRoot: null,
@@ -872,9 +1295,12 @@ export function runChecksImpl(projectRoot, seams = {}) {
872
1295
  checks.push(checkLegacyLayout(projectRoot, seams));
873
1296
  checks.push(checkCanonicalVendoredNpmSignpost(projectRoot, seams));
874
1297
  checks.push(checkStaleXbriefSchemaDeposit(projectRoot, seams));
1298
+ checks.push(checkXbriefEnvelopeMajorVersion(projectRoot, seams));
875
1299
  checks.push(checkGitignoreCoverage(projectRoot, seams));
876
1300
  checks.push(checkTypescript7SideBySide(projectRoot, seams));
877
1301
  checks.push(checkCoverageCheckResumePolicy(projectRoot));
1302
+ checks.push(checkCompletedLifecycleConsistency(projectRoot));
1303
+ checks.push(checkCompletedOpenItems(projectRoot));
878
1304
  return {
879
1305
  projectRoot,
880
1306
  installRoot,
@@ -5,13 +5,12 @@ export declare function cmdDoctor(args: readonly string[], seams?: DoctorSeams):
5
5
  export declare function runAgentHooksHealthCheck(projectRoot: string, consumerContext: boolean, sink: ReturnType<typeof createPlainSink>, addFinding: (finding: Finding) => void, seams: DoctorSeams): boolean;
6
6
  export declare function runAgentHooksLiveProbeCheck(projectRoot: string, sink: ReturnType<typeof createPlainSink>, addFinding: (finding: Finding) => void, seams: DoctorSeams): void;
7
7
  /**
8
- * Fail closed when PROJECT-DEFINITION under an xbrief/ layout still declares
9
- * envelope 0.6 while the framework schema is 0.8 (#2971). Greenfield (no
10
- * project definition yet) and current 0.8 envelopes pass. Unreadable paths
11
- * (test seams / permission) skip rather than false-positive fail. Behind-major
12
- * records `deft migrate:xbrief` as the next action — layout rename alone is not
13
- * enough. Distinct from deposited-schema (`stale-xbrief-schema-deposit`) which
14
- * must NOT route to migrate:xbrief when only framework schema files are stale.
8
+ * Fail closed when any scanned project xbrief lifecycle `*.xbrief.json` envelope
9
+ * declares a schema major behind the framework target (#2971 / #3243). Greenfield
10
+ * (no envelopes) skips. Behind-major records `deft migrate:xbrief` as the next
11
+ * action (hybrid in-place rewrite on already-xbrief trees #3236). Distinct from
12
+ * deposited-schema (`stale-xbrief-schema-deposit`) which must NOT route to
13
+ * migrate:xbrief when only framework schema files are stale.
15
14
  */
16
15
  export declare function runXbriefEnvelopeVersionCheck(projectRoot: string, sink: ReturnType<typeof createPlainSink>, addFinding: (f: Finding) => void, seams: DoctorSeams): void;
17
16
  /**