@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
@@ -0,0 +1,90 @@
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 type { ResolutionFacts, ResolutionPlan } from "@deftai/directive-types";
24
+ import { type ClassifySeams } from "../resolution/index.js";
25
+ import { type RunInitDepositCliOptions } from "./init-deposit.js";
26
+ import { type RunMigrateCliOptions } from "./migrate.js";
27
+ import { type RunRefreshDepositCliOptions } from "./refresh.js";
28
+ import type { InitDepositIo } from "./scaffold.js";
29
+ /** The four adoption paths `directive init` dispatches into. */
30
+ export type InitDispatchDecision = "scaffold" | "brownfield-install" | "delegate-update" | "route-migrate";
31
+ /** Human-readable state label per decision, used in the printed summary. */
32
+ export declare const INIT_DISPATCH_STATE_LABEL: Record<InitDispatchDecision, string>;
33
+ /**
34
+ * Explicit disclosure printed BEFORE delegating an already-initialized project
35
+ * to `update`. Its presence is what keeps `init` from silently masquerading as
36
+ * `update` (the #2199 anti-pattern): the user always sees that init delegated.
37
+ */
38
+ export declare const UPDATE_DELEGATION_DISCLOSURE = "Existing install detected \u2014 refreshing via `update` (init delegates to update; it does not re-scaffold).";
39
+ /**
40
+ * Collapse the shared resolution {@link ResolutionPlan} + fact-set onto one of
41
+ * the four init dispatch decisions. There is no second classifier: `plan.mode`
42
+ * is the single source of truth, and the empty-vs-brownfield split reads the
43
+ * `hasAppCode`/`hasGit` facts already produced by `classify()`.
44
+ */
45
+ export declare function decideInitDispatch(facts: ResolutionFacts, resolutionPlan: ResolutionPlan): InitDispatchDecision;
46
+ export interface InitDispatchClassification {
47
+ readonly decision: InitDispatchDecision;
48
+ readonly plan: ResolutionPlan;
49
+ readonly facts: ResolutionFacts;
50
+ }
51
+ /**
52
+ * Run the up-front dispatch classifier. Reuses the keystone `classify()`
53
+ * fact-set + `plan()` precedence table — no second classifier lives here.
54
+ */
55
+ export declare function classifyInitDispatch(projectDir: string, classifySeams?: ClassifySeams): InitDispatchClassification;
56
+ /** The single recommended next-action line for a decision. */
57
+ export declare function initDispatchNextAction(decision: InitDispatchDecision, resolutionPlan: ResolutionPlan): string;
58
+ /** Print the concise state summary + exactly one recommended next action. */
59
+ export declare function printInitDispatchSummary(io: InitDepositIo, decision: InitDispatchDecision, resolutionPlan: ResolutionPlan): void;
60
+ /** Machine-readable payload emitted for `--dry-run`/`--plan` (no execution). */
61
+ export declare function buildInitDispatchDryRunJson(projectDir: string, classification: InitDispatchClassification): Record<string, unknown>;
62
+ /**
63
+ * Injectable delegate seams. Defaults are the real narrow-verb CLIs; tests
64
+ * inject fakes to assert single-sourced delegation without touching the fs.
65
+ */
66
+ export interface InitDispatchSeams {
67
+ readonly classifySeams?: ClassifySeams;
68
+ readonly runScaffold?: (options: RunInitDepositCliOptions) => Promise<number>;
69
+ readonly runRefresh?: (options: RunRefreshDepositCliOptions) => Promise<number>;
70
+ readonly runMigrate?: (options: RunMigrateCliOptions) => number;
71
+ }
72
+ export interface RunInitDispatchCliOptions {
73
+ readonly projectDir: string;
74
+ readonly jsonOut: boolean;
75
+ readonly nonInteractive: boolean;
76
+ /** `--dry-run`/`--plan`: print the classified plan without executing. */
77
+ readonly dryRun: boolean;
78
+ readonly writeOut: (text: string) => void;
79
+ readonly writeErr: (text: string) => void;
80
+ readonly seams?: InitDispatchSeams;
81
+ }
82
+ /**
83
+ * CLI-facing entrypoint for `directive init`. Classifies, prints a state summary
84
+ * + one next action, then either stops (`--dry-run`) or delegates to the narrow
85
+ * verb for the resolved decision. The summary/disclosure use the `printf`
86
+ * channel (stderr under `--json`) so that in JSON mode stdout stays a SINGLE
87
+ * JSON object — the delegate's result, or the dry-run plan.
88
+ */
89
+ export declare function runInitDispatchCli(options: RunInitDispatchCliOptions): Promise<number>;
90
+ //# sourceMappingURL=init-dispatch.d.ts.map
@@ -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,7 +8,9 @@
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";
13
+ import { assertDepositContained } from "../deposit/contain.js";
12
14
  import { copyTree } from "../deposit/copy-tree.js";
