@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
@@ -2,6 +2,10 @@ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { evaluate as evaluateAgentsMdAdvisory } from "../agents-md-advisory/evaluate.js";
4
4
  import { contentRoot } from "../content-root.js";
5
+ import { describeShadowedPlanExtension, detectShadowedPlanExtensions, } from "../policy/plan-extensions.js";
6
+ import { loadProjectDefinition } from "../policy/resolve.js";
7
+ import { checkLocalEngineIntegrity, classify, detectPackageManager, evaluateSkew, reconcileVersions, plan as resolvePlan, } from "../resolution/index.js";
8
+ import { resolveUserMdPath } from "../user-config/resolve-user-md.js";
5
9
  import { agentsRefreshPlan, hasV3ManagedMarker } from "./agents-md.js";
6
10
  import { runChecks } from "./checks.js";
7
11
  import { CONSUMER_FRAMEWORK_DIRS, EXPECTED_CONTENT_DIRS, EXPECTED_FRAMEWORK_DIRS, NETWORK_DISCLOSURE_LINE, PAYLOAD_STALENESS_OFFLINE_SKIP_MESSAGE, TASKFILE_INCLUDE_SNIPPET, UV_INSTALL_URL, } from "./constants.js";
@@ -10,11 +14,33 @@ import { formatAllowedFlagsHint, formatUnknownFlagsError, parseDoctorFlags } fro
10
14
  import { pythonJsonDump } from "./json.js";
11
15
  import { parseInstallRootFromAgentsMd } from "./manifest.js";
12
16
  import { createPlainSink } from "./output.js";
13
- import { resolveDefaultFrameworkRoot, resolvePath, resolveVersion, runningInsideDeftRepo, } from "./paths.js";
17
+ import { readTextSafe, resolveDefaultFrameworkRoot, resolvePath, resolveVersion, runningInsideDeftRepo, } from "./paths.js";
14
18
  import { runPayloadStalenessCheck } from "./payload-staleness.js";
15
19
  import { runLocalSignpostChecks } from "./signpost-checks.js";
16
- import { classifyTaskfileInclude, formatMissingIncludeSnippet, resolveConsumerTaskfile, } from "./taskfile.js";
20
+ import { classifyTaskfileInclude, formatMissingIncludeSnippet, includesBlockHasDeftTaskfile, resolveConsumerTaskfile, } from "./taskfile.js";
17
21
  import { defaultWhich } from "./which.js";
