@deftai/directive-core 0.68.1 → 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 (58) 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/index.d.ts +3 -0
  15. package/dist/init-deposit/index.js +3 -0
  16. package/dist/init-deposit/init-dispatch.d.ts +90 -0
  17. package/dist/init-deposit/init-dispatch.js +170 -0
  18. package/dist/init-deposit/refresh.d.ts +79 -2
  19. package/dist/init-deposit/refresh.js +177 -5
  20. package/dist/init-deposit/scaffold.d.ts +25 -0
  21. package/dist/init-deposit/scaffold.js +57 -0
  22. package/dist/init-deposit/untrack-core.d.ts +84 -0
  23. package/dist/init-deposit/untrack-core.js +150 -0
  24. package/dist/resolution/classify.d.ts +41 -0
  25. package/dist/resolution/classify.js +138 -0
  26. package/dist/resolution/engine-ladder.d.ts +98 -0
  27. package/dist/resolution/engine-ladder.js +185 -0
  28. package/dist/resolution/index.d.ts +19 -0
  29. package/dist/resolution/index.js +19 -0
  30. package/dist/resolution/integrity.d.ts +48 -0
  31. package/dist/resolution/integrity.js +80 -0
  32. package/dist/resolution/package-manager.d.ts +77 -0
  33. package/dist/resolution/package-manager.js +103 -0
  34. package/dist/resolution/pin.d.ts +73 -0
  35. package/dist/resolution/pin.js +169 -0
  36. package/dist/resolution/plan.d.ts +66 -0
  37. package/dist/resolution/plan.js +219 -0
  38. package/dist/resolution/skew-policy.d.ts +46 -0
  39. package/dist/resolution/skew-policy.js +120 -0
  40. package/dist/session/session-start.d.ts +2 -0
  41. package/dist/session/session-start.js +28 -1
  42. package/dist/user-config/index.d.ts +2 -0
  43. package/dist/user-config/index.js +2 -0
  44. package/dist/user-config/resolve-user-md.d.ts +76 -0
  45. package/dist/user-config/resolve-user-md.js +120 -0
  46. package/dist/xbrief-migrate/constants.d.ts +10 -0
  47. package/dist/xbrief-migrate/constants.js +19 -0
  48. package/dist/xbrief-migrate/detect.d.ts +34 -0
  49. package/dist/xbrief-migrate/detect.js +33 -0
  50. package/dist/xbrief-migrate/fs-helpers.d.ts +13 -0
  51. package/dist/xbrief-migrate/fs-helpers.js +46 -1
  52. package/dist/xbrief-migrate/index.d.ts +4 -2
  53. package/dist/xbrief-migrate/index.js +4 -2
  54. package/dist/xbrief-migrate/migrate-project.d.ts +23 -1
  55. package/dist/xbrief-migrate/migrate-project.js +97 -8
  56. package/dist/xbrief-migrate/signpost.d.ts +7 -1
  57. package/dist/xbrief-migrate/signpost.js +18 -3
  58. package/package.json +7 -3
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Universal adoption dispatcher for `directive init` (#2265 / epic #2203).
3
+ *
4
+ * `directive init` is the ONE entrypoint a first-time adopter runs regardless of
5
+ * their directory state. This module classifies the directory via the shared
6
+ * keystone `classify()`/`plan()` fact-set (#2264, consumed read-only) and
7
+ * dispatches to exactly one of four paths, always printing a concise state
8
+ * summary plus exactly one recommended next action:
9
+ *
10
+ * - `scaffold` — empty greenfield dir; deposit Directive.
11
+ * - `brownfield-install` — app code but no Directive deposit; install support
12
+ * beside the app code without disturbing it.
13
+ * - `delegate-update` — already initialized; DELEGATE to `update` with an
14
+ * explicit disclosure line. init never silently
15
+ * refreshes under its own name (the #2199 anti-pattern)
16
+ * and never re-scaffolds an existing install.
17
+ * - `route-migrate` — legacy / pre-cutover layout; route to `migrate`.
18
+ *
19
+ * Logic is single-sourced: each delegate branch CALLS the existing narrow verb
20
+ * (`runInitDepositCli` / `runRefreshDepositCli` / `runMigrateCli`) — the
21
+ * dispatcher never re-implements deposit, refresh, or migrate behavior.
22
+ */
23
+ import { resolve } from "node:path";
24
+ import { classify, plan } from "../resolution/index.js";
25
+ import { runInitDepositCli } from "./init-deposit.js";
26
+ import { runMigrateCli } from "./migrate.js";
27
+ import { runRefreshDepositCli } from "./refresh.js";
28
+ /** Human-readable state label per decision, used in the printed summary. */
29
+ export const INIT_DISPATCH_STATE_LABEL = {
30
+ scaffold: "empty directory (greenfield)",
31
+ "brownfield-install": "brownfield project (app code, no Directive deposit)",
32
+ "delegate-update": "already initialized",
33
+ "route-migrate": "legacy / pre-cutover layout",
34
+ };
35
+ /**
36
+ * Explicit disclosure printed BEFORE delegating an already-initialized project
37
+ * to `update`. Its presence is what keeps `init` from silently masquerading as
38
+ * `update` (the #2199 anti-pattern): the user always sees that init delegated.
39
+ */
40
+ export const UPDATE_DELEGATION_DISCLOSURE = "Existing install detected — refreshing via `update` (init delegates to update; it does not re-scaffold).";
41
+ /**
42
+ * Collapse the shared resolution {@link ResolutionPlan} + fact-set onto one of
43
+ * the four init dispatch decisions. There is no second classifier: `plan.mode`
44
+ * is the single source of truth, and the empty-vs-brownfield split reads the
45
+ * `hasAppCode`/`hasGit` facts already produced by `classify()`.
46
+ */
47
+ export function decideInitDispatch(facts, resolutionPlan) {
48
+ if (resolutionPlan.mode === "migrate")
49
+ return "route-migrate";
50
+ if (resolutionPlan.mode === "init") {
51
+ return facts.hasAppCode || facts.hasGit ? "brownfield-install" : "scaffold";
52
+ }
53
+ // Deposit present (proceed | update | install-* | blocked): the install exists,
54
+ // so `update` owns the engine/content dimension. init delegates.
55
+ return "delegate-update";
56
+ }
57
+ /**
58
+ * Run the up-front dispatch classifier. Reuses the keystone `classify()`
59
+ * fact-set + `plan()` precedence table — no second classifier lives here.
60
+ */
61
+ export function classifyInitDispatch(projectDir, classifySeams = {}) {
62
+ const facts = classify(projectDir, classifySeams);
63
+ const resolutionPlan = plan(facts, {});
64
+ return { decision: decideInitDispatch(facts, resolutionPlan), plan: resolutionPlan, facts };
65
+ }
66
+ /** The single recommended next-action line for a decision. */
67
+ export function initDispatchNextAction(decision, resolutionPlan) {
68
+ switch (decision) {
69
+ case "scaffold":
70
+ return "Next: scaffolding a fresh Directive deposit (.deft/core, AGENTS.md managed section, xbrief/ layout).";
71
+ case "brownfield-install":
72
+ return "Next: installing Directive support beside your app code (app source is left untouched); then extract a brownfield spec and set up xbrief/.";
73
+ case "delegate-update":
74
+ return "Next: refreshing the existing install via `update`.";
75
+ case "route-migrate":
76
+ return `Next: routing to \`migrate\`. ${resolutionPlan.nextAction.remediation}`;
77
+ default: {
78
+ const exhaustive = decision;
79
+ throw new Error(`unhandled init dispatch decision: ${String(exhaustive)}`);
80
+ }
81
+ }
82
+ }
83
+ /** Print the concise state summary + exactly one recommended next action. */
84
+ export function printInitDispatchSummary(io, decision, resolutionPlan) {
85
+ io.printf(`\n[directive init] State: ${INIT_DISPATCH_STATE_LABEL[decision]}\n`);
86
+ io.printf(`[directive init] ${initDispatchNextAction(decision, resolutionPlan)}\n`);
87
+ for (const warning of resolutionPlan.warnings) {
88
+ io.printf(`[directive init] Note: ${warning}\n`);
89
+ }
90
+ }
91
+ /** Machine-readable payload emitted for `--dry-run`/`--plan` (no execution). */
92
+ export function buildInitDispatchDryRunJson(projectDir, classification) {
93
+ return {
94
+ success: true,
95
+ action: "init",
96
+ dry_run: true,
97
+ dispatch: classification.decision,
98
+ mode: classification.plan.mode,
99
+ project_dir: projectDir,
100
+ next_action: classification.plan.nextAction,
101
+ warnings: classification.plan.warnings,
102
+ };
103
+ }
104
+ /**
105
+ * CLI-facing entrypoint for `directive init`. Classifies, prints a state summary
106
+ * + one next action, then either stops (`--dry-run`) or delegates to the narrow
107
+ * verb for the resolved decision. The summary/disclosure use the `printf`
108
+ * channel (stderr under `--json`) so that in JSON mode stdout stays a SINGLE
109
+ * JSON object — the delegate's result, or the dry-run plan.
110
+ */
111
+ export async function runInitDispatchCli(options) {
112
+ const projectDir = resolve(options.projectDir);
113
+ const seams = options.seams ?? {};
114
+ const io = {
115
+ printf: (text) => {
116
+ if (options.jsonOut)
117
+ options.writeErr(text);
118
+ else
119
+ options.writeOut(text);
120
+ },
121
+ };
122
+ const classification = classifyInitDispatch(projectDir, seams.classifySeams ?? {});
123
+ const { decision, plan: resolutionPlan } = classification;
124
+ printInitDispatchSummary(io, decision, resolutionPlan);
125
+ if (options.dryRun) {
126
+ if (options.jsonOut) {
127
+ options.writeOut(`${JSON.stringify(buildInitDispatchDryRunJson(projectDir, classification), null, 2)}\n`);
128
+ }
129
+ return 0;
130
+ }
131
+ switch (decision) {
132
+ case "scaffold":
133
+ case "brownfield-install": {
134
+ const runScaffold = seams.runScaffold ?? runInitDepositCli;
135
+ return runScaffold({
136
+ projectDir,
137
+ jsonOut: options.jsonOut,
138
+ nonInteractive: options.nonInteractive,
139
+ writeOut: options.writeOut,
140
+ writeErr: options.writeErr,
141
+ });
142
+ }
143
+ case "delegate-update": {
144
+ io.printf(`\n[directive init] ${UPDATE_DELEGATION_DISCLOSURE}\n`);
145
+ const runRefresh = seams.runRefresh ?? runRefreshDepositCli;
146
+ return runRefresh({
147
+ projectDir,
148
+ jsonOut: options.jsonOut,
149
+ nonInteractive: options.nonInteractive,
150
+ upgrade: false,
151
+ writeOut: options.writeOut,
152
+ writeErr: options.writeErr,
153
+ });
154
+ }
155
+ case "route-migrate": {
156
+ const runMigrate = seams.runMigrate ?? runMigrateCli;
157
+ return runMigrate({
158
+ projectDir,
159
+ jsonOut: options.jsonOut,
160
+ writeOut: options.writeOut,
161
+ writeErr: options.writeErr,
162
+ });
163
+ }
164
+ default: {
165
+ const exhaustive = decision;
166
+ throw new Error(`unhandled init dispatch decision: ${String(exhaustive)}`);
167
+ }
168
+ }
169
+ }
170
+ //# sourceMappingURL=init-dispatch.js.map
@@ -7,6 +7,9 @@
7
7
  *
8
8
  * Refs #1942, #1430, #1671.
9
9
  */
10
+ import type { ResolutionFacts, ResolutionPlan } from "@deftai/directive-types";
11
+ import { type ClassifySeams, type EngineInstallRunner, type EngineResolution, type LadderFacts, type ReprojectRunner } from "../resolution/index.js";
12
+ import { type GitLsFiles } from "./gitignore.js";
10
13
  import { type InitDepositArgs } from "./init-deposit.js";
11
14
  import { type LegacyLayoutDetection } from "./legacy-detect.js";
12
15
  import { type InitDepositIo } from "./scaffold.js";
@@ -33,18 +36,92 @@ export interface RefreshDepositSeams {
33
36
  nowIso?: () => string;
34
37
  gitPorcelain?: (projectRoot: string) => string | null;
35
38
  detectLegacy?: (projectDir: string) => LegacyLayoutDetection;
39
+ /**
40
+ * #2266: `git ls-files` probe threaded into the non-destructive `.gitignore`
41
+ * upkeep so the refresh never invokes a destructive `git rm --cached` path.
42
+ */
43
+ gitLsFiles?: GitLsFiles;
36
44
  }
45
+ /**
46
+ * The four states `directive update` classifies an EXISTING install into BEFORE
47
+ * any copy (#2266). Derived from the keystone `classify()`/`plan()` spine
48
+ * (#2264) — there is exactly one classifier in the system; this maps the shared
49
+ * {@link ResolutionPlan} onto the narrow update-verb contract.
50
+ *
51
+ * - `not-initialized` — no Directive footprint at all; STOP, hint `init`.
52
+ * - `current` — initialized and already up to date; refresh is a no-op.
53
+ * - `updated` — initialized and the refresh forward-migrates content.
54
+ * - `migration-required` — pre-cutover artifacts; `update` is not enough.
55
+ */
56
+ export type UpdateState = "not-initialized" | "current" | "updated" | "migration-required";
57
+ export interface UpdateClassification {
58
+ readonly state: UpdateState;
59
+ readonly plan: ResolutionPlan;
60
+ readonly facts: ResolutionFacts;
61
+ }
62
+ /** The exact refusal shown when `update` runs against an un-initialized project. */
63
+ export declare const NOT_INITIALIZED_MESSAGE = "This project is not initialized. Run directive init.";
64
+ /** `update` refused because the project has no install / needs a different verb. */
65
+ export declare const UPDATE_REFUSED_EXIT_CODE = 1;
66
+ /**
67
+ * Collapse the shared resolution {@link ResolutionPlan} onto the four update
68
+ * states. Pre-cutover wins over everything (migrate before any refresh); a
69
+ * project with no footprint is not-initialized; an initialized project that the
70
+ * spine says can `proceed` is already current; everything else (deposit
71
+ * reconstitution, content behind pin, engine self-heal) is an `updated` refresh.
72
+ */
73
+ export declare function updateStateFromPlan(facts: ResolutionFacts, resolutionPlan: ResolutionPlan): UpdateState;
74
+ /**
75
+ * Run the up-front four-state classifier (#2266). Reuses the keystone
76
+ * `classify()` fact-set + `plan()` precedence table — no second classifier.
77
+ */
78
+ export declare function classifyUpdateState(projectDir: string, classifySeams?: ClassifySeams): UpdateClassification;
79
+ export interface SelfHealSeams {
80
+ /** Pre-computed ladder facts; when omitted, cheap local probes build them. */
81
+ readonly ladderFacts?: LadderFacts;
82
+ /** Injected side-effecting install runner; when omitted, the install is deferred. */
83
+ readonly engineInstallRunner?: EngineInstallRunner;
84
+ /** Injected content re-projection after a fresh install. */
85
+ readonly reproject?: ReprojectRunner;
86
+ }
87
+ /**
88
+ * Cheap, non-networked ladder facts for the default self-heal path. The global
89
+ * engine version is whatever `classify()` already probed; the local sandbox
90
+ * engine integrity is a filesystem check. `registryUp`/`globalPrefixWritable`
91
+ * default optimistic so the ladder surfaces the canonical `npm i -g` remediation
92
+ * when no install runner is wired.
93
+ */
94
+ export declare function buildDefaultLadderFacts(projectDir: string, facts: ResolutionFacts): LadderFacts;
95
+ /**
96
+ * Self-heal a mismatched / unreachable engine by DELEGATING to the keystone
97
+ * global-first ladder (`resolveEngine`, #2264). The refresh's content copy comes
98
+ * from the resolved content package and does not itself require the engine, so
99
+ * this is a best-effort heal that prints the ladder trace and remediation; when
100
+ * a caller wires an install runner, the ladder performs the install with zero
101
+ * manual npm/PATH steps (#2266 a3).
102
+ */
103
+ export declare function selfHealEngine(projectDir: string, facts: ResolutionFacts, io: InitDepositIo, seams?: SelfHealSeams): EngineResolution;
37
104
  /** Framework-managed uncommitted paths after refresh (#1671). */
38
105
  export declare function frameworkRefreshSideEffects(projectDir: string, readPorcelain?: (root: string) => string | null): string[];
39
106
  export declare function printRefreshSideEffects(io: InitDepositIo, files: readonly string[]): void;
40
107
  export declare function buildVersionSkewNotice(engineVersion: string, contentVersion: string, previousDepositVersion: string | null): string | null;
41
- export declare function buildUpdateSummaryJson(result: RefreshDepositResult, options: RefreshDepositArgs): Record<string, unknown>;
42
- export declare function printUpdateComplete(result: RefreshDepositResult, io: InitDepositIo): void;
108
+ export declare function buildUpdateSummaryJson(result: RefreshDepositResult, options: RefreshDepositArgs, updateState?: UpdateState): Record<string, unknown>;
109
+ export declare function printUpdateComplete(result: RefreshDepositResult, io: InitDepositIo, updateState?: UpdateState): void;
43
110
  export declare function runRefreshDeposit(args: RefreshDepositArgs, io: InitDepositIo, seams?: RefreshDepositSeams): Promise<RefreshDepositResult>;
44
111
  export interface RunRefreshDepositCliOptions extends RefreshDepositArgs {
45
112
  readonly writeOut: (text: string) => void;
46
113
  readonly writeErr: (text: string) => void;
47
114
  readonly seams?: RefreshDepositSeams;
115
+ /** #2266: print the classified plan without executing the refresh (`--dry-run`/`--plan`). */
116
+ readonly dryRun?: boolean;
117
+ /** #2266: seams for the up-front four-state classifier (default: real fs + engine probe). */
118
+ readonly classifySeams?: ClassifySeams;
119
+ /** #2266: pre-computed engine-ladder facts for the self-heal delegation (default: probed). */
120
+ readonly ladderFacts?: LadderFacts;
121
+ /** #2266: injected engine install runner for the ladder self-heal (default: deferred). */
122
+ readonly engineInstallRunner?: EngineInstallRunner;
123
+ /** #2266: injected content re-projection after a self-heal install. */
124
+ readonly reproject?: ReprojectRunner;
48
125
  }
49
126
  /** CLI-facing wrapper: runs refresh, emits JSON or wizard UX, returns exit code. */
50
127
  export declare function runRefreshDepositCli(options: RunRefreshDepositCliOptions): Promise<number>;
@@ -8,6 +8,7 @@
8
8
  * Refs #1942, #1430, #1671.
9
9
  */
10
10
  import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
11
+ import { platform as osPlatform } from "node:os";
11
12
  import { join, resolve } from "node:path";
12
13
  import { copyTree } from "../deposit/copy-tree.js";
13
14
  import { prunePythonArtifactsFromDeposit } from "../deposit/python-free.js";
@@ -16,12 +17,92 @@ import { manifestTagToVersion, parseInstallManifest } from "../doctor/manifest.j
16
17
  import { readCorePackageVersion } from "../engine-version.js";
17
18
  import { resolveLifecycleRoot } from "../layout/resolve.js";
18
19
  import { DEV_FALLBACK } from "../platform/constants.js";
20
+ import { checkLocalEngineIntegrity, classify, ENGINE_PACKAGE, plan, renderGlobalInstall, resolveEngine, } from "../resolution/index.js";
19
21
  import { gitPorcelain } from "../story-ready/git.js";
22
+ import { ensureInitGitignoreLines } from "./gitignore.js";
20
23
  import { depositStagePaths, isInstallerManagedPath, printCommitGuidance } from "./hygiene.js";
21
24
  import { parseInitArgv } from "./init-deposit.js";
22
25
  import { buildLegacyRefusalJson, buildLegacyRefusalMessage, detectLegacyLayout, LEGACY_LAYOUT_REFUSED_EXIT_CODE, LegacyLayoutRefusedError, } from "./legacy-detect.js";
23
26
  import { printMigrateNudgeIfNeeded } from "./migrate.js";
24
27
  import { CANONICAL_INSTALL_ROOT, depositNeutralization, ensureTaskfile, writeAgentsMd, writeInstallManifest, } from "./scaffold.js";
28
+ /** The exact refusal shown when `update` runs against an un-initialized project. */
29
+ export const NOT_INITIALIZED_MESSAGE = "This project is not initialized. Run directive init.";
30
+ /** `update` refused because the project has no install / needs a different verb. */
31
+ export const UPDATE_REFUSED_EXIT_CODE = 1;
32
+ /** A project is initialized when it carries ANY Directive footprint. */
33
+ function isInitialized(facts) {
34
+ return facts.hasDeftCore || facts.hasManagedSection || facts.pinVersion !== null;
35
+ }
36
+ /**
37
+ * Collapse the shared resolution {@link ResolutionPlan} onto the four update
38
+ * states. Pre-cutover wins over everything (migrate before any refresh); a
39
+ * project with no footprint is not-initialized; an initialized project that the
40
+ * spine says can `proceed` is already current; everything else (deposit
41
+ * reconstitution, content behind pin, engine self-heal) is an `updated` refresh.
42
+ */
43
+ export function updateStateFromPlan(facts, resolutionPlan) {
44
+ if (resolutionPlan.mode === "migrate")
45
+ return "migration-required";
46
+ if (!isInitialized(facts))
47
+ return "not-initialized";
48
+ if (resolutionPlan.mode === "proceed")
49
+ return "current";
50
+ return "updated";
51
+ }
52
+ /**
53
+ * Run the up-front four-state classifier (#2266). Reuses the keystone
54
+ * `classify()` fact-set + `plan()` precedence table — no second classifier.
55
+ */
56
+ export function classifyUpdateState(projectDir, classifySeams = {}) {
57
+ const facts = classify(projectDir, classifySeams);
58
+ const resolutionPlan = plan(facts, {});
59
+ return { state: updateStateFromPlan(facts, resolutionPlan), plan: resolutionPlan, facts };
60
+ }
61
+ /**
62
+ * Cheap, non-networked ladder facts for the default self-heal path. The global
63
+ * engine version is whatever `classify()` already probed; the local sandbox
64
+ * engine integrity is a filesystem check. `registryUp`/`globalPrefixWritable`
65
+ * default optimistic so the ladder surfaces the canonical `npm i -g` remediation
66
+ * when no install runner is wired.
67
+ */
68
+ export function buildDefaultLadderFacts(projectDir, facts) {
69
+ const integrity = checkLocalEngineIntegrity(projectDir);
70
+ return {
71
+ pinVersion: facts.pinVersion,
72
+ globalEngineVersion: facts.engineReachable ? facts.engineVersion : null,
73
+ localEngine: integrity.present ? { version: null, integrity } : null,
74
+ registryUp: true,
75
+ globalPrefixWritable: true,
76
+ stagedTarballAvailable: false,
77
+ platform: osPlatform(),
78
+ };
79
+ }
80
+ /**
81
+ * Self-heal a mismatched / unreachable engine by DELEGATING to the keystone
82
+ * global-first ladder (`resolveEngine`, #2264). The refresh's content copy comes
83
+ * from the resolved content package and does not itself require the engine, so
84
+ * this is a best-effort heal that prints the ladder trace and remediation; when
85
+ * a caller wires an install runner, the ladder performs the install with zero
86
+ * manual npm/PATH steps (#2266 a3).
87
+ */
88
+ export function selfHealEngine(projectDir, facts, io, seams = {}) {
89
+ const ladderFacts = seams.ladderFacts ?? buildDefaultLadderFacts(projectDir, facts);
90
+ const resolution = resolveEngine(ladderFacts, {
91
+ installRunner: seams.engineInstallRunner,
92
+ reproject: seams.reproject,
93
+ });
94
+ io.printf(`\n[deft update] engine self-heal (global-first ladder): ${resolution.trace}\n`);
95
+ if (!resolution.selfHealed && !resolution.decision.usable) {
96
+ io.printf(`[deft update] ${resolution.decision.reason}\n`);
97
+ if (resolution.decision.rung === "install-global") {
98
+ const suffix = facts.pinVersion ? `@${facts.pinVersion}` : "";
99
+ const spec = `${ENGINE_PACKAGE}${suffix}`;
100
+ io.printf(` Remediation: ${renderGlobalInstall("npm", spec)}\n`);
101
+ io.printf(` (pnpm: ${renderGlobalInstall("pnpm", spec)})\n`);
102
+ }
103
+ }
104
+ return resolution;
105
+ }
25
106
  function normalizeVersion(version) {
26
107
  return version.trim().replace(/^v/, "");
27
108
  }
@@ -199,10 +280,11 @@ export function buildVersionSkewNotice(engineVersion, contentVersion, previousDe
199
280
  }
200
281
  return null;
201
282
  }
202
- export function buildUpdateSummaryJson(result, options) {
283
+ export function buildUpdateSummaryJson(result, options, updateState) {
203
284
  return {
204
285
  success: true,
205
286
  action: "upgrade",
287
+ ...(updateState ? { update_state: updateState } : {}),
206
288
  version: result.engineVersion,
207
289
  project_dir: result.projectDir,
208
290
  deft_dir: result.deftDir,
@@ -229,10 +311,13 @@ export function buildUpdateSummaryJson(result, options) {
229
311
  agents_md_updated: result.agentsMdUpdated,
230
312
  };
231
313
  }
232
- export function printUpdateComplete(result, io) {
314
+ export function printUpdateComplete(result, io, updateState) {
233
315
  io.printf("\n✓ Deft framework payload refreshed.\n\n");
234
316
  io.printf(` Location : ${result.deftDir}\n`);
235
317
  io.printf(` Content : v${normalizeVersion(result.contentVersion)}\n`);
318
+ if (updateState) {
319
+ io.printf(` State : ${updateState}\n`);
320
+ }
236
321
  io.printf(` AGENTS.md : ${result.agentsMdUpdated ? "updated" : "already current"}\n`);
237
322
  if (result.versionSkewNotice) {
238
323
  io.printf(`\n${result.versionSkewNotice}\n`);
@@ -283,6 +368,12 @@ export async function runRefreshDeposit(args, io, seams = {}) {
283
368
  syncBareVersionMarker(projectDir, contentVersion);
284
369
  const agentsMdUpdated = writeAgentsMd(projectDir, deftDir, io);
285
370
  await depositNeutralization(projectDir, io);
371
+ // #2266: non-destructive `.gitignore` upkeep for framework-owned paths. This
372
+ // NEVER un-tracks a committed deposit -- `ensureInitGitignoreLines` only writes
373
+ // `.gitignore` and leaves an already-tracked `.deft/core` tracked. The
374
+ // destructive `git rm --cached .deft/core` un-track is the deliberate
375
+ // `migrate --untrack-core` step (#2269), not `update`.
376
+ ensureInitGitignoreLines(projectDir, io, { gitLsFiles: seams.gitLsFiles });
286
377
  let taskfileWired = false;
287
378
  if (args.nonInteractive) {
288
379
  taskfileWired = ensureTaskfile(projectDir, io);
@@ -309,6 +400,59 @@ export async function runRefreshDeposit(args, io, seams = {}) {
309
400
  stagedPaths,
310
401
  };
311
402
  }
403
+ function buildRefusalJson(state, projectDir, message, command) {
404
+ return {
405
+ success: false,
406
+ action: "update",
407
+ update_state: state,
408
+ project_dir: projectDir,
409
+ message,
410
+ next_action: { command },
411
+ };
412
+ }
413
+ /** Emit the `not-initialized` refusal: STOP, hint `init`, never write a partial install. */
414
+ function emitNotInitialized(options, io, projectDir) {
415
+ io.printf(`${NOT_INITIALIZED_MESSAGE}\n`);
416
+ if (options.jsonOut) {
417
+ options.writeOut(`${JSON.stringify(buildRefusalJson("not-initialized", projectDir, NOT_INITIALIZED_MESSAGE, "directive init"), null, 2)}\n`);
418
+ }
419
+ return UPDATE_REFUSED_EXIT_CODE;
420
+ }
421
+ /** Emit the `migration-required` refusal: `update` is not enough; point at init/migrate. */
422
+ function emitMigrationRequired(options, io, projectDir, classification) {
423
+ const remediation = classification.plan.nextAction.remediation;
424
+ const message = `directive update: this project requires migration before it can be refreshed. ${remediation}`;
425
+ io.printf(`${message}\n`);
426
+ if (options.jsonOut) {
427
+ options.writeOut(`${JSON.stringify(buildRefusalJson("migration-required", projectDir, message, classification.plan.nextAction.command ?? "directive init"), null, 2)}\n`);
428
+ }
429
+ return UPDATE_REFUSED_EXIT_CODE;
430
+ }
431
+ /** Emit the classified plan for `--dry-run`/`--plan` without executing the refresh. */
432
+ function emitDryRunPlan(options, io, projectDir, classification) {
433
+ const { state, plan: resolutionPlan } = classification;
434
+ io.printf(`\n[deft update] dry-run -- classified plan (no changes written):\n`);
435
+ io.printf(` State : ${state}\n`);
436
+ io.printf(` Mode : ${resolutionPlan.mode}\n`);
437
+ io.printf(` Root cause : ${resolutionPlan.nextAction.rootCause}\n`);
438
+ io.printf(` Remediation : ${resolutionPlan.nextAction.remediation}\n`);
439
+ for (const warning of resolutionPlan.warnings) {
440
+ io.printf(` Warning : ${warning}\n`);
441
+ }
442
+ if (options.jsonOut) {
443
+ options.writeOut(`${JSON.stringify({
444
+ success: true,
445
+ action: "update",
446
+ dry_run: true,
447
+ update_state: state,
448
+ mode: resolutionPlan.mode,
449
+ project_dir: projectDir,
450
+ next_action: resolutionPlan.nextAction,
451
+ warnings: resolutionPlan.warnings,
452
+ }, null, 2)}\n`);
453
+ }
454
+ return 0;
455
+ }
312
456
  /** CLI-facing wrapper: runs refresh, emits JSON or wizard UX, returns exit code. */
313
457
  export async function runRefreshDepositCli(options) {
314
458
  const io = {
@@ -321,14 +465,42 @@ export async function runRefreshDepositCli(options) {
321
465
  }
322
466
  },
323
467
  };
468
+ // #2266: classify up front (BEFORE any copy) and gate on the four states.
469
+ // Legacy layouts keep their existing refusal path (thrown by runRefreshDeposit
470
+ // and handled below), so classification is skipped for them.
471
+ const projectDir = resolve(options.projectDir);
472
+ const detectLegacy = options.seams?.detectLegacy ?? detectLegacyLayout;
473
+ let classification = null;
474
+ if (!detectLegacy(projectDir).legacy) {
475
+ classification = classifyUpdateState(projectDir, options.classifySeams ?? {});
476
+ if (classification.state === "not-initialized") {
477
+ return emitNotInitialized(options, io, projectDir);
478
+ }
479
+ if (classification.state === "migration-required") {
480
+ return emitMigrationRequired(options, io, projectDir, classification);
481
+ }
482
+ if (options.dryRun) {
483
+ return emitDryRunPlan(options, io, projectDir, classification);
484
+ }
485
+ // #2266 a3: self-heal a mismatched / unreachable engine via the keystone
486
+ // global-first ladder before the refresh proceeds.
487
+ if (!classification.facts.engineReachable) {
488
+ selfHealEngine(projectDir, classification.facts, io, {
489
+ ladderFacts: options.ladderFacts,
490
+ engineInstallRunner: options.engineInstallRunner,
491
+ reproject: options.reproject,
492
+ });
493
+ }
494
+ }
324
495
  try {
325
496
  const result = await runRefreshDeposit(options, io, options.seams);
497
+ const state = classification?.state;
326
498
  if (options.jsonOut) {
327
- options.writeOut(`${JSON.stringify(buildUpdateSummaryJson(result, options), null, 2)}\n`);
328
- printUpdateComplete(result, { printf: options.writeErr });
499
+ options.writeOut(`${JSON.stringify(buildUpdateSummaryJson(result, options, state), null, 2)}\n`);
500
+ printUpdateComplete(result, { printf: options.writeErr }, state);
329
501
  }
330
502
  else {
331
- printUpdateComplete(result, io);
503
+ printUpdateComplete(result, io, state);
332
504
  }
333
505
  return 0;
334
506
  }
@@ -27,6 +27,31 @@ export interface InstallManifestFields {
27
27
  }
28
28
  export declare function buildInstallManifestText(fields: InstallManifestFields): string;
29
29
  export declare function writeInstallManifest(projectDir: string, deftDir: string, fields: InstallManifestFields): string;
30
+ /** Canonical committed pin dependency name (mirrors resolution/pin.ts). */
31
+ export declare const PIN_DEPENDENCY_NAME = "@deftai/directive";
32
+ export interface EnsurePackageJsonPinResult {
33
+ readonly changed: boolean;
34
+ /** Exact version written to the devDependency pin. */
35
+ readonly pinVersion: string;
36
+ /** A fresh package.json was created (vs. an existing one updated). */
37
+ readonly created: boolean;
38
+ }
39
+ /**
40
+ * Write the committed `package.json` version pin on `@deftai/directive`
41
+ * (exact devDependency; `"private": true` preserved) so the resolution spine
42
+ * has a canonical, npm-native pin to read before directive runs. This unblocks
43
+ * #2269 (which is gated on the pin existing).
44
+ *
45
+ * Behavior:
46
+ * - existing `package.json` -> the exact devDependency pin is set/updated;
47
+ * an existing `"private"` value is preserved verbatim (never clobbered).
48
+ * - absent `package.json` -> a minimal `{ "private": true, ... }` is created.
49
+ * - idempotent -> re-running with the same pin makes no change.
50
+ *
51
+ * This is a deposit primitive; wiring it into the `init` verb flow is owned by
52
+ * #2265 (the init-consumes-plan child), per the #2264 scope guard.
53
+ */
54
+ export declare function ensurePackageJsonPin(projectDir: string, version: string, io: InitDepositIo): EnsurePackageJsonPinResult;
30
55
  export declare function writeAgentsMd(projectDir: string, deftDir: string, io: InitDepositIo): boolean;
31
56
  export declare function writeConsumerVbrief(projectDir: string, deftDir: string, io: InitDepositIo): Promise<boolean>;
32
57
  export declare function writeAgentsSkills(projectDir: string, io: InitDepositIo): boolean;
@@ -212,6 +212,63 @@ export function writeInstallManifest(projectDir, deftDir, fields) {
212
212
  writeFileSync(path, body, "utf8");
213
213
  return path;
214
214
  }
215
+ /** Canonical committed pin dependency name (mirrors resolution/pin.ts). */
216
+ export const PIN_DEPENDENCY_NAME = "@deftai/directive";
217
+ /**
218
+ * Write the committed `package.json` version pin on `@deftai/directive`
219
+ * (exact devDependency; `"private": true` preserved) so the resolution spine
220
+ * has a canonical, npm-native pin to read before directive runs. This unblocks
221
+ * #2269 (which is gated on the pin existing).
222
+ *
223
+ * Behavior:
224
+ * - existing `package.json` -> the exact devDependency pin is set/updated;
225
+ * an existing `"private"` value is preserved verbatim (never clobbered).
226
+ * - absent `package.json` -> a minimal `{ "private": true, ... }` is created.
227
+ * - idempotent -> re-running with the same pin makes no change.
228
+ *
229
+ * This is a deposit primitive; wiring it into the `init` verb flow is owned by
230
+ * #2265 (the init-consumes-plan child), per the #2264 scope guard.
231
+ */
232
+ export function ensurePackageJsonPin(projectDir, version, io) {
233
+ const pinVersion = version.trim().replace(/^v/i, "");
234
+ const path = join(projectDir, "package.json");
235
+ const existed = existsSync(path);
236
+ let pkg = {};
237
+ if (existed) {
238
+ try {
239
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
240
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
241
+ pkg = parsed;
242
+ }
243
+ else {
244
+ throw new Error("package.json root must be a JSON object");
245
+ }
246
+ }
247
+ catch (cause) {
248
+ throw new Error(`could not parse package.json (leaving it unchanged): ${String(cause)}`);
249
+ }
250
+ }
251
+ else {
252
+ // Fresh scaffold: a consumer deposit is a non-published workspace.
253
+ pkg.private = true;
254
+ }
255
+ const devDeps = typeof pkg.devDependencies === "object" &&
256
+ pkg.devDependencies !== null &&
257
+ !Array.isArray(pkg.devDependencies)
258
+ ? { ...pkg.devDependencies }
259
+ : {};
260
+ if (devDeps[PIN_DEPENDENCY_NAME] === pinVersion) {
261
+ io.printf(`package.json already pins ${PIN_DEPENDENCY_NAME}@${pinVersion} — skipping.\n`);
262
+ return { changed: false, pinVersion, created: false };
263
+ }
264
+ devDeps[PIN_DEPENDENCY_NAME] = pinVersion;
265
+ pkg.devDependencies = devDeps;
266
+ writeFileSync(path, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
267
+ io.printf(existed
268
+ ? `package.json updated: pinned ${PIN_DEPENDENCY_NAME}@${pinVersion} (exact).\n`
269
+ : `package.json created: pinned ${PIN_DEPENDENCY_NAME}@${pinVersion} (exact, private).\n`);
270
+ return { changed: true, pinVersion, created: !existed };
271
+ }
215
272
  export function writeAgentsMd(projectDir, deftDir, io) {
216
273
  const plan = agentsRefreshPlan(projectDir, { frameworkRoot: deftDir });
217
274
  const state = plan.state;