13
15
  import { prunePythonArtifactsFromDeposit } from "../deposit/python-free.js";
14
16
  import { resolveInstalledContentRoot } from "../deposit/resolve-content.js";
@@ -16,12 +18,92 @@ import { manifestTagToVersion, parseInstallManifest } from "../doctor/manifest.j
16
18
  import { readCorePackageVersion } from "../engine-version.js";
17
19
  import { resolveLifecycleRoot } from "../layout/resolve.js";
18
20
  import { DEV_FALLBACK } from "../platform/constants.js";
21
+ import { checkLocalEngineIntegrity, classify, ENGINE_PACKAGE, plan, renderGlobalInstall, resolveEngine, } from "../resolution/index.js";
19
22
  import { gitPorcelain } from "../story-ready/git.js";
23
+ import { ensureInitGitignoreLines } from "./gitignore.js";
20
24
  import { depositStagePaths, isInstallerManagedPath, printCommitGuidance } from "./hygiene.js";
21
25
  import { parseInitArgv } from "./init-deposit.js";
22
26
  import { buildLegacyRefusalJson, buildLegacyRefusalMessage, detectLegacyLayout, LEGACY_LAYOUT_REFUSED_EXIT_CODE, LegacyLayoutRefusedError, } from "./legacy-detect.js";
23
27
  import { printMigrateNudgeIfNeeded } from "./migrate.js";
24
28
  import { CANONICAL_INSTALL_ROOT, depositNeutralization, ensureTaskfile, writeAgentsMd, writeInstallManifest, } from "./scaffold.js";
