@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.
- package/dist/doctor/constants.d.ts +9 -0
- package/dist/doctor/constants.js +11 -0
- package/dist/doctor/main.d.ts +47 -1
- package/dist/doctor/main.js +352 -2
- package/dist/doctor/payload-staleness.d.ts +8 -0
- package/dist/doctor/payload-staleness.js +11 -8
- package/dist/doctor/types.d.ts +48 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/init-deposit/gitignore.d.ts +18 -0
- package/dist/init-deposit/gitignore.js +94 -15
- package/dist/init-deposit/headless-manifest.d.ts +87 -0
- package/dist/init-deposit/headless-manifest.js +261 -0
- package/dist/init-deposit/index.d.ts +3 -0
- package/dist/init-deposit/index.js +3 -0
- package/dist/init-deposit/init-dispatch.d.ts +90 -0
- package/dist/init-deposit/init-dispatch.js +170 -0
- package/dist/init-deposit/refresh.d.ts +79 -2
- package/dist/init-deposit/refresh.js +177 -5
- package/dist/init-deposit/scaffold.d.ts +25 -0
- package/dist/init-deposit/scaffold.js +57 -0
- package/dist/init-deposit/untrack-core.d.ts +84 -0
- package/dist/init-deposit/untrack-core.js +150 -0
- package/dist/resolution/classify.d.ts +41 -0
- package/dist/resolution/classify.js +138 -0
- package/dist/resolution/engine-ladder.d.ts +98 -0
- package/dist/resolution/engine-ladder.js +185 -0
- package/dist/resolution/index.d.ts +19 -0
- package/dist/resolution/index.js +19 -0
- package/dist/resolution/integrity.d.ts +48 -0
- package/dist/resolution/integrity.js +80 -0
- package/dist/resolution/package-manager.d.ts +77 -0
- package/dist/resolution/package-manager.js +103 -0
- package/dist/resolution/pin.d.ts +73 -0
- package/dist/resolution/pin.js +169 -0
- package/dist/resolution/plan.d.ts +66 -0
- package/dist/resolution/plan.js +219 -0
- package/dist/resolution/skew-policy.d.ts +46 -0
- package/dist/resolution/skew-policy.js +120 -0
- package/dist/session/session-start.d.ts +2 -0
- package/dist/session/session-start.js +28 -1
- package/dist/user-config/index.d.ts +2 -0
- package/dist/user-config/index.js +2 -0
- package/dist/user-config/resolve-user-md.d.ts +76 -0
- package/dist/user-config/resolve-user-md.js +120 -0
- package/dist/xbrief-migrate/constants.d.ts +10 -0
- package/dist/xbrief-migrate/constants.js +19 -0
- package/dist/xbrief-migrate/detect.d.ts +34 -0
- package/dist/xbrief-migrate/detect.js +33 -0
- package/dist/xbrief-migrate/fs-helpers.d.ts +13 -0
- package/dist/xbrief-migrate/fs-helpers.js +46 -1
- package/dist/xbrief-migrate/index.d.ts +4 -2
- package/dist/xbrief-migrate/index.js +4 -2
- package/dist/xbrief-migrate/migrate-project.d.ts +23 -1
- package/dist/xbrief-migrate/migrate-project.js +97 -8
- package/dist/xbrief-migrate/signpost.d.ts +7 -1
- package/dist/xbrief-migrate/signpost.js +18 -3
- package/package.json +7 -3
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version pin + reconciliation for the resolution spine (#2264, absorbs #2199).
|
|
3
|
+
*
|
|
4
|
+
* The committed `package.json` devDependency on `@deftai/directive` (exact,
|
|
5
|
+
* `"private": true`) is the CANONICAL pin — read before directive runs, npm-native
|
|
6
|
+
* so the engine ladder stays plain npm. This module reads that pin and reconciles
|
|
7
|
+
* it against the reachable engine version, the `.deft/core/VERSION` content marker,
|
|
8
|
+
* and the AGENTS.md managed-section `sha=`.
|
|
9
|
+
*
|
|
10
|
+
* Semver helpers here are the single comparison primitive reused by the skew
|
|
11
|
+
* policy and the engine ladder so version ordering is defined in exactly one place.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
/** The canonical committed dependency the pin lives on. */
|
|
16
|
+
export const PIN_DEPENDENCY_NAME = "@deftai/directive";
|
|
17
|
+
const BARE_SEMVER_RE = /^\d+\.\d+\.\d+([-+][0-9A-Za-z.-]+)?$/;
|
|
18
|
+
const EXACT_SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.-]+)?$/;
|
|
19
|
+
/** Parse a semver prefix (`major.minor.patch`); null when not exact semver. */
|
|
20
|
+
export function parseSemver(version) {
|
|
21
|
+
if (typeof version !== "string")
|
|
22
|
+
return null;
|
|
23
|
+
const normalized = version.trim().replace(/^v/i, "");
|
|
24
|
+
const match = EXACT_SEMVER_RE.exec(normalized);
|
|
25
|
+
if (!match)
|
|
26
|
+
return null;
|
|
27
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
28
|
+
}
|
|
29
|
+
/** True when `version` is an EXACT pin (no range operators like `^` / `~` / `*`). */
|
|
30
|
+
export function isExactPin(version) {
|
|
31
|
+
if (typeof version !== "string")
|
|
32
|
+
return false;
|
|
33
|
+
return BARE_SEMVER_RE.test(version.trim().replace(/^v/i, ""));
|
|
34
|
+
}
|
|
35
|
+
/** Compare two versions: -1 (a<b), 0 (a==b), 1 (a>b); null when either is unparseable. */
|
|
36
|
+
export function compareSemver(a, b) {
|
|
37
|
+
const pa = parseSemver(a);
|
|
38
|
+
const pb = parseSemver(b);
|
|
39
|
+
if (pa === null || pb === null)
|
|
40
|
+
return null;
|
|
41
|
+
for (let i = 0; i < 3; i += 1) {
|
|
42
|
+
const av = pa[i] ?? 0;
|
|
43
|
+
const bv = pb[i] ?? 0;
|
|
44
|
+
if (av > bv)
|
|
45
|
+
return 1;
|
|
46
|
+
if (av < bv)
|
|
47
|
+
return -1;
|
|
48
|
+
}
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
/** True when `a >= b` under semver ordering; false when either is unparseable. */
|
|
52
|
+
export function semverGte(a, b) {
|
|
53
|
+
const cmp = compareSemver(a, b);
|
|
54
|
+
return cmp === 0 || cmp === 1;
|
|
55
|
+
}
|
|
56
|
+
function defaultIsFile(p) {
|
|
57
|
+
try {
|
|
58
|
+
return existsSync(p);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function defaultReadText(p) {
|
|
65
|
+
try {
|
|
66
|
+
return readFileSync(p, "utf8");
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function readDependencySpec(pkg) {
|
|
73
|
+
for (const block of ["devDependencies", "dependencies"]) {
|
|
74
|
+
const deps = pkg[block];
|
|
75
|
+
if (typeof deps === "object" && deps !== null && !Array.isArray(deps)) {
|
|
76
|
+
const spec = deps[PIN_DEPENDENCY_NAME];
|
|
77
|
+
if (typeof spec === "string" && spec.trim().length > 0) {
|
|
78
|
+
return spec.trim();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Read the committed `package.json` pin on `@deftai/directive`. The pin is
|
|
86
|
+
* canonical only when it is an EXACT version; a range spec is reported via
|
|
87
|
+
* `nonExact` so callers can warn rather than silently trusting a floating range.
|
|
88
|
+
*/
|
|
89
|
+
export function readPin(projectRoot, seams = {}) {
|
|
90
|
+
const isFile = seams.isFile ?? defaultIsFile;
|
|
91
|
+
const readText = seams.readText ?? defaultReadText;
|
|
92
|
+
const absent = {
|
|
93
|
+
pinVersion: null,
|
|
94
|
+
rawSpec: null,
|
|
95
|
+
isPrivate: false,
|
|
96
|
+
nonExact: false,
|
|
97
|
+
};
|
|
98
|
+
const path = join(projectRoot, "package.json");
|
|
99
|
+
if (!isFile(path))
|
|
100
|
+
return absent;
|
|
101
|
+
const text = readText(path);
|
|
102
|
+
if (text === null)
|
|
103
|
+
return absent;
|
|
104
|
+
let pkg;
|
|
105
|
+
try {
|
|
106
|
+
const parsed = JSON.parse(text);
|
|
107
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
108
|
+
return absent;
|
|
109
|
+
pkg = parsed;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return absent;
|
|
113
|
+
}
|
|
114
|
+
const isPrivate = pkg.private === true;
|
|
115
|
+
const rawSpec = readDependencySpec(pkg);
|
|
116
|
+
if (rawSpec === null)
|
|
117
|
+
return { pinVersion: null, rawSpec: null, isPrivate, nonExact: false };
|
|
118
|
+
if (!isExactPin(rawSpec)) {
|
|
119
|
+
return { pinVersion: null, rawSpec, isPrivate, nonExact: true };
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
pinVersion: rawSpec.replace(/^v/i, ""),
|
|
123
|
+
rawSpec,
|
|
124
|
+
isPrivate,
|
|
125
|
+
nonExact: false,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Reconcile the pin against the engine, the deposited content marker, and the
|
|
130
|
+
* AGENTS managed-section sha. The managed-section `sha=` is part of the
|
|
131
|
+
* reconciliation, not just `VERSION` (per the #2264 acceptance).
|
|
132
|
+
*/
|
|
133
|
+
export function reconcileVersions(inputs) {
|
|
134
|
+
const { pinVersion, engineVersion, contentVersion, managedSectionSha } = inputs;
|
|
135
|
+
const mismatches = [];
|
|
136
|
+
const contentCmp = compareSemver(contentVersion, pinVersion);
|
|
137
|
+
const contentBehindPin = contentCmp === -1;
|
|
138
|
+
if (contentBehindPin) {
|
|
139
|
+
mismatches.push(`.deft/core/VERSION (${contentVersion}) is behind the package.json pin (${pinVersion}); content forward-migration required`);
|
|
140
|
+
}
|
|
141
|
+
else if (contentCmp === 1) {
|
|
142
|
+
mismatches.push(`.deft/core/VERSION (${contentVersion}) is ahead of the package.json pin (${pinVersion})`);
|
|
143
|
+
}
|
|
144
|
+
if (engineVersion !== null && pinVersion !== null) {
|
|
145
|
+
const engineCmp = compareSemver(engineVersion, pinVersion);
|
|
146
|
+
if (engineCmp === -1) {
|
|
147
|
+
mismatches.push(`engine (${engineVersion}) is behind the package.json pin (${pinVersion})`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
let managedShaMismatch = false;
|
|
151
|
+
const expected = inputs.expectedManagedSectionSha;
|
|
152
|
+
if (typeof expected === "string" && expected.length > 0) {
|
|
153
|
+
if (managedSectionSha === null) {
|
|
154
|
+
managedShaMismatch = true;
|
|
155
|
+
mismatches.push("AGENTS.md managed-section sha is absent but an expected sha is known");
|
|
156
|
+
}
|
|
157
|
+
else if (managedSectionSha !== expected) {
|
|
158
|
+
managedShaMismatch = true;
|
|
159
|
+
mismatches.push(`AGENTS.md managed-section sha (${managedSectionSha}) does not match expected (${expected})`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
consistent: mismatches.length === 0,
|
|
164
|
+
contentBehindPin,
|
|
165
|
+
managedShaMismatch,
|
|
166
|
+
mismatches,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
//# sourceMappingURL=pin.js.map
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single ordered precedence table for the resolution spine (#2264 / epic #2203).
|
|
3
|
+
*
|
|
4
|
+
* `plan()` takes the orthogonal fact-set from `classify()` and collapses it into
|
|
5
|
+
* exactly ONE recommended action, emitting the versioned public
|
|
6
|
+
* {@link ResolutionPlan} schema. This is the single source of truth every
|
|
7
|
+
* consumption context (init / update / doctor / headless) derives from — there
|
|
8
|
+
* is no second classifier downstream (closes the #537 split-source drift risk).
|
|
9
|
+
*
|
|
10
|
+
* SCOPE (keystone): this delivers the decision spine. It does NOT rewire the
|
|
11
|
+
* user-facing behavior of init / update / doctor / headless (children B–E,
|
|
12
|
+
* #2265–#2268), which consume `plan()`. The `files` array is therefore empty in
|
|
13
|
+
* the spine; downstream children populate it.
|
|
14
|
+
*/
|
|
15
|
+
import type { PlanPolicy, ResolutionFacts, ResolutionFile, ResolutionPlan } from "@deftai/directive-types";
|
|
16
|
+
import type { LadderDecision } from "./engine-ladder.js";
|
|
17
|
+
import { type PackageManager } from "./package-manager.js";
|
|
18
|
+
import { type SkewResult } from "./skew-policy.js";
|
|
19
|
+
export interface PlanOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Pre-computed engine ladder decision, supplied when the global engine is
|
|
22
|
+
* unreachable and the caller resolved it via `decideEngineLadder`/`resolveEngine`
|
|
23
|
+
* (warm sandbox / cold sandbox / registry-down). When omitted, `plan()` uses the
|
|
24
|
+
* in-process reachable engine facts.
|
|
25
|
+
*/
|
|
26
|
+
readonly engineResolution?: LadderDecision | null;
|
|
27
|
+
/** Pre-computed skew result; when omitted `plan()` computes it from facts + policy. */
|
|
28
|
+
readonly skew?: SkewResult | null;
|
|
29
|
+
/** Interactive session (a human can answer a skew prompt). */
|
|
30
|
+
readonly interactive?: boolean;
|
|
31
|
+
/** Environment map for the `DEFT_ACCEPT_ENGINE_SKEW` escape hatch. */
|
|
32
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
33
|
+
/** The `--accept-engine-jump` flag was supplied. */
|
|
34
|
+
readonly acceptEngineJump?: boolean;
|
|
35
|
+
/** Platform id, used only to render the sandbox install command. */
|
|
36
|
+
readonly platform?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Files a downstream consumer has already materialised for the plan to carry
|
|
39
|
+
* in {@link ResolutionPlan.files}. The keystone spine leaves `files` empty;
|
|
40
|
+
* children (the #2268 headless emitter) collect the file set out-of-band and
|
|
41
|
+
* thread it here so the emitted manifest is single-sourced from `plan()`
|
|
42
|
+
* rather than a separate "what would init produce" reimplementation. When
|
|
43
|
+
* omitted, `files` stays empty exactly as before.
|
|
44
|
+
*/
|
|
45
|
+
readonly files?: readonly ResolutionFile[];
|
|
46
|
+
/**
|
|
47
|
+
* Active package manager for rendering install/upgrade command strings
|
|
48
|
+
* (#2197). Defaults to npm so existing behaviour and tests are unchanged;
|
|
49
|
+
* callers detect the manager via `detectPackageManager()` and thread it here.
|
|
50
|
+
* The sandbox (`install-sandbox`) rung stays on npm regardless of this value
|
|
51
|
+
* -- the `.deft/.cli/<platform>` layout is npm-shaped and package-manager
|
|
52
|
+
* invisible (locked non-goal, issue #2197).
|
|
53
|
+
*/
|
|
54
|
+
readonly packageManager?: PackageManager;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The single ordered precedence table. First matching row wins; each `plan()`
|
|
58
|
+
* call returns exactly one recommended action.
|
|
59
|
+
*
|
|
60
|
+
* When `options.files` is supplied, the resolved plan carries that file set in
|
|
61
|
+
* {@link ResolutionPlan.files} (the #2268 headless single-sourcing seam); the
|
|
62
|
+
* precedence decision itself is unchanged. With no `files` option the array
|
|
63
|
+
* stays empty exactly as the keystone spine emitted it.
|
|
64
|
+
*/
|
|
65
|
+
export declare function plan(facts: ResolutionFacts, policy?: PlanPolicy, options?: PlanOptions): ResolutionPlan;
|
|
66
|
+
//# sourceMappingURL=plan.d.ts.map
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single ordered precedence table for the resolution spine (#2264 / epic #2203).
|
|
3
|
+
*
|
|
4
|
+
* `plan()` takes the orthogonal fact-set from `classify()` and collapses it into
|
|
5
|
+
* exactly ONE recommended action, emitting the versioned public
|
|
6
|
+
* {@link ResolutionPlan} schema. This is the single source of truth every
|
|
7
|
+
* consumption context (init / update / doctor / headless) derives from — there
|
|
8
|
+
* is no second classifier downstream (closes the #537 split-source drift risk).
|
|
9
|
+
*
|
|
10
|
+
* SCOPE (keystone): this delivers the decision spine. It does NOT rewire the
|
|
11
|
+
* user-facing behavior of init / update / doctor / headless (children B–E,
|
|
12
|
+
* #2265–#2268), which consume `plan()`. The `files` array is therefore empty in
|
|
13
|
+
* the spine; downstream children populate it.
|
|
14
|
+
*/
|
|
15
|
+
import { RESOLUTION_PLAN_SCHEMA_VERSION } from "@deftai/directive-types";
|
|
16
|
+
import { ENGINE_PACKAGE, renderEphemeral, renderGlobalInstall, } from "./package-manager.js";
|
|
17
|
+
import { reconcileVersions } from "./pin.js";
|
|
18
|
+
import { evaluateSkew } from "./skew-policy.js";
|
|
19
|
+
const RUNG_TO_MODE = {
|
|
20
|
+
global: "proceed",
|
|
21
|
+
local: "proceed",
|
|
22
|
+
"install-global": "install-global",
|
|
23
|
+
"install-sandbox": "install-sandbox",
|
|
24
|
+
"install-staged": "install-staged",
|
|
25
|
+
"hard-fail": "blocked",
|
|
26
|
+
};
|
|
27
|
+
function pinSuffix(pinVersion) {
|
|
28
|
+
return pinVersion ? `@${pinVersion}` : "@<pin>";
|
|
29
|
+
}
|
|
30
|
+
function makePlan(mode, nextAction, warnings) {
|
|
31
|
+
return {
|
|
32
|
+
schemaVersion: RESOLUTION_PLAN_SCHEMA_VERSION,
|
|
33
|
+
mode,
|
|
34
|
+
files: [],
|
|
35
|
+
nextAction,
|
|
36
|
+
warnings,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function legacyVbriefWarning(facts) {
|
|
40
|
+
if (facts.hasVbrief && !facts.hasXbrief) {
|
|
41
|
+
return "legacy vbrief/ tree present without xbrief/; run `directive migrate:xbrief`";
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
/** Resolve the effective engine version from either the ladder decision or in-process facts. */
|
|
46
|
+
function effectiveEngineVersion(facts, engineResolution) {
|
|
47
|
+
if (engineResolution?.usable)
|
|
48
|
+
return engineResolution.resolvedVersion;
|
|
49
|
+
if (facts.engineReachable)
|
|
50
|
+
return facts.engineVersion;
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
function contentStale(facts) {
|
|
54
|
+
const recon = reconcileVersions({
|
|
55
|
+
pinVersion: facts.pinVersion,
|
|
56
|
+
engineVersion: facts.engineVersion,
|
|
57
|
+
contentVersion: facts.deftCorePayloadVersion,
|
|
58
|
+
managedSectionSha: facts.managedSectionSha,
|
|
59
|
+
});
|
|
60
|
+
if (recon.contentBehindPin)
|
|
61
|
+
return true;
|
|
62
|
+
// A deposit that carries no readable managed-section sha is treated as stale.
|
|
63
|
+
if (facts.hasManagedSection && facts.managedSectionSha === null)
|
|
64
|
+
return true;
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The single ordered precedence table. First matching row wins; each `plan()`
|
|
69
|
+
* call returns exactly one recommended action.
|
|
70
|
+
*
|
|
71
|
+
* When `options.files` is supplied, the resolved plan carries that file set in
|
|
72
|
+
* {@link ResolutionPlan.files} (the #2268 headless single-sourcing seam); the
|
|
73
|
+
* precedence decision itself is unchanged. With no `files` option the array
|
|
74
|
+
* stays empty exactly as the keystone spine emitted it.
|
|
75
|
+
*/
|
|
76
|
+
export function plan(facts, policy = {}, options = {}) {
|
|
77
|
+
const resolved = resolvePlan(facts, policy, options);
|
|
78
|
+
const files = options.files ?? [];
|
|
79
|
+
return files.length === 0 ? resolved : { ...resolved, files };
|
|
80
|
+
}
|
|
81
|
+
function resolvePlan(facts, policy, options) {
|
|
82
|
+
const warnings = [];
|
|
83
|
+
const pm = options.packageManager ?? "npm";
|
|
84
|
+
const legacyWarning = legacyVbriefWarning(facts);
|
|
85
|
+
if (legacyWarning)
|
|
86
|
+
warnings.push(legacyWarning);
|
|
87
|
+
// Row 1: pre-cutover artifacts must migrate before anything else.
|
|
88
|
+
if (facts.preCutoverArtifacts) {
|
|
89
|
+
return makePlan("migrate", {
|
|
90
|
+
command: null,
|
|
91
|
+
rootCause: "pre-v0.20 document-model artifacts detected",
|
|
92
|
+
remediation: "Migrate with the frozen pre-v0.20 bridge before any gate runs. See UPGRADING.md § Frozen pre-v0.20 document-model migration (#2068).",
|
|
93
|
+
}, warnings);
|
|
94
|
+
}
|
|
95
|
+
// Row 2: no usable deposit — deposit / reconstitute one.
|
|
96
|
+
if (!facts.hasDeftCore) {
|
|
97
|
+
const rootCause = facts.hasManagedSection
|
|
98
|
+
? "AGENTS.md carries a managed section but the .deft/core/ payload is absent (hybrid deposit not reconstituted)"
|
|
99
|
+
: facts.hasAppCode || facts.hasGit
|
|
100
|
+
? "brownfield project without a Deft deposit"
|
|
101
|
+
: "greenfield project without a Deft deposit";
|
|
102
|
+
return makePlan("init", {
|
|
103
|
+
command: renderEphemeral(pm, "init"),
|
|
104
|
+
rootCause,
|
|
105
|
+
remediation: "Deposit (or reconstitute) the .deft/core/ payload with `directive init`.",
|
|
106
|
+
}, warnings);
|
|
107
|
+
}
|
|
108
|
+
// Row 3+: deposit present — resolve the engine dimension.
|
|
109
|
+
const engineResolution = options.engineResolution ?? null;
|
|
110
|
+
// 3a: a supplied ladder decision that requires an install (global engine
|
|
111
|
+
// unreachable path: warm/cold sandbox, registry-down).
|
|
112
|
+
if (engineResolution && !engineResolution.usable) {
|
|
113
|
+
const mode = RUNG_TO_MODE[engineResolution.rung];
|
|
114
|
+
return planForInstallRung(engineResolution, mode, facts, options, warnings);
|
|
115
|
+
}
|
|
116
|
+
const effectiveEngine = effectiveEngineVersion(facts, engineResolution);
|
|
117
|
+
// 3b: no engine reachable and no ladder resolution supplied — cannot resolve.
|
|
118
|
+
if (effectiveEngine === null) {
|
|
119
|
+
return makePlan("blocked", {
|
|
120
|
+
command: null,
|
|
121
|
+
rootCause: "no Directive engine is reachable in the execution environment",
|
|
122
|
+
remediation: "Resolve the engine via the global-first ladder (resolveEngine) / bootstrap before running any gate.",
|
|
123
|
+
}, warnings);
|
|
124
|
+
}
|
|
125
|
+
// 3c: no committed pin — cannot reconcile; proceed but warn.
|
|
126
|
+
if (facts.pinVersion === null) {
|
|
127
|
+
warnings.push("no committed package.json pin on @deftai/directive; skipping engine/pin reconciliation");
|
|
128
|
+
return makePlan("proceed", {
|
|
129
|
+
command: null,
|
|
130
|
+
rootCause: `engine ${effectiveEngine} reachable; no pin to reconcile against`,
|
|
131
|
+
remediation: "Run the requested gate. Consider committing an exact pin (unblocks #2269).",
|
|
132
|
+
}, warnings);
|
|
133
|
+
}
|
|
134
|
+
// 3d: reconcile engine against the pin via the three-band skew policy.
|
|
135
|
+
const skew = options.skew ??
|
|
136
|
+
evaluateSkew(effectiveEngine, facts.pinVersion, {
|
|
137
|
+
engineSkewWindow: policy.engineSkewWindow,
|
|
138
|
+
acceptEngineJump: options.acceptEngineJump,
|
|
139
|
+
interactive: options.interactive,
|
|
140
|
+
env: options.env,
|
|
141
|
+
});
|
|
142
|
+
if (skew.message)
|
|
143
|
+
warnings.push(skew.message);
|
|
144
|
+
switch (skew.decision) {
|
|
145
|
+
case "reject-global":
|
|
146
|
+
return makePlan("install-global", {
|
|
147
|
+
command: renderGlobalInstall(pm, `${ENGINE_PACKAGE}${pinSuffix(facts.pinVersion)}`),
|
|
148
|
+
rootCause: `engine ${effectiveEngine} is behind pin ${facts.pinVersion}`,
|
|
149
|
+
remediation: "Install the pinned engine (or fall through the ladder to a local install).",
|
|
150
|
+
}, warnings);
|
|
151
|
+
case "fail-closed":
|
|
152
|
+
return makePlan("blocked", {
|
|
153
|
+
command: `directive <gate> --accept-engine-jump`,
|
|
154
|
+
rootCause: `engine ${effectiveEngine} is a large jump ahead of pin ${facts.pinVersion}`,
|
|
155
|
+
remediation: "Confirm the jump with --accept-engine-jump or DEFT_ACCEPT_ENGINE_SKEW=1 after reviewing the delta.",
|
|
156
|
+
}, warnings);
|
|
157
|
+
case "prompt":
|
|
158
|
+
return makePlan("blocked", {
|
|
159
|
+
command: null,
|
|
160
|
+
rootCause: `engine ${effectiveEngine} is a large jump ahead of pin ${facts.pinVersion} (interactive)`,
|
|
161
|
+
remediation: "Prompt the operator to confirm the engine jump, or pass --accept-engine-jump.",
|
|
162
|
+
}, warnings);
|
|
163
|
+
default: {
|
|
164
|
+
// proceed-silent | proceed-loud-update
|
|
165
|
+
if (skew.requiresUpdateFirst || contentStale(facts)) {
|
|
166
|
+
return makePlan("update", {
|
|
167
|
+
command: renderEphemeral(pm, "update"),
|
|
168
|
+
rootCause: skew.requiresUpdateFirst
|
|
169
|
+
? `engine ${effectiveEngine} is ahead of pin ${facts.pinVersion} within the skew window`
|
|
170
|
+
: `deposited content is behind pin ${facts.pinVersion}`,
|
|
171
|
+
remediation: "Forward-migrate content with `directive update`, then run the gate.",
|
|
172
|
+
}, warnings);
|
|
173
|
+
}
|
|
174
|
+
return makePlan("proceed", {
|
|
175
|
+
command: null,
|
|
176
|
+
rootCause: `engine ${effectiveEngine} matches pin ${facts.pinVersion} and content is current`,
|
|
177
|
+
remediation: "Run the requested gate.",
|
|
178
|
+
}, warnings);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function planForInstallRung(engineResolution, mode, facts, options, warnings) {
|
|
183
|
+
const platform = options.platform ?? "<platform>";
|
|
184
|
+
const pm = options.packageManager ?? "npm";
|
|
185
|
+
const pinnedSuffix = pinSuffix(facts.pinVersion);
|
|
186
|
+
const withTrace = [...warnings, `ladder: ${engineResolution.trace}`];
|
|
187
|
+
switch (mode) {
|
|
188
|
+
case "install-global":
|
|
189
|
+
return makePlan("install-global", {
|
|
190
|
+
command: renderGlobalInstall(pm, `${ENGINE_PACKAGE}${pinnedSuffix}`),
|
|
191
|
+
rootCause: engineResolution.reason,
|
|
192
|
+
remediation: pm === "pnpm"
|
|
193
|
+
? "Install the pinned engine into the pnpm global bin (ensure PNPM_HOME is on PATH; run `pnpm setup` if needed)."
|
|
194
|
+
: "Install the pinned engine into the global npm prefix.",
|
|
195
|
+
}, withTrace);
|
|
196
|
+
case "install-sandbox":
|
|
197
|
+
// Locked non-goal (#2197): the .deft/.cli/<platform> sandbox stays on npm
|
|
198
|
+
// regardless of the caller's package manager -- its node_modules/.bin
|
|
199
|
+
// layout is npm-shaped and validated by integrity.ts.
|
|
200
|
+
return makePlan("install-sandbox", {
|
|
201
|
+
command: `npm install --prefix .deft/.cli/${platform} @deftai/directive${pinnedSuffix}`,
|
|
202
|
+
rootCause: engineResolution.reason,
|
|
203
|
+
remediation: "Install the pinned engine into the sandbox-local .deft/.cli/<platform> prefix.",
|
|
204
|
+
}, withTrace);
|
|
205
|
+
case "install-staged":
|
|
206
|
+
return makePlan("install-staged", {
|
|
207
|
+
command: null,
|
|
208
|
+
rootCause: engineResolution.reason,
|
|
209
|
+
remediation: "Install from the pre-staged tarball / vendored payload (registry is down).",
|
|
210
|
+
}, withTrace);
|
|
211
|
+
default:
|
|
212
|
+
return makePlan("blocked", {
|
|
213
|
+
command: null,
|
|
214
|
+
rootCause: engineResolution.reason,
|
|
215
|
+
remediation: "Stage a Directive payload (registry down and no staged tarball).",
|
|
216
|
+
}, withTrace);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
//# sourceMappingURL=plan.js.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Three-band engine-vs-pin skew policy for the resolution spine (#2264, #2199).
|
|
3
|
+
*
|
|
4
|
+
* Content is always forward-migrated (`update`) before any gate runs; this
|
|
5
|
+
* policy governs whether a globally-reachable engine that differs from the pin
|
|
6
|
+
* may be used, and how loudly:
|
|
7
|
+
*
|
|
8
|
+
* - `engine == pin` -> proceed silently (trace only).
|
|
9
|
+
* - `engine > pin` within the skew window -> proceed, emit a loud delta, `update` first.
|
|
10
|
+
* - `engine > pin` beyond the window -> fail closed non-interactively (require
|
|
11
|
+
* `--accept-engine-jump`; prompt when interactive).
|
|
12
|
+
* `DEFT_ACCEPT_ENGINE_SKEW=1` is the CI escape.
|
|
13
|
+
* - `engine < pin` -> reject the global, fall through the ladder.
|
|
14
|
+
*/
|
|
15
|
+
/** Default skew window pre-1.0, measured in minor versions. */
|
|
16
|
+
export declare const DEFAULT_ENGINE_SKEW_WINDOW = 3;
|
|
17
|
+
/** Environment variable that acts as the CI / non-interactive escape hatch. */
|
|
18
|
+
export declare const ACCEPT_ENGINE_SKEW_ENV = "DEFT_ACCEPT_ENGINE_SKEW";
|
|
19
|
+
export type SkewBand = "match" | "within-window" | "beyond-window" | "engine-behind" | "unknown";
|
|
20
|
+
export type SkewDecision = "proceed-silent" | "proceed-loud-update" | "prompt" | "fail-closed" | "reject-global";
|
|
21
|
+
export interface SkewOptions {
|
|
22
|
+
/** Skew window (minor versions pre-1.0; ignored post-1.0 where "same major" applies). */
|
|
23
|
+
readonly engineSkewWindow?: number | null;
|
|
24
|
+
/** The `--accept-engine-jump` flag was supplied. */
|
|
25
|
+
readonly acceptEngineJump?: boolean;
|
|
26
|
+
/** Whether the session is interactive (a human can answer a prompt). */
|
|
27
|
+
readonly interactive?: boolean;
|
|
28
|
+
/** Environment map for the `DEFT_ACCEPT_ENGINE_SKEW` escape hatch. */
|
|
29
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
30
|
+
}
|
|
31
|
+
export interface SkewResult {
|
|
32
|
+
readonly band: SkewBand;
|
|
33
|
+
readonly decision: SkewDecision;
|
|
34
|
+
/** Loud delta / fail-closed message, or null when silent. */
|
|
35
|
+
readonly message: string | null;
|
|
36
|
+
/** The recommended flow must run `update` before proceeding. */
|
|
37
|
+
readonly requiresUpdateFirst: boolean;
|
|
38
|
+
/** An escape hatch (flag or env) was used to permit a beyond-window jump. */
|
|
39
|
+
readonly escapeHatchUsed: boolean;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Evaluate the three-band skew policy for a reachable engine against the pin.
|
|
43
|
+
* Unparseable versions fail closed (a version we cannot order is not safe to run).
|
|
44
|
+
*/
|
|
45
|
+
export declare function evaluateSkew(engineVersion: string | null, pinVersion: string | null, opts?: SkewOptions): SkewResult;
|
|
46
|
+
//# sourceMappingURL=skew-policy.d.ts.map
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Three-band engine-vs-pin skew policy for the resolution spine (#2264, #2199).
|
|
3
|
+
*
|
|
4
|
+
* Content is always forward-migrated (`update`) before any gate runs; this
|
|
5
|
+
* policy governs whether a globally-reachable engine that differs from the pin
|
|
6
|
+
* may be used, and how loudly:
|
|
7
|
+
*
|
|
8
|
+
* - `engine == pin` -> proceed silently (trace only).
|
|
9
|
+
* - `engine > pin` within the skew window -> proceed, emit a loud delta, `update` first.
|
|
10
|
+
* - `engine > pin` beyond the window -> fail closed non-interactively (require
|
|
11
|
+
* `--accept-engine-jump`; prompt when interactive).
|
|
12
|
+
* `DEFT_ACCEPT_ENGINE_SKEW=1` is the CI escape.
|
|
13
|
+
* - `engine < pin` -> reject the global, fall through the ladder.
|
|
14
|
+
*/
|
|
15
|
+
import { compareSemver, parseSemver } from "./pin.js";
|
|
16
|
+
/** Default skew window pre-1.0, measured in minor versions. */
|
|
17
|
+
export const DEFAULT_ENGINE_SKEW_WINDOW = 3;
|
|
18
|
+
/** Environment variable that acts as the CI / non-interactive escape hatch. */
|
|
19
|
+
export const ACCEPT_ENGINE_SKEW_ENV = "DEFT_ACCEPT_ENGINE_SKEW";
|
|
20
|
+
function resolveWindow(opts) {
|
|
21
|
+
const raw = opts.engineSkewWindow;
|
|
22
|
+
if (typeof raw === "number" && Number.isInteger(raw) && raw >= 0)
|
|
23
|
+
return raw;
|
|
24
|
+
return DEFAULT_ENGINE_SKEW_WINDOW;
|
|
25
|
+
}
|
|
26
|
+
function escapeHatchActive(opts) {
|
|
27
|
+
if (opts.acceptEngineJump === true)
|
|
28
|
+
return true;
|
|
29
|
+
const env = opts.env ?? process.env;
|
|
30
|
+
return env[ACCEPT_ENGINE_SKEW_ENV] === "1";
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Classify the `engine > pin` skew band. Pre-1.0 (pin major 0) the window is
|
|
34
|
+
* measured in minors; post-1.0 the window collapses to "same major".
|
|
35
|
+
*/
|
|
36
|
+
function classifyAheadBand(engine, pin, window) {
|
|
37
|
+
const [engineMajor, engineMinor] = engine;
|
|
38
|
+
const [pinMajor, pinMinor] = pin;
|
|
39
|
+
if (pinMajor === 0) {
|
|
40
|
+
if (engineMajor > 0)
|
|
41
|
+
return "beyond-window";
|
|
42
|
+
return engineMinor - pinMinor <= window ? "within-window" : "beyond-window";
|
|
43
|
+
}
|
|
44
|
+
return engineMajor === pinMajor ? "within-window" : "beyond-window";
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Evaluate the three-band skew policy for a reachable engine against the pin.
|
|
48
|
+
* Unparseable versions fail closed (a version we cannot order is not safe to run).
|
|
49
|
+
*/
|
|
50
|
+
export function evaluateSkew(engineVersion, pinVersion, opts = {}) {
|
|
51
|
+
const engine = parseSemver(engineVersion);
|
|
52
|
+
const pin = parseSemver(pinVersion);
|
|
53
|
+
if (engine === null || pin === null) {
|
|
54
|
+
return {
|
|
55
|
+
band: "unknown",
|
|
56
|
+
decision: "fail-closed",
|
|
57
|
+
message: `cannot order engine (${engineVersion ?? "unknown"}) against pin (${pinVersion ?? "unknown"}); refusing to run an unorderable engine`,
|
|
58
|
+
requiresUpdateFirst: false,
|
|
59
|
+
escapeHatchUsed: false,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const cmp = compareSemver(engineVersion, pinVersion);
|
|
63
|
+
if (cmp === 0) {
|
|
64
|
+
return {
|
|
65
|
+
band: "match",
|
|
66
|
+
decision: "proceed-silent",
|
|
67
|
+
message: null,
|
|
68
|
+
requiresUpdateFirst: false,
|
|
69
|
+
escapeHatchUsed: false,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (cmp === -1) {
|
|
73
|
+
return {
|
|
74
|
+
band: "engine-behind",
|
|
75
|
+
decision: "reject-global",
|
|
76
|
+
message: `engine ${engineVersion} is behind pin ${pinVersion}; rejecting global engine and falling through the ladder`,
|
|
77
|
+
requiresUpdateFirst: false,
|
|
78
|
+
escapeHatchUsed: false,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// engine > pin
|
|
82
|
+
const window = resolveWindow(opts);
|
|
83
|
+
const band = classifyAheadBand(engine, pin, window);
|
|
84
|
+
if (band === "within-window") {
|
|
85
|
+
return {
|
|
86
|
+
band,
|
|
87
|
+
decision: "proceed-loud-update",
|
|
88
|
+
message: `engine ${engineVersion} is ahead of pin ${pinVersion} within the skew window; proceeding after content update`,
|
|
89
|
+
requiresUpdateFirst: true,
|
|
90
|
+
escapeHatchUsed: false,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
// beyond-window
|
|
94
|
+
if (escapeHatchActive(opts)) {
|
|
95
|
+
return {
|
|
96
|
+
band,
|
|
97
|
+
decision: "proceed-loud-update",
|
|
98
|
+
message: `engine ${engineVersion} is a large jump ahead of pin ${pinVersion}; accepted via escape hatch (--accept-engine-jump / ${ACCEPT_ENGINE_SKEW_ENV}=1)`,
|
|
99
|
+
requiresUpdateFirst: true,
|
|
100
|
+
escapeHatchUsed: true,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (opts.interactive === true) {
|
|
104
|
+
return {
|
|
105
|
+
band,
|
|
106
|
+
decision: "prompt",
|
|
107
|
+
message: `engine ${engineVersion} is a large jump ahead of pin ${pinVersion}; prompt the operator to confirm (or pass --accept-engine-jump)`,
|
|
108
|
+
requiresUpdateFirst: true,
|
|
109
|
+
escapeHatchUsed: false,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
band,
|
|
114
|
+
decision: "fail-closed",
|
|
115
|
+
message: `engine ${engineVersion} is a large jump ahead of pin ${pinVersion}; failing closed. Re-run with --accept-engine-jump or ${ACCEPT_ENGINE_SKEW_ENV}=1`,
|
|
116
|
+
requiresUpdateFirst: false,
|
|
117
|
+
escapeHatchUsed: false,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
//# sourceMappingURL=skew-policy.js.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ResolveUserMdResult } from "../user-config/resolve-user-md.js";
|
|
1
2
|
import type { GitRunner } from "./git.js";
|
|
2
3
|
import { ritualStatePath } from "./ritual-sentinel.js";
|
|
3
4
|
export declare const QUICK_STEPS: readonly ["alignment", "branch_policy", "triage_welcome"];
|
|
@@ -30,6 +31,7 @@ export interface SessionStartOptions {
|
|
|
30
31
|
readonly verifyTools?: (output: (line: string) => void) => {
|
|
31
32
|
exitCode: number;
|
|
32
33
|
};
|
|
34
|
+
readonly resolveUserMd?: (projectRoot: string) => ResolveUserMdResult;
|
|
33
35
|
}
|
|
34
36
|
export declare function parseDeferrals(rawValues: readonly string[]): {
|
|
35
37
|
deferrals: Record<string, string>;
|