@deftai/directive-core 0.68.0 → 0.69.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 (59) hide show
  1. package/dist/doctor/constants.d.ts +9 -0
  2. package/dist/doctor/constants.js +11 -0
  3. package/dist/doctor/main.d.ts +47 -1
  4. package/dist/doctor/main.js +352 -2
  5. package/dist/doctor/payload-staleness.d.ts +8 -0
  6. package/dist/doctor/payload-staleness.js +11 -8
  7. package/dist/doctor/types.d.ts +48 -0
  8. package/dist/index.d.ts +2 -0
  9. package/dist/index.js +2 -0
  10. package/dist/init-deposit/gitignore.d.ts +18 -0
  11. package/dist/init-deposit/gitignore.js +94 -15
  12. package/dist/init-deposit/headless-manifest.d.ts +87 -0
  13. package/dist/init-deposit/headless-manifest.js +261 -0
  14. package/dist/init-deposit/hygiene.js +11 -0
  15. package/dist/init-deposit/index.d.ts +3 -0
  16. package/dist/init-deposit/index.js +3 -0
  17. package/dist/init-deposit/init-dispatch.d.ts +90 -0
  18. package/dist/init-deposit/init-dispatch.js +170 -0
  19. package/dist/init-deposit/refresh.d.ts +79 -2
  20. package/dist/init-deposit/refresh.js +177 -5
  21. package/dist/init-deposit/scaffold.d.ts +25 -0
  22. package/dist/init-deposit/scaffold.js +57 -0
  23. package/dist/init-deposit/untrack-core.d.ts +84 -0
  24. package/dist/init-deposit/untrack-core.js +150 -0
  25. package/dist/resolution/classify.d.ts +41 -0
  26. package/dist/resolution/classify.js +138 -0
  27. package/dist/resolution/engine-ladder.d.ts +98 -0
  28. package/dist/resolution/engine-ladder.js +185 -0
  29. package/dist/resolution/index.d.ts +19 -0
  30. package/dist/resolution/index.js +19 -0
  31. package/dist/resolution/integrity.d.ts +48 -0
  32. package/dist/resolution/integrity.js +80 -0
  33. package/dist/resolution/package-manager.d.ts +77 -0
  34. package/dist/resolution/package-manager.js +103 -0
  35. package/dist/resolution/pin.d.ts +73 -0
  36. package/dist/resolution/pin.js +169 -0
  37. package/dist/resolution/plan.d.ts +66 -0
  38. package/dist/resolution/plan.js +219 -0
  39. package/dist/resolution/skew-policy.d.ts +46 -0
  40. package/dist/resolution/skew-policy.js +120 -0
  41. package/dist/session/session-start.d.ts +2 -0
  42. package/dist/session/session-start.js +28 -1
  43. package/dist/user-config/index.d.ts +2 -0
  44. package/dist/user-config/index.js +2 -0
  45. package/dist/user-config/resolve-user-md.d.ts +76 -0
  46. package/dist/user-config/resolve-user-md.js +120 -0
  47. package/dist/xbrief-migrate/constants.d.ts +10 -0
  48. package/dist/xbrief-migrate/constants.js +19 -0
  49. package/dist/xbrief-migrate/detect.d.ts +34 -0
  50. package/dist/xbrief-migrate/detect.js +33 -0
  51. package/dist/xbrief-migrate/fs-helpers.d.ts +13 -0
  52. package/dist/xbrief-migrate/fs-helpers.js +46 -1
  53. package/dist/xbrief-migrate/index.d.ts +4 -2
  54. package/dist/xbrief-migrate/index.js +4 -2
  55. package/dist/xbrief-migrate/migrate-project.d.ts +23 -1
  56. package/dist/xbrief-migrate/migrate-project.js +97 -8
  57. package/dist/xbrief-migrate/signpost.d.ts +7 -1
  58. package/dist/xbrief-migrate/signpost.js +18 -3
  59. package/package.json +7 -3
@@ -1,3 +1,4 @@
1
+ import { type PackageManager } from "../resolution/package-manager.js";
1
2
  export declare const UV_INSTALL_URL = "https://docs.astral.sh/uv/";
2
3
  export declare const UPGRADING_DOC_URL = "https://github.com/deftai/directive/blob/master/content/UPGRADING.md";
3
4
  export declare const GO_BRIDGE_RELEASES_URL = "https://github.com/deftai/directive/releases";