29
+ /** The exact refusal shown when `update` runs against an un-initialized project. */
30
+ export const NOT_INITIALIZED_MESSAGE = "This project is not initialized. Run directive init.";
31
+ /** `update` refused because the project has no install / needs a different verb. */
32
+ export const UPDATE_REFUSED_EXIT_CODE = 1;
33
+ /** A project is initialized when it carries ANY Directive footprint. */
34
+ function isInitialized(facts) {
35
+ return facts.hasDeftCore || facts.hasManagedSection || facts.pinVersion !== null;
36
+ }
37
+ /**
38
+ * Collapse the shared resolution {@link ResolutionPlan} onto the four update
39
+ * states. Pre-cutover wins over everything (migrate before any refresh); a
40
+ * project with no footprint is not-initialized; an initialized project that the
41
+ * spine says can `proceed` is already current; everything else (deposit
42
+ * reconstitution, content behind pin, engine self-heal) is an `updated` refresh.
43
+ */
44
+ export function updateStateFromPlan(facts, resolutionPlan) {
45
+ if (resolutionPlan.mode === "migrate")
46
+ return "migration-required";
47
+ if (!isInitialized(facts))
48
+ return "not-initialized";
49
+ if (resolutionPlan.mode === "proceed")
50
+ return "current";
51
+ return "updated";
52
+ }
53
+ /**
54
+ * Run the up-front four-state classifier (#2266). Reuses the keystone
55
+ * `classify()` fact-set + `plan()` precedence table — no second classifier.
56
+ */
57
+ export function classifyUpdateState(projectDir, classifySeams = {}) {
58
+ const facts = classify(projectDir, classifySeams);
59
+ const resolutionPlan = plan(facts, {});
60
+ return { state: updateStateFromPlan(facts, resolutionPlan), plan: resolutionPlan, facts };
61
+ }
62
+ /**
63
+ * Cheap, non-networked ladder facts for the default self-heal path. The global
64
+ * engine version is whatever `classify()` already probed; the local sandbox
65
+ * engine integrity is a filesystem check. `registryUp`/`globalPrefixWritable`
66
+ * default optimistic so the ladder surfaces the canonical `npm i -g` remediation
67
+ * when no install runner is wired.
68
+ */
69
+ export function buildDefaultLadderFacts(projectDir, facts) {
70
+ const integrity = checkLocalEngineIntegrity(projectDir);
71
+ return {
72
+ pinVersion: facts.pinVersion,
73
+ globalEngineVersion: facts.engineReachable ? facts.engineVersion : null,
74
+ localEngine: integrity.present ? { version: null, integrity } : null,
75
+ registryUp: true,
76
+ globalPrefixWritable: true,
77
+ stagedTarballAvailable: false,
78
+ platform: osPlatform(),
79
+ };
80
+ }
81
+ /**
82
+ * Self-heal a mismatched / unreachable engine by DELEGATING to the keystone
83
+ * global-first ladder (`resolveEngine`, #2264). The refresh's content copy comes
84
+ * from the resolved content package and does not itself require the engine, so
85
+ * this is a best-effort heal that prints the ladder trace and remediation; when
86
+ * a caller wires an install runner, the ladder performs the install with zero
87
+ * manual npm/PATH steps (#2266 a3).
88
+ */
89
+ export function selfHealEngine(projectDir, facts, io, seams = {}) {
90
+ const ladderFacts = seams.ladderFacts ?? buildDefaultLadderFacts(projectDir, facts);
91
+ const resolution = resolveEngine(ladderFacts, {
92
+ installRunner: seams.engineInstallRunner,
93
+ reproject: seams.reproject,
94
+ });
95
+ io.printf(`\n[deft update] engine self-heal (global-first ladder): ${resolution.trace}\n`);
96
+ if (!resolution.selfHealed && !resolution.decision.usable) {
97
+ io.printf(`[deft update] ${resolution.decision.reason}\n`);
98
+ if (resolution.decision.rung === "install-global") {
99
+ const suffix = facts.pinVersion ? `@${facts.pinVersion}` : "";
100
+ const spec = `${ENGINE_PACKAGE}${suffix}`;
101
+ io.printf(` Remediation: ${renderGlobalInstall("npm", spec)}\n`);
102
+ io.printf(` (pnpm: ${renderGlobalInstall("pnpm", spec)})\n`);
103
+ }
104
+ }
105
+ return resolution;
106
+ }
25
107
  function normalizeVersion(version) {
26
108
  return version.trim().replace(/^v/, "");
27
109
  }
@@ -199,10 +281,11 @@ export function buildVersionSkewNotice(engineVersion, contentVersion, previousDe
199
281
  }
200
282
  return null;
201
283
  }