22
+ const DEFAULT_RESOLUTION_PLATFORMS = ["linux", "darwin", "win32"];
23
+ /**
24
+ * Read the `packageManager` field (Corepack) from a project package.json, or
25
+ * null when absent/unreadable. Lets the doctor detect a pnpm project that has
26
+ * no `pnpm-lock.yaml` yet (fresh Corepack clone) for the #2197 upgrade hint.
27
+ */
28
+ function readPackageManagerField(packageJsonPath, readTextFn) {
29
+ const raw = readTextFn(packageJsonPath);
30
+ if (raw === null)
31
+ return null;
32
+ try {
33
+ const parsed = JSON.parse(raw);
34
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
35
+ const field = parsed.packageManager;
36
+ return typeof field === "string" ? field : null;
37
+ }
38
+ }
39
+ catch {
40
+ // Malformed package.json -- fall through to null (npm default).
41
+ }
42
+ return null;
43
+ }
18
44
  export function cmdDoctor(args, seams = {}) {
19
45
  const flags = parseDoctorFlags(args);
20
46
  if (flags.unknown.length > 0) {
@@ -145,12 +171,27 @@ export function cmdDoctor(args, seams = {}) {
145
171
  if (flags.network) {
146
172
  sink.info(NETWORK_DISCLOSURE_LINE);
147
173
  sink.info("Checking payload staleness from install manifest...");
174
+ // Detect the package manager for the upgrade recommendation from signals
175
+ // that are meaningful for a globally-installed CLI: the explicit
176
+ // DEFT_PACKAGE_MANAGER override, the project's `packageManager` field
177
+ // (Corepack), and the project's pnpm-lock.yaml. We deliberately do NOT
178
+ // consult the ambient npm_config_user_agent here (it is set by whatever
179
+ // shell/script spawned the process, not by the consumer's project) so the
180
+ // recommendation is a stable property of the project (#2197).
181
+ const readTextForPm = seams.readText ?? readTextSafe;
182
+ const isFileForPm = seams.isFile ?? ((p) => existsSync(p));
183
+ const packageManager = detectPackageManager({
184
+ env: { DEFT_PACKAGE_MANAGER: process.env.DEFT_PACKAGE_MANAGER },
185
+ packageManagerField: readPackageManagerField(join(projectRoot, "package.json"), readTextForPm),
186
+ pnpmLockPresent: isFileForPm(join(projectRoot, "pnpm-lock.yaml")),
187
+ });
148
188
  runPayloadStalenessCheck(projectRoot, sink, addFinding, {
149
189
  frameworkRoot,
150
190
  readText: seams.readText,
151
191
  isFile: seams.isFile,
152
192
  runGitLsRemote: seams.runGitLsRemote,
153
193
  runNpmViewVersion: seams.runNpmViewVersion,
194
+ packageManager,
154
195
  });
155
196
  }
156
197
  else {
@@ -213,8 +254,26 @@ export function cmdDoctor(args, seams = {}) {
213
254
  if (!jsonMode) {
214
255
  sink.blank();
215
256
  }
257
+ sink.info("Checking USER.md resolution...");
258
+ const userMd = runUserMdResolutionCheck(projectRoot, sink, addFinding, seams);
259
+ if (!jsonMode) {
260
+ sink.blank();
261
+ }
262
+ sink.info("Checking plan.policy namespacing (shadowed bare keys)...");
263
+ runPlanExtensionShadowCheck(projectRoot, sink, addFinding, seams);
264
+ if (!jsonMode) {
265
+ sink.blank();
266
+ }
216
267
  sink.info("Checking optional root Taskfile.yml include...");
217
268
  runTaskfileIncludeCheck(projectRoot, fixMode, jsonMode, sink, addFinding, seams);
269
+ let resolution = null;
270
+ if (!runningInsideDeftRepo(projectRoot, seams)) {
271
+ if (!jsonMode) {
272
+ sink.blank();
273
+ sink.info("Resolution -- operating mode + single next action (from shared plan())...");
274
+ }
275
+ resolution = runResolutionDecision(projectRoot, jsonMode, sink, addFinding, seams);
276
+ }
218
277
  const errorCount = findings.filter((f) => f.severity === "error").length;
219
278
  const warningCount = findings.filter((f) => f.severity === "warning").length;
220
279
  const exitCode = errorCount > 0 ? 1 : 0;
@@ -232,6 +291,28 @@ export function cmdDoctor(args, seams = {}) {
232
291
  findings,
233
292
  summary: { errors: errorCount, warnings: warningCount },
234
293
  project_root: projectRoot,
294
+ user_md: {
295
+ path: userMd.path,
296
+ rung: userMd.rung,
297
+ found: userMd.found,
298
+ diagnostic: userMd.diagnostic,
299
+ },
300
+ ...(resolution
301
+ ? {
302
+ resolution: {
303
+ mode: resolution.mode,
304
+ operating_mode: resolution.operatingMode,
305
+ reconciliation: resolution.reconciliation,
306
+ platform_skew: resolution.platformSkew,
307
+ platform_skew_detected: resolution.platformSkewDetected,
308
+ action_required: resolution.actionRequired,
309
+ next_command: resolution.nextCommand,
310
+ root_cause: resolution.rootCause,
311
+ remediation: resolution.remediation,
312
+ warnings: resolution.warnings,
313
+ },
314
+ }
315
+ : {}),
235
316
  };
236
317
  process.stdout.write(`${pythonJsonDump(payload)}\n`);
237
318
  return exitCode;
@@ -290,7 +371,9 @@ function runInstallIntegrityChecks(projectRoot, sink, addFinding, seams) {
290
371
  sink.info(`${name}: skip -- ${detail}`);
291
372
  continue;
292
373
  }
293
- if ((name === "legacy-layout" || name === "canonical-vendored-npm-signpost") &&
374
+ if ((name === "legacy-layout" ||
375
+ name === "canonical-vendored-npm-signpost" ||
376
+ name === "manifest-version-reportable") &&
294
377
  status === "fail") {
295
378
  sink.warn(`${name}: ${detail}`);
296
379
  addFinding({
@@ -513,4 +596,324 @@ function runTaskfileIncludeCheck(projectRoot, fixMode, jsonMode, sink, addFindin
513
596
  file: taskfilePath,
514
597
  });
515
598
  }
599
+ /**
600
+ * Surface the resolved USER.md path + which search rung matched (#2271) so the
601
+ * resolution boundary is visible in doctor output. Uses the shared first-hit-
602
+ * wins resolver, which never throws: an absent USER.md degrades to a `no
603
+ * USER.md found; using defaults` diagnostic. This check never fails the doctor
604
+ * — it emits a `skip`-severity finding (informational) in both states.
605
+ */
606
+ function runUserMdResolutionCheck(projectRoot, sink, addFinding, seams) {
607
+ const resolveFn = seams.resolveUserMd ?? ((root) => resolveUserMdPath({ projectRoot: root }));
608
+ const result = resolveFn(projectRoot);
609
+ // Sanitize newlines on the data-derived path/diagnostic before it lands in a
610
+ // rendered bullet (CWE-116).
611
+ const safePath = result.path.replace(/\r?\n/g, " ");
612
+ const safeDiagnostic = result.diagnostic.replace(/\r?\n/g, " ");
613
+ if (result.found) {
614
+ sink.success(`USER.md resolved (${result.rung}): ${safePath}`);
615
+ }
616
+ else {
617
+ sink.info(`USER.md: ${safeDiagnostic}`);
618
+ }
619
+ addFinding({
620
+ severity: "skip",
621
+ message: result.found ? `USER.md resolved (${result.rung}): ${safePath}` : safeDiagnostic,
622
+ check: "user-md-resolution",
623
+ status: result.found ? "resolved" : "defaulted",
624
+ path: safePath,
625
+ rung: result.rung,
626
+ found: result.found,
627
+ });
628
+ return result;
629
+ }
630
+ /**
631
+ * Loud shadow diagnostic (#2301): flag every plan-extension key whose bare form
632
+ * (e.g. `plan.policy`) coexists with the namespaced form (`plan.x-directive/policy`).
633
+ * Because the reader is namespace-first, the bare block is silently ignored --
634
+ * the exact "edit takes no effect, no warning" trap behind #2295. Emits a
635
+ * `warning` finding per shadowed key; never an `error`, so `deft doctor` stays
636
+ * green on a merely-legibility issue. A clean project reports a `skip` finding.
637
+ */
638
+ function runPlanExtensionShadowCheck(projectRoot, sink, addFinding, seams) {
639
+ const checkName = "plan-extension-shadow";
640
+ const detect = seams.detectPlanExtensionShadows ??
641
+ ((root) => {
642
+ const [data] = loadProjectDefinition(root);
643
+ return data === null ? [] : detectShadowedPlanExtensions(data.plan);
644
+ });
645
+ try {
646
+ const shadows = detect(projectRoot);
647
+ if (shadows.length === 0) {
648
+ const cleanMessage = `${checkName}: no shadowed bare plan keys`;
649
+ sink.success(cleanMessage);
650
+ addFinding({ severity: "skip", message: cleanMessage, check: checkName, status: "clean" });
651
+ return;
652
+ }
653
+ for (const shadow of shadows) {
654
+ const message = `plan.policy shadow -- ${describeShadowedPlanExtension(shadow)}`.replace(/\r?\n/g, " ");
655
+ sink.warn(message);
656
+ addFinding({
657
+ severity: "warning",
658
+ message,
659
+ check: checkName,
660
+ status: "shadowed",
661
+ namespaced_key: shadow.namespacedKey,
662
+ legacy_key: shadow.legacyKey,
663
+ shadowed_sub_keys: [...shadow.shadowedSubKeys],
664
+ });
665
+ }
666
+ }
667
+ catch (exc) {
668
+ const detail = `${exc instanceof Error ? exc.name : "Error"}: ${exc}`.replace(/\r?\n/g, " ");
669
+ const message = `${checkName}: probe failed -- ${detail}`;
670
+ sink.warn(message);
671
+ addFinding({ severity: "warning", message, check: checkName });
672
+ }
673
+ }
674
+ /**
675
+ * Never emit a bare `task ...` remediation in a project without Taskfile wiring
676
+ * (#2267). The `directive` surface always works; `task deft:X` is optional and
677
+ * only present when the consumer wired the include. `plan()` already emits the
678
+ * `directive` / `npx` / `npm` surface, so this guard is a defensive invariant:
679
+ * any `task`-prefixed command is rewritten to the `directive` surface unless the
680
+ * project actually has the include.
681
+ */
682
+ /**
683
+ * Detect Taskfile wiring through the injected seam (#2267). Mirrors
684
+ * `classifyTaskfileInclude` but routes the filesystem read through
685
+ * `seams.readText` so the resolution decision stays deterministic + injectable
686
+ * in tests -- unlike a direct `classifyTaskfileInclude(projectRoot)` call, which
687
+ * bypasses every other seam-flowed probe in `runResolutionDecision`.
688
+ */
689
+ export function resolveTaskfileWiring(projectRoot, seams) {
690
+ const readText = seams.readText ??
691
+ ((path) => {
692
+ try {
693
+ return readFileSync(path, "utf8");
694
+ }
695
+ catch {
696
+ return null;
697
+ }
698
+ });
699
+ for (const name of ["Taskfile.yml", "Taskfile.yaml"]) {
700
+ const text = readText(join(projectRoot, name));
701
+ if (text !== null) {
702
+ return includesBlockHasDeftTaskfile(text.replace(/^\uFEFF/, ""));
703
+ }
704
+ }
705
+ return false;
706
+ }
707
+ export function enforceDirectiveSurface(command, hasTaskfileWiring) {
708
+ if (command === null) {
709
+ return null;
710
+ }
711
+ if (!hasTaskfileWiring && /^\s*task\s+/.test(command)) {
712
+ return command.replace(/^\s*task\s+/, "directive ");
713
+ }
714
+ return command;
715
+ }
716
+ /** Human-facing operating mode derived from the orthogonal fact-set. */
717
+ export function resolveOperatingMode(facts) {
718
+ if (facts.preCutoverArtifacts) {
719
+ return "pre-cutover (pre-v0.20 document model -- migrate first)";
720
+ }
721
+ if (!facts.hasDeftCore) {
722
+ if (facts.hasManagedSection) {
723
+ return "hybrid (managed AGENTS.md present; .deft/core/ payload not reconstituted)";
724
+ }
725
+ return facts.hasAppCode || facts.hasGit
726
+ ? "brownfield (existing project, no Deft deposit)"
727
+ : "greenfield (no Deft deposit)";
728
+ }
729
+ return facts.hasManagedSection
730
+ ? "hybrid (vendored .deft/core/ + managed AGENTS.md)"
731
+ : "vendored (.deft/core/ deposit, no managed AGENTS.md section)";
732
+ }
733
+ /**
734
+ * Single engine/pin/VERSION reconciliation line. `reconcileVersions` flags
735
+ * content-behind/ahead and engine-behind; engine-ahead skew is folded in here so
736
+ * the line is the one version-state summary the operator reads.
737
+ */
738
+ export function resolveReconciliationLine(facts) {
739
+ const engine = facts.engineVersion ?? "unreachable";
740
+ const content = facts.deftCorePayloadVersion ?? "none";
741
+ const pin = facts.pinVersion ?? "none";
742
+ const recon = reconcileVersions({
743
+ pinVersion: facts.pinVersion,
744
+ engineVersion: facts.engineVersion,
745
+ contentVersion: facts.deftCorePayloadVersion,
746
+ managedSectionSha: facts.managedSectionSha,
747
+ });
748
+ const notes = [...recon.mismatches];
749
+ if (facts.engineVersion !== null && facts.pinVersion !== null) {
750
+ const skew = evaluateSkew(facts.engineVersion, facts.pinVersion, {});
751
+ if (skew.band === "within-window" || skew.band === "beyond-window") {
752
+ notes.push(skew.message ?? `engine ${facts.engineVersion} ahead of pin ${facts.pinVersion}`);
753
+ }
754
+ }
755
+ // Without a committed pin there is nothing to reconcile against, so never
756
+ // claim "aligned" — that would contradict a Next command that tells the
757
+ // operator to init / commit a pin (Greptile #2283 display-accuracy).
758
+ let rawVerdict;
759
+ if (facts.pinVersion === null) {
760
+ rawVerdict =
761
+ facts.engineVersion === null && facts.deftCorePayloadVersion === null
762
+ ? "nothing to reconcile yet (no engine, pin, or deposited content)"
763
+ : "no committed package.json pin to reconcile against";
764
+ }
765
+ else {
766
+ rawVerdict = notes.length === 0 ? "current (engine/pin/content aligned)" : notes.join("; ");
767
+ }
768
+ // Sanitize newlines so a data-derived version/mismatch string can't break out
769
+ // of the rendered bullet (CWE-116).
770
+ const verdict = rawVerdict.replace(/\r?\n/g, " ");
771
+ return `engine ${engine}, content ${content}, pin ${pin} -- ${verdict}`;
772
+ }
773
+ /**
774
+ * Probe `.deft/.cli/<platform>` across platforms and report presence + skew. A
775
+ * partial install, or a mix of present + absent platforms, is engine skew: a
776
+ * gate can pass on one platform and fail on another. Read-only.
777
+ */
778
+ export function resolvePlatformSkew(projectRoot, seams) {
779
+ const platforms = seams.resolutionPlatforms ?? DEFAULT_RESOLUTION_PLATFORMS;
780
+ const isFile = seams.isFile ?? ((p) => existsSync(p));
781
+ const isDir = seams.isDir ??
782
+ ((p) => {
783
+ try {
784
+ return statSync(p).isDirectory();
785
+ }
786
+ catch {
787
+ return false;
788
+ }
789
+ });
790
+ const states = [];
791
+ let anyPresent = false;
792
+ let anyAbsent = false;
793
+ let anyPartial = false;
794
+ for (const platform of platforms) {
795
+ const result = checkLocalEngineIntegrity(projectRoot, { platform, isFile, isDir });
796
+ if (result.usable) {
797
+ states.push(`${platform} intact`);
798
+ anyPresent = true;
799
+ }
800
+ else if (result.partial) {
801
+ states.push(`${platform} partial (missing ${result.missingMarkers.join(", ")})`);
802
+ anyPresent = true;
803
+ anyPartial = true;
804
+ }
805
+ else {
806
+ states.push(`${platform} absent`);
807
+ anyAbsent = true;
808
+ }
809
+ }
810
+ const skewDetected = anyPartial || (anyPresent && anyAbsent);
811
+ const findings = [];
812
+ if (!anyPresent) {
813
+ return {
814
+ line: "no sandbox-local engines (.deft/.cli/<platform> absent on all probed platforms)",
815
+ skewDetected: false,
816
+ findings,
817
+ };
818
+ }
819
+ let line = `.deft/.cli -> ${states.join("; ")}`;
820
+ if (skewDetected) {
821
+ line += " -- cross-platform engine skew detected";
822
+ findings.push({
823
+ severity: "warning",
824
+ message: `Cross-platform .deft/.cli engine skew: ${states.join("; ")}. A partial or platform-divergent local engine can make gates pass on one platform and fail on another; reconcile with a clean per-platform install.`,
825
+ check: "resolution:platform-skew",
826
+ status: "skew",
827
+ platforms: [...platforms],
828
+ });
829
+ }
830
+ return { line, skewDetected, findings };
831
+ }
832
+ /**
833
+ * The single read-only decision surface (#2267). Reuses the shared keystone
834
+ * `classify()` + `plan()` (the ONE classifier) to emit the operating mode, the
835
+ * engine/pin/VERSION reconciliation, cross-platform skew, and exactly ONE
836
+ * primary `Next command:` with a root-cause + remediation rationale. Secondary
837
+ * migration advice (`plan()` warnings) is suppressed until the primary blocker
838
+ * clears. Mutates nothing.
839
+ */
840
+ export function runResolutionDecision(projectRoot, jsonMode, sink, addFinding, seams) {
841
+ const facts = classify(projectRoot, {
842
+ ...(seams.isFile ? { isFile: seams.isFile } : {}),
843
+ ...(seams.isDir ? { isDir: seams.isDir } : {}),
844
+ ...(seams.readText ? { readText: seams.readText } : {}),
845
+ ...(seams.engineProbe ? { engineProbe: seams.engineProbe } : {}),
846
+ });
847
+ const plan = resolvePlan(facts, {}, { platform: process.platform, interactive: false });
848
+ const operatingMode = resolveOperatingMode(facts);
849
+ const reconciliation = resolveReconciliationLine(facts);
850
+ const skew = resolvePlatformSkew(projectRoot, seams);
851
+ const hasTaskfileWiring = resolveTaskfileWiring(projectRoot, seams);
852
+ const nextCommand = enforceDirectiveSurface(plan.nextAction.command, hasTaskfileWiring);
853
+ const actionRequired = plan.mode !== "proceed";
854
+ // Sanitize newlines on data-derived strings before they land in a rendered
855
+ // bullet (CWE-116); root cause / remediation can carry version substrings.
856
+ const rootCauseLine = plan.nextAction.rootCause.replace(/\r?\n/g, " ");
857
+ const remediationLine = plan.nextAction.remediation.replace(/\r?\n/g, " ");
858
+ if (!jsonMode) {
859
+ sink.raw(`Operating mode: ${operatingMode}`);
860
+ sink.raw(`Version reconciliation: ${reconciliation}`);
861
+ sink.raw(`Cross-platform engine: ${skew.line}`);
862
+ if (actionRequired) {
863
+ sink.blank();
864
+ sink.warn("Primary next action (resolve this first; secondary migration advice is deferred until it clears):");
865
+ sink.raw(` Root cause: ${rootCauseLine}`);
866
+ sink.raw(nextCommand
867
+ ? `Next command: ${nextCommand}`
868
+ : "Next command: (manual -- no single command)");
869
+ sink.raw(` Does / why safe: ${remediationLine}`);
870
+ }
871
+ else {
872
+ // Derive the proceed line from plan() so it never over-claims "aligned"
873
+ // in the no-pin case (Greptile #2283).
874
+ sink.success(`Resolution: proceed -- ${rootCauseLine}.`);
875
+ // Only once the primary blocker has cleared do we surface the ordered
876
+ // secondary notes (legacy vbrief migrate, no-pin advisory, ...).
877
+ for (const warning of plan.warnings) {
878
+ sink.info(`note: ${warning.replace(/\r?\n/g, " ")}`);
879
+ }
880
+ }
881
+ }
882
+ for (const finding of skew.findings) {
883
+ addFinding(finding);
884
+ }
885
+ if (actionRequired) {
886
+ addFinding({
887
+ severity: "warning",
888
+ message: `Next command: ${nextCommand ?? "(manual)"} -- ${rootCauseLine}`,
889
+ check: "resolution",
890
+ status: plan.mode,
891
+ mode: plan.mode,
892
+ next_command: nextCommand,
893
+ root_cause: rootCauseLine,
894
+ remediation: remediationLine,
895
+ operating_mode: operatingMode,
896
+ });
897
+ }
898
+ else {
899
+ addFinding({
900
+ severity: "skip",
901
+ message: "resolution: proceed -- no primary action required",
902
+ check: "resolution",
903
+ status: "proceed",
904
+ });
905
+ }
906
+ return {
907
+ operatingMode,
908
+ reconciliation,
909
+ platformSkew: skew.line,
910
+ platformSkewDetected: skew.skewDetected,
911
+ mode: plan.mode,
912
+ actionRequired,
913
+ nextCommand,
914
+ rootCause: plan.nextAction.rootCause,
915
+ remediation: plan.nextAction.remediation,
916
+ warnings: plan.warnings,
917
+ };
918
+ }
516
919
  //# sourceMappingURL=main.js.map
@@ -2,6 +2,26 @@ import { readTextSafe } from "./paths.js";
2
2
  export declare function parseManifest(text: string): Record<string, string>;
3
3
  export declare function parseInstallManifest(text: string): Record<string, string>;
4
4
  export declare function manifestTagToVersion(manifest: Record<string, string>): string | null;
5
+ /** Reportability classes for an install manifest's version provenance (#2294). */
6
+ export type ManifestVersionSource = "tag" | "ref" | "sha" | "none";
7
+ export interface ReportableVersion {
8
+ /** Semver derived from `tag`/`ref` (leading `v` stripped), else null. */
9
+ readonly version: string | null;
10
+ /** Recorded commit `sha`, trimmed, else null. */
11
+ readonly sha: string | null;
12
+ /** Where a reportable identity was found. */
13
+ readonly source: ManifestVersionSource;
14
+ }
15
+ /**
16
+ * Classify what a manifest can report as its version (#2294). A legacy
17
+ * `deft-install` deposit made without a release pin writes empty `tag`/`ref`
18
+ * and only a short `sha`; `manifestTagToVersion` then returns null and the
19
+ * version is silently unreportable. This helper distinguishes a pinned
20
+ * semver (`tag`/`ref`) from a sha-only deposit from a manifest with no
21
+ * provenance at all, so callers can surface an actionable signal instead of a
22
+ * blank.
23
+ */
24
+ export declare function manifestReportableVersion(manifest: Record<string, string>): ReportableVersion;
5
25
  export declare function manifestCandidatePaths(projectRoot: string, installRoot: string | null): string[];
6
26
  export declare function locateManifest(projectRoot: string, installRoot: string | null, isFile?: (p: string) => boolean): string | null;
7
27
  export declare function parseInstallRootFromAgentsMd(text: string): string | null;
@@ -43,6 +43,28 @@ export function manifestTagToVersion(manifest) {
43
43
  }
44
44
  return null;
45
45
  }
46
+ /**
47
+ * Classify what a manifest can report as its version (#2294). A legacy
48
+ * `deft-install` deposit made without a release pin writes empty `tag`/`ref`
49
+ * and only a short `sha`; `manifestTagToVersion` then returns null and the
50
+ * version is silently unreportable. This helper distinguishes a pinned
51
+ * semver (`tag`/`ref`) from a sha-only deposit from a manifest with no
52
+ * provenance at all, so callers can surface an actionable signal instead of a
53
+ * blank.
54
+ */
55
+ export function manifestReportableVersion(manifest) {
56
+ const version = manifestTagToVersion(manifest);
57
+ if (version !== null) {
58
+ const tag = typeof manifest.tag === "string" ? manifest.tag.trim() : "";
59
+ return { version, sha: readSha(manifest), source: tag ? "tag" : "ref" };
60
+ }
61
+ const sha = readSha(manifest);
62
+ return { version: null, sha, source: sha !== null ? "sha" : "none" };
63
+ }
64
+ function readSha(manifest) {
65
+ const raw = typeof manifest.sha === "string" ? manifest.sha.trim() : "";
66
+ return raw || null;
67
+ }
46
68
  export function manifestCandidatePaths(projectRoot, installRoot) {
47
69
  const raw = [];
48
70
  if (installRoot) {
@@ -1,3 +1,4 @@
1
+ import type { PackageManager } from "../resolution/package-manager.js";
1
2
  import type { OutputSink } from "./output.js";
2
3
  import type { Finding } from "./types.js";
3
4
  export interface PayloadStalenessSeams {
@@ -12,6 +13,13 @@ export interface PayloadStalenessSeams {
12
13
  ok: boolean;
13
14
  version: string;
14
15
  };
16
+ /**
17
+ * Active package manager for rendering the upgrade recommendation (#2197).
18
+ * Defaults to npm so existing behaviour/tests are unchanged; the doctor entry
19
+ * detects the manager and threads it here so a pnpm consumer sees the pnpm
20
+ * upgrade one-liner (`pnpm add -g @deftai/directive@latest`).
21
+ */
22
+ readonly packageManager?: PackageManager;
15
23
  }
16
24
  export declare function runPayloadStalenessCheck(projectRoot: string, sink: OutputSink, addFinding: (finding: Finding) => void, seams?: PayloadStalenessSeams): void;
17
25
  //# sourceMappingURL=payload-staleness.d.ts.map
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { dirname, join } from "node:path";
3
3
  import { detectCanonicalVendoredManifest, isNpmManaged } from "../init-deposit/migrate.js";
4
- import { CANONICAL_UPGRADE_COMMAND, NPM_PACKAGE_NAME, VENDORED_NPM_DEPOSIT_UPGRADE_COMMAND, } from "./constants.js";
4
+ import { CANONICAL_UPGRADE_COMMAND, NPM_PACKAGE_NAME, upgradeCommandFor, VENDORED_NPM_DEPOSIT_UPGRADE_COMMAND, } from "./constants.js";
5
5
  import { locateManifest, parseInstallManifest } from "./manifest.js";
6
6
  import { readTextSafe, resolveDefaultFrameworkRoot } from "./paths.js";
7
7
  function isDeftFrameworkRepo(projectRoot, readText = readTextSafe) {
@@ -92,12 +92,14 @@ function emitUnverified(checkName, reason, sink, addFinding) {
92
92
  status: "unverified",
93
93
  });
94
94
  }
95
- function resolveUpgradeCommand(projectRoot, manifest, isFile) {
96
- const vendoredManifest = detectCanonicalVendoredManifest(projectRoot, isFile);
97
- if (vendoredManifest !== null && isNpmManaged(manifest)) {
98
- return VENDORED_NPM_DEPOSIT_UPGRADE_COMMAND;
99
- }
100
- return CANONICAL_UPGRADE_COMMAND;
95
+ function resolveUpgradeCommand(projectRoot, manifest, isFile, pm = "npm") {
96
+ const vendored = detectCanonicalVendoredManifest(projectRoot, isFile) !== null && isNpmManaged(manifest);
97
+ if (pm === "pnpm") {
98
+ // Same registry, same tarball -- only the command form differs (#2197).
99
+ const upgrade = upgradeCommandFor("pnpm");
100
+ return vendored ? `${upgrade} && deft update` : upgrade;
101
+ }
102
+ return vendored ? VENDORED_NPM_DEPOSIT_UPGRADE_COMMAND : CANONICAL_UPGRADE_COMMAND;
101
103
  }
102
104
  function emitStale(checkName, installedLabel, remoteLabel, ref, upgradeCommand, sink, addFinding, extras = {}, behindWord = "remote") {
103
105
  const msg = `Framework payload is stale (installed ${installedLabel} behind ${behindWord} ${remoteLabel} for ref '${ref}'). ` +
@@ -163,7 +165,8 @@ export function runPayloadStalenessCheck(projectRoot, sink, addFinding, seams =
163
165
  return;
164
166
  }
165
167
  const manifest = parseInstallManifest(text);
166
- const upgradeCommand = resolveUpgradeCommand(projectRoot, manifest, isFile);
168
+ const activePm = seams.packageManager ?? "npm";
169
+ const upgradeCommand = resolveUpgradeCommand(projectRoot, manifest, isFile, activePm);
167
170
  const installedSha = (manifest.sha ?? "").trim();
168
171
  const ref = (manifest.ref ?? manifest.tag ?? "").trim();
169
172
  const tag = (manifest.tag ?? "").trim();
@@ -1,4 +1,8 @@
1
1
  import type { AdvisoryEvaluateResult } from "../agents-md-advisory/evaluate.js";
2
+ import type { ShadowedPlanExtension } from "../policy/plan-extensions.js";
3
+ import type { EngineProbeResult } from "../resolution/classify.js";
4
+ import type { ResolutionMode } from "../resolution/index.js";
5
+ import type { ResolveUserMdResult } from "../user-config/resolve-user-md.js";
2
6
  export declare const EXIT_CLEAN = 0;
3
7
  export declare const EXIT_DRIFT = 1;
4
8
  export declare const EXIT_CONFIG_ERROR = 2;
@@ -25,6 +29,34 @@ export interface Finding {
25
29
  readonly status?: string;
26
30
  readonly [key: string]: unknown;
27
31
  }
32
+ /**
33
+ * The single read-only decision surface derived from the shared keystone
34
+ * `plan()` (#2267 / epic #2203). `plan()` is the ONE classifier: doctor never
35
+ * re-derives the mode here, it only renders the mode + the single ordered next
36
+ * action plus the operating-mode / reconciliation / cross-platform-skew context.
37
+ */
38
+ export interface ResolutionSummary {
39
+ /** Human-facing operating mode (hybrid / vendored / greenfield / ...). */
40
+ readonly operatingMode: string;
41
+ /** engine/pin/VERSION reconciliation verdict line. */
42
+ readonly reconciliation: string;
43
+ /** Cross-platform `.deft/.cli/<platform>` engine presence + skew line. */
44
+ readonly platformSkew: string;
45
+ /** True when the platform installs diverge (present/partial/absent mix). */
46
+ readonly platformSkewDetected: boolean;
47
+ /** Resolved mode from `plan()` (the single classifier). */
48
+ readonly mode: ResolutionMode;
49
+ /** True when the resolved mode requires operator action (mode !== "proceed"). */
50
+ readonly actionRequired: boolean;
51
+ /** The ONE primary next command, directive-surfaced; null for manual actions. */
52
+ readonly nextCommand: string | null;
53
+ /** Why the primary action is recommended. */
54
+ readonly rootCause: string;
55
+ /** What the remediation does + why it is safe. */
56
+ readonly remediation: string;
57
+ /** Ordered secondary warnings from `plan()` (informational, not directives). */
58
+ readonly warnings: readonly string[];
59
+ }
28
60
  export interface DoctorFlags {
29
61
  readonly session: boolean;
30
62
  readonly fix: boolean;
@@ -79,5 +111,29 @@ export interface DoctorSeams {
79
111
  readonly readYn?: (prompt: string, defaultYes: boolean) => boolean;
80
112
  readonly writeText?: (path: string, content: string) => void;
81
113
  readonly now?: () => Date;
114
+ /**
115
+ * Engine-reachability probe threaded into the shared `classify()`. Injected so
116
+ * the resolution decision stays deterministic + offline in tests; the default
117
+ * shells out to `directive --version` / `deft --version`.
118
+ */
119
+ readonly engineProbe?: () => EngineProbeResult;
120
+ /**
121
+ * Platform ids probed for cross-platform `.deft/.cli/<platform>` engine skew.
122
+ * Defaults to `["linux", "darwin", "win32"]`.
123
+ */
124
+ readonly resolutionPlatforms?: readonly string[];
125
+ /**
126
+ * USER.md resolver seam (#2271). Injected so the doctor USER.md-resolution
127
+ * surface stays deterministic + offline in tests. Defaults to the shared
128
+ * first-hit-wins resolver scoped to the project root.
129
+ */
130
+ readonly resolveUserMd?: (projectRoot: string) => ResolveUserMdResult;
131
+ /**
132
+ * Plan-extension shadow detector seam (#2301). Injected so the doctor
133
+ * shadow-diagnostic surface stays deterministic + offline in tests. Defaults
134
+ * to loading PROJECT-DEFINITION and running `detectShadowedPlanExtensions` on
135
+ * its `plan` object; returns [] when no project definition is present.
136
+ */
137
+ readonly detectPlanExtensionShadows?: (projectRoot: string) => readonly ShadowedPlanExtension[];
82
138
  }
83
139
  //# sourceMappingURL=types.d.ts.map
package/dist/index.d.ts CHANGED
@@ -35,6 +35,7 @@ export * as releaseE2e from "./release-e2e/index.js";
35
35
  export * as releasePublish from "./release-publish/index.js";
36
36
  export * as releaseRollback from "./release-rollback/index.js";
37
37
  export * as render from "./render/index.js";
38
+ export * as resolution from "./resolution/index.js";
38
39
  export * as scm from "./scm/index.js";
39
40
  export * as scope from "./scope/index.js";
40
41
  export * as session from "./session/index.js";
@@ -42,6 +43,7 @@ export * as slice from "./slice/index.js";
42
43
  export * as storyReady from "./story-ready/index.js";
43
44
  export * as swarm from "./swarm/index.js";
44
45
  export * as triage from "./triage/index.js";
46
+ export * as userConfig from "./user-config/index.js";
45
47
  export * as validateContent from "./validate-content/index.js";
46
48
  export * as vbriefActivate from "./vbrief-activate/index.js";
47
49
  export * as vbriefBuild from "./vbrief-build/index.js";