@@ -18,6 +19,14 @@ export declare const DEFT_REPO_POSITIVE_MARKERS: readonly ["content/templates/ag
18
19
  export declare const EXPECTED_CONTENT_DIRS: readonly ["languages", "strategies", "skills", "templates"];
19
20
  /** Post-freeze canonical upgrade path (#1997 / #2003 / #1912). */
20
21
  export declare const CANONICAL_UPGRADE_COMMAND = "npm i -g @deftai/directive@latest";
22
+ /**
23
+ * Render the canonical upgrade one-liner for the active package manager (#2197).
24
+ * Defaults to npm (`CANONICAL_UPGRADE_COMMAND`) so existing callers are
25
+ * unchanged; pass `pnpm` to emit the pnpm form
26
+ * (`pnpm add -g @deftai/directive@latest`). Consumed by the doctor
27
+ * payload-staleness recommendation (`payload-staleness.ts`).
28
+ */
29
+ export declare function upgradeCommandFor(pm?: PackageManager): string;
21
30
  /** Vendored npm-managed deposit: global bump plus in-place `.deft/core/` refresh (#2115). */
22
31
  export declare const VENDORED_NPM_DEPOSIT_UPGRADE_COMMAND = "npm i -g @deftai/directive@latest && deft update";
23
32
  export declare const CLEAN_WINDOW_HOURS = 24;
@@ -1,3 +1,4 @@
1
+ import { ENGINE_PACKAGE, renderGlobalInstall, } from "../resolution/package-manager.js";
1
2
  export const UV_INSTALL_URL = "https://docs.astral.sh/uv/";
2
3
  // Stable, version-neutral upgrade signposts (#1912). Core principle: never bake
3
4
  // the upgrade command/version into the artifact being upgraded -- bake in a
@@ -60,6 +61,16 @@ export const DEFT_REPO_POSITIVE_MARKERS = [
60
61
  export const EXPECTED_CONTENT_DIRS = ["languages", "strategies", "skills", "templates"];
61
62
  /** Post-freeze canonical upgrade path (#1997 / #2003 / #1912). */
62
63
  export const CANONICAL_UPGRADE_COMMAND = "npm i -g @deftai/directive@latest";
64
+ /**
65
+ * Render the canonical upgrade one-liner for the active package manager (#2197).
66
+ * Defaults to npm (`CANONICAL_UPGRADE_COMMAND`) so existing callers are
67
+ * unchanged; pass `pnpm` to emit the pnpm form
68
+ * (`pnpm add -g @deftai/directive@latest`). Consumed by the doctor
69
+ * payload-staleness recommendation (`payload-staleness.ts`).
70
+ */
71
+ export function upgradeCommandFor(pm = "npm") {
72
+ return renderGlobalInstall(pm, `${ENGINE_PACKAGE}@latest`);
73
+ }
63
74
  /** Vendored npm-managed deposit: global bump plus in-place `.deft/core/` refresh (#2115). */
64
75
  export const VENDORED_NPM_DEPOSIT_UPGRADE_COMMAND = `${CANONICAL_UPGRADE_COMMAND} && deft update`;
65
76
  export const CLEAN_WINDOW_HOURS = 24;
@@ -1,3 +1,49 @@
1
- import type { DoctorSeams } from "./types.js";
1
+ import { type ResolutionFacts } from "../resolution/index.js";
2
+ import { createPlainSink } from "./output.js";
3
+ import type { DoctorSeams, Finding, ResolutionSummary } from "./types.js";
2
4
  export declare function cmdDoctor(args: readonly string[], seams?: DoctorSeams): number;
5
+ /**
6
+ * Never emit a bare `task ...` remediation in a project without Taskfile wiring
7
+ * (#2267). The `directive` surface always works; `task deft:X` is optional and
8
+ * only present when the consumer wired the include. `plan()` already emits the
9
+ * `directive` / `npx` / `npm` surface, so this guard is a defensive invariant:
10
+ * any `task`-prefixed command is rewritten to the `directive` surface unless the
11
+ * project actually has the include.
12
+ */
13
+ /**
14
+ * Detect Taskfile wiring through the injected seam (#2267). Mirrors
15
+ * `classifyTaskfileInclude` but routes the filesystem read through
16
+ * `seams.readText` so the resolution decision stays deterministic + injectable
17
+ * in tests -- unlike a direct `classifyTaskfileInclude(projectRoot)` call, which
18
+ * bypasses every other seam-flowed probe in `runResolutionDecision`.
19
+ */
20
+ export declare function resolveTaskfileWiring(projectRoot: string, seams: DoctorSeams): boolean;
21
+ export declare function enforceDirectiveSurface(command: string | null, hasTaskfileWiring: boolean): string | null;
22
+ /** Human-facing operating mode derived from the orthogonal fact-set. */
23
+ export declare function resolveOperatingMode(facts: ResolutionFacts): string;
24
+ /**
25
+ * Single engine/pin/VERSION reconciliation line. `reconcileVersions` flags
26
+ * content-behind/ahead and engine-behind; engine-ahead skew is folded in here so
27
+ * the line is the one version-state summary the operator reads.
28
+ */
29
+ export declare function resolveReconciliationLine(facts: ResolutionFacts): string;
30
+ /**
31
+ * Probe `.deft/.cli/<platform>` across platforms and report presence + skew. A
32
+ * partial install, or a mix of present + absent platforms, is engine skew: a
33
+ * gate can pass on one platform and fail on another. Read-only.
34
+ */
35
+ export declare function resolvePlatformSkew(projectRoot: string, seams: DoctorSeams): {
36
+ line: string;
37
+ skewDetected: boolean;
38
+ findings: Finding[];
39
+ };
40
+ /**
41
+ * The single read-only decision surface (#2267). Reuses the shared keystone
42
+ * `classify()` + `plan()` (the ONE classifier) to emit the operating mode, the
43
+ * engine/pin/VERSION reconciliation, cross-platform skew, and exactly ONE
44
+ * primary `Next command:` with a root-cause + remediation rationale. Secondary
45
+ * migration advice (`plan()` warnings) is suppressed until the primary blocker
46
+ * clears. Mutates nothing.
47
+ */
48
+ export declare function runResolutionDecision(projectRoot: string, jsonMode: boolean, sink: ReturnType<typeof createPlainSink>, addFinding: (f: Finding) => void, seams: DoctorSeams): ResolutionSummary;
3
49
  //# sourceMappingURL=main.d.ts.map
@@ -2,6 +2,8 @@ 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 { checkLocalEngineIntegrity, classify, detectPackageManager, evaluateSkew, reconcileVersions, plan as resolvePlan, } from "../resolution/index.js";
6
+ import { resolveUserMdPath } from "../user-config/resolve-user-md.js";
5
7
  import { agentsRefreshPlan, hasV3ManagedMarker } from "./agents-md.js";
6
8
  import { runChecks } from "./checks.js";
7
9
  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 +12,33 @@ import { formatAllowedFlagsHint, formatUnknownFlagsError, parseDoctorFlags } fro
10
12
  import { pythonJsonDump } from "./json.js";
11
13
  import { parseInstallRootFromAgentsMd } from "./manifest.js";
12
14
  import { createPlainSink } from "./output.js";
13
- import { resolveDefaultFrameworkRoot, resolvePath, resolveVersion, runningInsideDeftRepo, } from "./paths.js";
15
+ import { readTextSafe, resolveDefaultFrameworkRoot, resolvePath, resolveVersion, runningInsideDeftRepo, } from "./paths.js";
14
16
  import { runPayloadStalenessCheck } from "./payload-staleness.js";
15
17
  import { runLocalSignpostChecks } from "./signpost-checks.js";
16
- import { classifyTaskfileInclude, formatMissingIncludeSnippet, resolveConsumerTaskfile, } from "./taskfile.js";
18
+ import { classifyTaskfileInclude, formatMissingIncludeSnippet, includesBlockHasDeftTaskfile, resolveConsumerTaskfile, } from "./taskfile.js";
17
19
  import { defaultWhich } from "./which.js";
20
+ const DEFAULT_RESOLUTION_PLATFORMS = ["linux", "darwin", "win32"];
21
+ /**
22
+ * Read the `packageManager` field (Corepack) from a project package.json, or
23
+ * null when absent/unreadable. Lets the doctor detect a pnpm project that has
24
+ * no `pnpm-lock.yaml` yet (fresh Corepack clone) for the #2197 upgrade hint.
25
+ */
26
+ function readPackageManagerField(packageJsonPath, readTextFn) {
27
+ const raw = readTextFn(packageJsonPath);
28
+ if (raw === null)
29
+ return null;
30
+ try {
31
+ const parsed = JSON.parse(raw);
32
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
33
+ const field = parsed.packageManager;
34
+ return typeof field === "string" ? field : null;
35
+ }
36
+ }
37
+ catch {
38
+ // Malformed package.json -- fall through to null (npm default).
39
+ }
40
+ return null;
41
+ }
18
42
  export function cmdDoctor(args, seams = {}) {
19
43
  const flags = parseDoctorFlags(args);
20
44
  if (flags.unknown.length > 0) {
@@ -145,12 +169,27 @@ export function cmdDoctor(args, seams = {}) {
145
169
  if (flags.network) {
146
170
  sink.info(NETWORK_DISCLOSURE_LINE);
147
171
  sink.info("Checking payload staleness from install manifest...");
172
+ // Detect the package manager for the upgrade recommendation from signals
173
+ // that are meaningful for a globally-installed CLI: the explicit
174
+ // DEFT_PACKAGE_MANAGER override, the project's `packageManager` field
175
+ // (Corepack), and the project's pnpm-lock.yaml. We deliberately do NOT
176
+ // consult the ambient npm_config_user_agent here (it is set by whatever
177
+ // shell/script spawned the process, not by the consumer's project) so the
178
+ // recommendation is a stable property of the project (#2197).
179
+ const readTextForPm = seams.readText ?? readTextSafe;
180
+ const isFileForPm = seams.isFile ?? ((p) => existsSync(p));
181
+ const packageManager = detectPackageManager({
182
+ env: { DEFT_PACKAGE_MANAGER: process.env.DEFT_PACKAGE_MANAGER },
183
+ packageManagerField: readPackageManagerField(join(projectRoot, "package.json"), readTextForPm),
184
+ pnpmLockPresent: isFileForPm(join(projectRoot, "pnpm-lock.yaml")),
185
+ });
148
186
  runPayloadStalenessCheck(projectRoot, sink, addFinding, {
149
187
  frameworkRoot,
150
188
  readText: seams.readText,
151
189
  isFile: seams.isFile,
152
190
  runGitLsRemote: seams.runGitLsRemote,
153
191
  runNpmViewVersion: seams.runNpmViewVersion,
192
+ packageManager,
154
193
  });
155
194
  }
156
195
  else {
@@ -213,8 +252,21 @@ export function cmdDoctor(args, seams = {}) {
213
252
  if (!jsonMode) {
214
253
  sink.blank();
215
254
  }
255
+ sink.info("Checking USER.md resolution...");
256
+ const userMd = runUserMdResolutionCheck(projectRoot, sink, addFinding, seams);
257
+ if (!jsonMode) {
258
+ sink.blank();
259
+ }
216
260
  sink.info("Checking optional root Taskfile.yml include...");
217
261
  runTaskfileIncludeCheck(projectRoot, fixMode, jsonMode, sink, addFinding, seams);
262
+ let resolution = null;
263
+ if (!runningInsideDeftRepo(projectRoot, seams)) {
264
+ if (!jsonMode) {
265
+ sink.blank();
266
+ sink.info("Resolution -- operating mode + single next action (from shared plan())...");
267
+ }
268
+ resolution = runResolutionDecision(projectRoot, jsonMode, sink, addFinding, seams);
269
+ }
218
270
  const errorCount = findings.filter((f) => f.severity === "error").length;
219
271
  const warningCount = findings.filter((f) => f.severity === "warning").length;
220
272
  const exitCode = errorCount > 0 ? 1 : 0;
@@ -232,6 +284,28 @@ export function cmdDoctor(args, seams = {}) {
232
284
  findings,
233
285
  summary: { errors: errorCount, warnings: warningCount },
234
286
  project_root: projectRoot,
287
+ user_md: {
288
+ path: userMd.path,
289
+ rung: userMd.rung,
290
+ found: userMd.found,
291
+ diagnostic: userMd.diagnostic,
292
+ },
293
+ ...(resolution
294
+ ? {
295
+ resolution: {
296
+ mode: resolution.mode,
297
+ operating_mode: resolution.operatingMode,
298
+ reconciliation: resolution.reconciliation,
299
+ platform_skew: resolution.platformSkew,
300
+ platform_skew_detected: resolution.platformSkewDetected,
301
+ action_required: resolution.actionRequired,
302
+ next_command: resolution.nextCommand,
303
+ root_cause: resolution.rootCause,
304
+ remediation: resolution.remediation,
305
+ warnings: resolution.warnings,
306
+ },
307
+ }
308
+ : {}),
235
309
  };
236
310
  process.stdout.write(`${pythonJsonDump(payload)}\n`);
237
311
  return exitCode;
@@ -513,4 +587,280 @@ function runTaskfileIncludeCheck(projectRoot, fixMode, jsonMode, sink, addFindin
513
587
  file: taskfilePath,
514
588
  });
515
589
  }
590
+ /**
591
+ * Surface the resolved USER.md path + which search rung matched (#2271) so the
592
+ * resolution boundary is visible in doctor output. Uses the shared first-hit-
593
+ * wins resolver, which never throws: an absent USER.md degrades to a `no
594
+ * USER.md found; using defaults` diagnostic. This check never fails the doctor
595
+ * — it emits a `skip`-severity finding (informational) in both states.
596
+ */
597
+ function runUserMdResolutionCheck(projectRoot, sink, addFinding, seams) {
598
+ const resolveFn = seams.resolveUserMd ?? ((root) => resolveUserMdPath({ projectRoot: root }));
599
+ const result = resolveFn(projectRoot);
600
+ // Sanitize newlines on the data-derived path/diagnostic before it lands in a
601
+ // rendered bullet (CWE-116).
602
+ const safePath = result.path.replace(/\r?\n/g, " ");
603
+ const safeDiagnostic = result.diagnostic.replace(/\r?\n/g, " ");
604
+ if (result.found) {
605
+ sink.success(`USER.md resolved (${result.rung}): ${safePath}`);
606
+ }
607
+ else {
608
+ sink.info(`USER.md: ${safeDiagnostic}`);
609
+ }
610
+ addFinding({
611
+ severity: "skip",
612
+ message: result.found ? `USER.md resolved (${result.rung}): ${safePath}` : safeDiagnostic,
613
+ check: "user-md-resolution",
614
+ status: result.found ? "resolved" : "defaulted",
615
+ path: safePath,
616
+ rung: result.rung,
617
+ found: result.found,
618
+ });
619
+ return result;
620
+ }
621
+ /**
622
+ * Never emit a bare `task ...` remediation in a project without Taskfile wiring
623
+ * (#2267). The `directive` surface always works; `task deft:X` is optional and
624
+ * only present when the consumer wired the include. `plan()` already emits the
625
+ * `directive` / `npx` / `npm` surface, so this guard is a defensive invariant:
626
+ * any `task`-prefixed command is rewritten to the `directive` surface unless the
627
+ * project actually has the include.
628
+ */
629
+ /**
630
+ * Detect Taskfile wiring through the injected seam (#2267). Mirrors
631
+ * `classifyTaskfileInclude` but routes the filesystem read through
632
+ * `seams.readText` so the resolution decision stays deterministic + injectable
633
+ * in tests -- unlike a direct `classifyTaskfileInclude(projectRoot)` call, which
634
+ * bypasses every other seam-flowed probe in `runResolutionDecision`.
635
+ */
636
+ export function resolveTaskfileWiring(projectRoot, seams) {
637
+ const readText = seams.readText ??
638
+ ((path) => {
639
+ try {
640
+ return readFileSync(path, "utf8");
641
+ }
642
+ catch {
643
+ return null;
644
+ }
645
+ });
646
+ for (const name of ["Taskfile.yml", "Taskfile.yaml"]) {
647
+ const text = readText(join(projectRoot, name));
648
+ if (text !== null) {
649
+ return includesBlockHasDeftTaskfile(text.replace(/^\uFEFF/, ""));
650
+ }
651
+ }
652
+ return false;
653
+ }
654
+ export function enforceDirectiveSurface(command, hasTaskfileWiring) {
655
+ if (command === null) {
656
+ return null;
657
+ }
658
+ if (!hasTaskfileWiring && /^\s*task\s+/.test(command)) {
659
+ return command.replace(/^\s*task\s+/, "directive ");
660
+ }
661
+ return command;
662
+ }
663
+ /** Human-facing operating mode derived from the orthogonal fact-set. */
664
+ export function resolveOperatingMode(facts) {
665
+ if (facts.preCutoverArtifacts) {
666
+ return "pre-cutover (pre-v0.20 document model -- migrate first)";
667
+ }
668
+ if (!facts.hasDeftCore) {
669
+ if (facts.hasManagedSection) {
670
+ return "hybrid (managed AGENTS.md present; .deft/core/ payload not reconstituted)";
671
+ }
672
+ return facts.hasAppCode || facts.hasGit
673
+ ? "brownfield (existing project, no Deft deposit)"
674
+ : "greenfield (no Deft deposit)";
675
+ }
676
+ return facts.hasManagedSection
677
+ ? "hybrid (vendored .deft/core/ + managed AGENTS.md)"
678
+ : "vendored (.deft/core/ deposit, no managed AGENTS.md section)";
679
+ }
680
+ /**
681
+ * Single engine/pin/VERSION reconciliation line. `reconcileVersions` flags
682
+ * content-behind/ahead and engine-behind; engine-ahead skew is folded in here so
683
+ * the line is the one version-state summary the operator reads.
684
+ */
685
+ export function resolveReconciliationLine(facts) {
686
+ const engine = facts.engineVersion ?? "unreachable";
687
+ const content = facts.deftCorePayloadVersion ?? "none";
688
+ const pin = facts.pinVersion ?? "none";
689
+ const recon = reconcileVersions({
690
+ pinVersion: facts.pinVersion,
691
+ engineVersion: facts.engineVersion,
692
+ contentVersion: facts.deftCorePayloadVersion,
693
+ managedSectionSha: facts.managedSectionSha,
694
+ });
695
+ const notes = [...recon.mismatches];
696
+ if (facts.engineVersion !== null && facts.pinVersion !== null) {
697
+ const skew = evaluateSkew(facts.engineVersion, facts.pinVersion, {});
698
+ if (skew.band === "within-window" || skew.band === "beyond-window") {
699
+ notes.push(skew.message ?? `engine ${facts.engineVersion} ahead of pin ${facts.pinVersion}`);
700
+ }
701
+ }
702
+ // Without a committed pin there is nothing to reconcile against, so never
703
+ // claim "aligned" — that would contradict a Next command that tells the
704
+ // operator to init / commit a pin (Greptile #2283 display-accuracy).
705
+ let rawVerdict;
706
+ if (facts.pinVersion === null) {
707
+ rawVerdict =
708
+ facts.engineVersion === null && facts.deftCorePayloadVersion === null
709
+ ? "nothing to reconcile yet (no engine, pin, or deposited content)"
710
+ : "no committed package.json pin to reconcile against";
711
+ }
712
+ else {
713
+ rawVerdict = notes.length === 0 ? "current (engine/pin/content aligned)" : notes.join("; ");
714
+ }
715
+ // Sanitize newlines so a data-derived version/mismatch string can't break out
716
+ // of the rendered bullet (CWE-116).
717
+ const verdict = rawVerdict.replace(/\r?\n/g, " ");
718
+ return `engine ${engine}, content ${content}, pin ${pin} -- ${verdict}`;
719
+ }
720
+ /**
721
+ * Probe `.deft/.cli/<platform>` across platforms and report presence + skew. A
722
+ * partial install, or a mix of present + absent platforms, is engine skew: a
723
+ * gate can pass on one platform and fail on another. Read-only.
724
+ */
725
+ export function resolvePlatformSkew(projectRoot, seams) {
726
+ const platforms = seams.resolutionPlatforms ?? DEFAULT_RESOLUTION_PLATFORMS;
727
+ const isFile = seams.isFile ?? ((p) => existsSync(p));
728
+ const isDir = seams.isDir ??
729
+ ((p) => {
730
+ try {
731
+ return statSync(p).isDirectory();
732
+ }
733
+ catch {
734
+ return false;
735
+ }
736
+ });
737
+ const states = [];
738
+ let anyPresent = false;
739
+ let anyAbsent = false;
740
+ let anyPartial = false;
741
+ for (const platform of platforms) {
742
+ const result = checkLocalEngineIntegrity(projectRoot, { platform, isFile, isDir });
743
+ if (result.usable) {
744
+ states.push(`${platform} intact`);
745
+ anyPresent = true;
746
+ }
747
+ else if (result.partial) {
748
+ states.push(`${platform} partial (missing ${result.missingMarkers.join(", ")})`);
749
+ anyPresent = true;
750
+ anyPartial = true;
751
+ }
752
+ else {
753
+ states.push(`${platform} absent`);
754
+ anyAbsent = true;
755
+ }
756
+ }
757
+ const skewDetected = anyPartial || (anyPresent && anyAbsent);
758
+ const findings = [];
759
+ if (!anyPresent) {
760
+ return {
761
+ line: "no sandbox-local engines (.deft/.cli/<platform> absent on all probed platforms)",
762
+ skewDetected: false,
763
+ findings,
764
+ };
765
+ }
766
+ let line = `.deft/.cli -> ${states.join("; ")}`;
767
+ if (skewDetected) {
768
+ line += " -- cross-platform engine skew detected";
769
+ findings.push({
770
+ severity: "warning",
771
+ 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.`,
772
+ check: "resolution:platform-skew",
773
+ status: "skew",
774
+ platforms: [...platforms],
775
+ });
776
+ }
777
+ return { line, skewDetected, findings };
778
+ }
779
+ /**
780
+ * The single read-only decision surface (#2267). Reuses the shared keystone
781
+ * `classify()` + `plan()` (the ONE classifier) to emit the operating mode, the
782
+ * engine/pin/VERSION reconciliation, cross-platform skew, and exactly ONE
783
+ * primary `Next command:` with a root-cause + remediation rationale. Secondary
784
+ * migration advice (`plan()` warnings) is suppressed until the primary blocker
785
+ * clears. Mutates nothing.
786
+ */
787
+ export function runResolutionDecision(projectRoot, jsonMode, sink, addFinding, seams) {
788
+ const facts = classify(projectRoot, {
789
+ ...(seams.isFile ? { isFile: seams.isFile } : {}),
790
+ ...(seams.isDir ? { isDir: seams.isDir } : {}),
791
+ ...(seams.readText ? { readText: seams.readText } : {}),
792
+ ...(seams.engineProbe ? { engineProbe: seams.engineProbe } : {}),
793
+ });
794
+ const plan = resolvePlan(facts, {}, { platform: process.platform, interactive: false });
795
+ const operatingMode = resolveOperatingMode(facts);
796
+ const reconciliation = resolveReconciliationLine(facts);
797
+ const skew = resolvePlatformSkew(projectRoot, seams);
798
+ const hasTaskfileWiring = resolveTaskfileWiring(projectRoot, seams);
799
+ const nextCommand = enforceDirectiveSurface(plan.nextAction.command, hasTaskfileWiring);
800
+ const actionRequired = plan.mode !== "proceed";
801
+ // Sanitize newlines on data-derived strings before they land in a rendered
802
+ // bullet (CWE-116); root cause / remediation can carry version substrings.
803
+ const rootCauseLine = plan.nextAction.rootCause.replace(/\r?\n/g, " ");
804
+ const remediationLine = plan.nextAction.remediation.replace(/\r?\n/g, " ");
805
+ if (!jsonMode) {
806
+ sink.raw(`Operating mode: ${operatingMode}`);
807
+ sink.raw(`Version reconciliation: ${reconciliation}`);
808
+ sink.raw(`Cross-platform engine: ${skew.line}`);
809
+ if (actionRequired) {
810
+ sink.blank();
811
+ sink.warn("Primary next action (resolve this first; secondary migration advice is deferred until it clears):");
812
+ sink.raw(` Root cause: ${rootCauseLine}`);
813
+ sink.raw(nextCommand
814
+ ? `Next command: ${nextCommand}`
815
+ : "Next command: (manual -- no single command)");
816
+ sink.raw(` Does / why safe: ${remediationLine}`);
817
+ }
818
+ else {
819
+ // Derive the proceed line from plan() so it never over-claims "aligned"
820
+ // in the no-pin case (Greptile #2283).
821
+ sink.success(`Resolution: proceed -- ${rootCauseLine}.`);
822
+ // Only once the primary blocker has cleared do we surface the ordered
823
+ // secondary notes (legacy vbrief migrate, no-pin advisory, ...).
824
+ for (const warning of plan.warnings) {
825
+ sink.info(`note: ${warning.replace(/\r?\n/g, " ")}`);
826
+ }
827
+ }
828
+ }
829
+ for (const finding of skew.findings) {
830
+ addFinding(finding);
831
+ }
832
+ if (actionRequired) {
833
+ addFinding({
834
+ severity: "warning",
835
+ message: `Next command: ${nextCommand ?? "(manual)"} -- ${rootCauseLine}`,
836
+ check: "resolution",
837
+ status: plan.mode,
838
+ mode: plan.mode,
839
+ next_command: nextCommand,
840
+ root_cause: rootCauseLine,
841
+ remediation: remediationLine,
842
+ operating_mode: operatingMode,
843
+ });
844
+ }
845
+ else {
846
+ addFinding({
847
+ severity: "skip",
848
+ message: "resolution: proceed -- no primary action required",
849
+ check: "resolution",
850
+ status: "proceed",
851
+ });
852
+ }
853
+ return {
854
+ operatingMode,
855
+ reconciliation,
856
+ platformSkew: skew.line,
857
+ platformSkewDetected: skew.skewDetected,
858
+ mode: plan.mode,
859
+ actionRequired,
860
+ nextCommand,
861
+ rootCause: plan.nextAction.rootCause,
862
+ remediation: plan.nextAction.remediation,
863
+ warnings: plan.warnings,
864
+ };
865
+ }
516
866
  //# sourceMappingURL=main.js.map
@@ -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,7 @@
1
1
  import type { AdvisoryEvaluateResult } from "../agents-md-advisory/evaluate.js";
2
+ import type { EngineProbeResult } from "../resolution/classify.js";
3
+ import type { ResolutionMode } from "../resolution/index.js";
4
+ import type { ResolveUserMdResult } from "../user-config/resolve-user-md.js";
2
5
  export declare const EXIT_CLEAN = 0;
3
6
  export declare const EXIT_DRIFT = 1;
4
7
  export declare const EXIT_CONFIG_ERROR = 2;
@@ -25,6 +28,34 @@ export interface Finding {
25
28
  readonly status?: string;
26
29
  readonly [key: string]: unknown;
27
30
  }
31
+ /**
32
+ * The single read-only decision surface derived from the shared keystone
33
+ * `plan()` (#2267 / epic #2203). `plan()` is the ONE classifier: doctor never
34
+ * re-derives the mode here, it only renders the mode + the single ordered next
35
+ * action plus the operating-mode / reconciliation / cross-platform-skew context.
36
+ */
37
+ export interface ResolutionSummary {
38
+ /** Human-facing operating mode (hybrid / vendored / greenfield / ...). */
39
+ readonly operatingMode: string;
40
+ /** engine/pin/VERSION reconciliation verdict line. */
41
+ readonly reconciliation: string;
42
+ /** Cross-platform `.deft/.cli/<platform>` engine presence + skew line. */
43
+ readonly platformSkew: string;
44
+ /** True when the platform installs diverge (present/partial/absent mix). */
45
+ readonly platformSkewDetected: boolean;
46
+ /** Resolved mode from `plan()` (the single classifier). */
47
+ readonly mode: ResolutionMode;
48
+ /** True when the resolved mode requires operator action (mode !== "proceed"). */
49
+ readonly actionRequired: boolean;
50
+ /** The ONE primary next command, directive-surfaced; null for manual actions. */
51
+ readonly nextCommand: string | null;
52
+ /** Why the primary action is recommended. */
53
+ readonly rootCause: string;
54
+ /** What the remediation does + why it is safe. */
55
+ readonly remediation: string;
56
+ /** Ordered secondary warnings from `plan()` (informational, not directives). */
57
+ readonly warnings: readonly string[];
58
+ }
28
59
  export interface DoctorFlags {
29
60
  readonly session: boolean;
30
61
  readonly fix: boolean;
@@ -79,5 +110,22 @@ export interface DoctorSeams {
79
110
  readonly readYn?: (prompt: string, defaultYes: boolean) => boolean;
80
111
  readonly writeText?: (path: string, content: string) => void;
81
112
  readonly now?: () => Date;
113
+ /**
114
+ * Engine-reachability probe threaded into the shared `classify()`. Injected so
115
+ * the resolution decision stays deterministic + offline in tests; the default
116
+ * shells out to `directive --version` / `deft --version`.
117
+ */
118
+ readonly engineProbe?: () => EngineProbeResult;
119
+ /**
120
+ * Platform ids probed for cross-platform `.deft/.cli/<platform>` engine skew.
121
+ * Defaults to `["linux", "darwin", "win32"]`.
122
+ */
123
+ readonly resolutionPlatforms?: readonly string[];
124
+ /**
125
+ * USER.md resolver seam (#2271). Injected so the doctor USER.md-resolution
126
+ * surface stays deterministic + offline in tests. Defaults to the shared
127
+ * first-hit-wins resolver scoped to the project root.
128
+ */
129
+ readonly resolveUserMd?: (projectRoot: string) => ResolveUserMdResult;
82
130
  }
83
131
  //# 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";
package/dist/index.js 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";