202
- export function buildUpdateSummaryJson(result, options) {
284
+ export function buildUpdateSummaryJson(result, options, updateState) {
203
285
  return {
204
286
  success: true,
205
287
  action: "upgrade",
288
+ ...(updateState ? { update_state: updateState } : {}),
206
289
  version: result.engineVersion,
207
290
  project_dir: result.projectDir,
208
291
  deft_dir: result.deftDir,
@@ -229,10 +312,13 @@ export function buildUpdateSummaryJson(result, options) {
229
312
  agents_md_updated: result.agentsMdUpdated,
230
313
  };
231
314
  }
232
- export function printUpdateComplete(result, io) {
315
+ export function printUpdateComplete(result, io, updateState) {
233
316
  io.printf("\n✓ Deft framework payload refreshed.\n\n");
234
317
  io.printf(` Location : ${result.deftDir}\n`);
235
318
  io.printf(` Content : v${normalizeVersion(result.contentVersion)}\n`);
319
+ if (updateState) {
320
+ io.printf(` State : ${updateState}\n`);
321
+ }
236
322
  io.printf(` AGENTS.md : ${result.agentsMdUpdated ? "updated" : "already current"}\n`);
237
323
  if (result.versionSkewNotice) {
238
324
  io.printf(`\n${result.versionSkewNotice}\n`);
@@ -250,6 +336,10 @@ export async function runRefreshDeposit(args, io, seams = {}) {
250
336
  if (legacy.legacy) {
251
337
  throw new LegacyLayoutRefusedError(legacy);
252
338
  }
339
+ // #2305: refuse a symlink-escaping deposit boundary BEFORE the first copy so a
340
+ // malicious `.deft`/`.deft/core` symlink cannot redirect the refresh outside
341
+ // the resolved project tree. Writes nothing on refusal.
342
+ assertDepositContained(projectDir, deftDir);
253
343
  const resolveContent = seams.resolveContentRoot ?? resolveInstalledContentRoot;
254
344
  const copyContent = seams.copyContent ?? copyTree;
255
345
  const readEngine = seams.readEngineVersion ?? readCorePackageVersion;
@@ -283,6 +373,12 @@ export async function runRefreshDeposit(args, io, seams = {}) {
283
373
  syncBareVersionMarker(projectDir, contentVersion);
284
374
  const agentsMdUpdated = writeAgentsMd(projectDir, deftDir, io);
285
375
  await depositNeutralization(projectDir, io);
376
+ // #2266: non-destructive `.gitignore` upkeep for framework-owned paths. This
377
+ // NEVER un-tracks a committed deposit -- `ensureInitGitignoreLines` only writes
378
+ // `.gitignore` and leaves an already-tracked `.deft/core` tracked. The
379
+ // destructive `git rm --cached .deft/core` un-track is the deliberate
380
+ // `migrate --untrack-core` step (#2269), not `update`.
381
+ ensureInitGitignoreLines(projectDir, io, { gitLsFiles: seams.gitLsFiles });
286
382
  let taskfileWired = false;
287
383
  if (args.nonInteractive) {
288
384
  taskfileWired = ensureTaskfile(projectDir, io);
@@ -309,6 +405,59 @@ export async function runRefreshDeposit(args, io, seams = {}) {
309
405
  stagedPaths,
310
406
  };
311
407
  }
408
+ function buildRefusalJson(state, projectDir, message, command) {
409
+ return {
410
+ success: false,
411
+ action: "update",
412
+ update_state: state,
413
+ project_dir: projectDir,
414
+ message,
415
+ next_action: { command },
416
+ };
417
+ }
418
+ /** Emit the `not-initialized` refusal: STOP, hint `init`, never write a partial install. */
419
+ function emitNotInitialized(options, io, projectDir) {
420
+ io.printf(`${NOT_INITIALIZED_MESSAGE}\n`);
421
+ if (options.jsonOut) {
422
+ options.writeOut(`${JSON.stringify(buildRefusalJson("not-initialized", projectDir, NOT_INITIALIZED_MESSAGE, "directive init"), null, 2)}\n`);
423
+ }
424
+ return UPDATE_REFUSED_EXIT_CODE;
425
+ }
426
+ /** Emit the `migration-required` refusal: `update` is not enough; point at init/migrate. */
427
+ function emitMigrationRequired(options, io, projectDir, classification) {
428
+ const remediation = classification.plan.nextAction.remediation;
429
+ const message = `directive update: this project requires migration before it can be refreshed. ${remediation}`;
430
+ io.printf(`${message}\n`);
431
+ if (options.jsonOut) {
432
+ options.writeOut(`${JSON.stringify(buildRefusalJson("migration-required", projectDir, message, classification.plan.nextAction.command ?? "directive init"), null, 2)}\n`);
433
+ }
434
+ return UPDATE_REFUSED_EXIT_CODE;
435
+ }
436
+ /** Emit the classified plan for `--dry-run`/`--plan` without executing the refresh. */
437
+ function emitDryRunPlan(options, io, projectDir, classification) {
438
+ const { state, plan: resolutionPlan } = classification;
439
+ io.printf(`\n[deft update] dry-run -- classified plan (no changes written):\n`);
440
+ io.printf(` State : ${state}\n`);
441
+ io.printf(` Mode : ${resolutionPlan.mode}\n`);
442
+ io.printf(` Root cause : ${resolutionPlan.nextAction.rootCause}\n`);
443
+ io.printf(` Remediation : ${resolutionPlan.nextAction.remediation}\n`);
444
+ for (const warning of resolutionPlan.warnings) {
445
+ io.printf(` Warning : ${warning}\n`);
446
+ }
447
+ if (options.jsonOut) {
448
+ options.writeOut(`${JSON.stringify({
449
+ success: true,
450
+ action: "update",
451
+ dry_run: true,
452
+ update_state: state,
453
+ mode: resolutionPlan.mode,
454
+ project_dir: projectDir,
455
+ next_action: resolutionPlan.nextAction,
456
+ warnings: resolutionPlan.warnings,
457
+ }, null, 2)}\n`);
458
+ }
459
+ return 0;
460
+ }
312
461
  /** CLI-facing wrapper: runs refresh, emits JSON or wizard UX, returns exit code. */
313
462
  export async function runRefreshDepositCli(options) {
314
463
  const io = {
@@ -321,14 +470,42 @@ export async function runRefreshDepositCli(options) {
321
470
  }
322
471
  },
323
472
  };
473
+ // #2266: classify up front (BEFORE any copy) and gate on the four states.
474
+ // Legacy layouts keep their existing refusal path (thrown by runRefreshDeposit
475
+ // and handled below), so classification is skipped for them.
476
+ const projectDir = resolve(options.projectDir);
477
+ const detectLegacy = options.seams?.detectLegacy ?? detectLegacyLayout;
478
+ let classification = null;
479
+ if (!detectLegacy(projectDir).legacy) {
480
+ classification = classifyUpdateState(projectDir, options.classifySeams ?? {});
481
+ if (classification.state === "not-initialized") {
482
+ return emitNotInitialized(options, io, projectDir);
483
+ }
484
+ if (classification.state === "migration-required") {
485
+ return emitMigrationRequired(options, io, projectDir, classification);
486
+ }
487
+ if (options.dryRun) {
488
+ return emitDryRunPlan(options, io, projectDir, classification);
489
+ }
490
+ // #2266 a3: self-heal a mismatched / unreachable engine via the keystone
491
+ // global-first ladder before the refresh proceeds.
492
+ if (!classification.facts.engineReachable) {
493
+ selfHealEngine(projectDir, classification.facts, io, {
494
+ ladderFacts: options.ladderFacts,
495
+ engineInstallRunner: options.engineInstallRunner,
496
+ reproject: options.reproject,
497
+ });
498
+ }
499
+ }
324
500
  try {
325
501
  const result = await runRefreshDeposit(options, io, options.seams);
502
+ const state = classification?.state;
326
503
  if (options.jsonOut) {
327
- options.writeOut(`${JSON.stringify(buildUpdateSummaryJson(result, options), null, 2)}\n`);
328
- printUpdateComplete(result, { printf: options.writeErr });
504
+ options.writeOut(`${JSON.stringify(buildUpdateSummaryJson(result, options, state), null, 2)}\n`);
505
+ printUpdateComplete(result, { printf: options.writeErr }, state);
329
506
  }
330
507
  else {
331
- printUpdateComplete(result, io);
508
+ printUpdateComplete(result, io, state);
332
509
  }
333
510
  return 0;
334
511
  }
@@ -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;