@deftai/directive-core 0.56.1 → 0.57.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.
@@ -63,6 +63,10 @@ const SKIP_DIRS = new Set([
63
63
  "dist",
64
64
  "tests",
65
65
  ".deft-cache",
66
+ // node_modules holds third-party markdown (dependency READMEs) that is not
67
+ // framework content; scanning it produced volatile, version-pinned cases that
68
+ // broke CI when dependency versions drifted (#1993 follow-up).
69
+ "node_modules",
66
70
  ]);
67
71
  const MIGRATION_ARTIFACT_TREES = ["vbrief/migration"];
68
72
  export function isMigrationArtifact(rel) {
@@ -14,6 +14,11 @@ export declare function checkInstallPathConsistency(projectRoot: string, install
14
14
  * `skip` for the canonical / greenfield layout (nothing to signpost).
15
15
  */
16
16
  export declare function checkLegacyLayout(projectRoot: string, seams?: CheckSeams): CheckResult;
17
+ /**
18
+ * #1997: signpost a canonical-vendored `.deft/core/` deposit that has not yet
19
+ * been stamped npm-managed. Local-only (no network).
20
+ */
21
+ export declare function checkCanonicalVendoredNpmSignpost(projectRoot: string, seams?: CheckSeams): CheckResult;
17
22
  export declare function deriveExitCode(checks: readonly CheckResult[], errors: readonly string[]): number;
18
23
  export declare function runChecksImpl(projectRoot: string, seams?: CheckSeams & {
19
24
  isDir?: (p: string) => boolean;
@@ -1,8 +1,9 @@
1
1
  import { join } from "node:path";
2
2
  import { detectLegacyLayout, legacyLayoutSignpostLine, } from "../init-deposit/legacy-detect.js";
3
+ import { detectCanonicalVendoredManifest, isNpmManaged, NPM_MANAGED_SENTINEL_KEY, NPM_MANAGED_SENTINEL_VALUE, } from "../init-deposit/migrate.js";
3
4
  import { findSkillPathsInText } from "../text/redos-safe.js";
4
- import { GO_BRIDGE_RELEASES_URL, UPGRADING_DOC_URL } from "./constants.js";
5
- import { isDeprecationRedirectStub, locateManifest, manifestCandidatePaths, manifestTagToVersion, parseInstallRootFromAgentsMd, parseManifest, } from "./manifest.js";
5
+ import { CANONICAL_UPGRADE_COMMAND, GO_BRIDGE_RELEASES_URL, UPGRADING_DOC_URL, } from "./constants.js";
6
+ import { isDeprecationRedirectStub, locateManifest, manifestCandidatePaths, manifestTagToVersion, parseInstallManifest, parseInstallRootFromAgentsMd, parseManifest, } from "./manifest.js";
6
7
  import { readTextSafe } from "./paths.js";
7
8
  function readText(path, seams) {
8
9
  return (seams.readText ?? readTextSafe)(path);
@@ -312,11 +313,63 @@ export function checkLegacyLayout(projectRoot, seams = {}) {
312
313
  },
313
314
  };
314
315
  }
316
+ /**
317
+ * #1997: signpost a canonical-vendored `.deft/core/` deposit that has not yet
318
+ * been stamped npm-managed. Local-only (no network).
319
+ */
320
+ export function checkCanonicalVendoredNpmSignpost(projectRoot, seams = {}) {
321
+ const readText = seams.readText ?? readTextSafe;
322
+ const isFile = seams.isFile ?? ((p) => readText(p) !== null);
323
+ const manifestPath = detectCanonicalVendoredManifest(projectRoot, isFile);
324
+ if (manifestPath === null) {
325
+ return {
326
+ name: "canonical-vendored-npm-signpost",
327
+ status: "skip",
328
+ detail: "No canonical-vendored .deft/core/ deposit (nothing to signpost).",
329
+ data: { canonical_vendored: false },
330
+ };
331
+ }
332
+ const text = readText(manifestPath);
333
+ if (text === null) {
334
+ return {
335
+ name: "canonical-vendored-npm-signpost",
336
+ status: "skip",
337
+ detail: "Canonical-vendored manifest unreadable.",
338
+ data: { canonical_vendored: true },
339
+ };
340
+ }
341
+ const manifest = parseInstallManifest(text);
342
+ if (isNpmManaged(manifest)) {
343
+ return {
344
+ name: "canonical-vendored-npm-signpost",
345
+ status: "skip",
346
+ detail: "Deposit is already npm-managed (hybrid).",
347
+ data: { canonical_vendored: true, npm_managed: true },
348
+ };
349
+ }
350
+ const detail = "Canonical-vendored install (.deft/core/) is not yet npm-managed. " +
351
+ "Post-freeze upgrades run via npm: install the engine with " +
352
+ `\`${CANONICAL_UPGRADE_COMMAND}\`, then run \`directive migrate\` ` +
353
+ `to stamp provenance. See ${UPGRADING_DOC_URL}.`;
354
+ return {
355
+ name: "canonical-vendored-npm-signpost",
356
+ status: "fail",
357
+ detail,
358
+ data: {
359
+ canonical_vendored: true,
360
+ npm_managed: false,
361
+ manifest_path: manifestPath,
362
+ sentinel_key: NPM_MANAGED_SENTINEL_KEY,
363
+ sentinel_value: NPM_MANAGED_SENTINEL_VALUE,
364
+ upgrading_doc_url: UPGRADING_DOC_URL,
365
+ },
366
+ };
367
+ }
315
368
  export function deriveExitCode(checks, errors) {
316
369
  if (errors.length > 0 || checks.some((c) => c.status === "error")) {
317
370
  return 2;
318
371
  }
319
- if (checks.some((c) => c.status === "fail")) {
372
+ if (checks.some((c) => c.status === "fail" && c.name !== "canonical-vendored-npm-signpost")) {
320
373
  return 1;
321
374
  }
322
375
  return 0;
@@ -349,6 +402,7 @@ export function runChecksImpl(projectRoot, seams = {}) {
349
402
  });
350
403
  checks.push(checkManifestAgreement(projectRoot, null, seams));
351
404
  checks.push(checkLegacyLayout(projectRoot, seams));
405
+ checks.push(checkCanonicalVendoredNpmSignpost(projectRoot, seams));
352
406
  return {
353
407
  projectRoot,
354
408
  installRoot: null,
@@ -362,6 +416,7 @@ export function runChecksImpl(projectRoot, seams = {}) {
362
416
  checks.push(checkManifestAgreement(projectRoot, installRoot, seams));
363
417
  checks.push(checkInstallPathConsistency(projectRoot, installRoot, seams));
364
418
  checks.push(checkLegacyLayout(projectRoot, seams));
419
+ checks.push(checkCanonicalVendoredNpmSignpost(projectRoot, seams));
365
420
  return {
366
421
  projectRoot,
367
422
  installRoot,
@@ -10,7 +10,9 @@ export declare const DOCTOR_ALLOWED_FLAGS: readonly ["--session", "--fix", "--re
10
10
  export declare const EXPECTED_FRAMEWORK_DIRS: readonly ["tasks", "scripts", "vbrief"];
11
11
  export declare const DEFT_REPO_POSITIVE_MARKERS: readonly ["content/templates/agents-entry.md", "content/skills/deft-directive-build/SKILL.md"];
12
12
  export declare const EXPECTED_CONTENT_DIRS: readonly ["languages", "strategies", "skills", "templates"];
13
- export declare const CANONICAL_UPGRADE_COMMAND = "deft-install --yes --upgrade --repo-root . --json";
13
+ /** Post-freeze canonical upgrade path (#1997 / #2003 / #1912). */
14
+ export declare const CANONICAL_UPGRADE_COMMAND = "npm i -g @deftai/directive@latest";
15
+ export declare const NPM_PACKAGE_NAME = "@deftai/directive";
14
16
  export declare const CLEAN_WINDOW_HOURS = 24;
15
17
  export declare const DIRTY_WINDOW_HOURS = 4;
16
18
  export declare const ENV_STATE_PATH = "DEFT_DOCTOR_STATE_PATH";
@@ -41,7 +41,9 @@ export const DEFT_REPO_POSITIVE_MARKERS = [
41
41
  // same check works for a source checkout (content/<dir>) and a flattened
42
42
  // consumer deposit (<dir>).
43
43
  export const EXPECTED_CONTENT_DIRS = ["languages", "strategies", "skills", "templates"];
44
- export const CANONICAL_UPGRADE_COMMAND = "deft-install --yes --upgrade --repo-root . --json";
44
+ /** Post-freeze canonical upgrade path (#1997 / #2003 / #1912). */
45
+ export const CANONICAL_UPGRADE_COMMAND = "npm i -g @deftai/directive@latest";
46
+ export const NPM_PACKAGE_NAME = "@deftai/directive";
45
47
  export const CLEAN_WINDOW_HOURS = 24;
46
48
  export const DIRTY_WINDOW_HOURS = 4;
47
49
  export const ENV_STATE_PATH = "DEFT_DOCTOR_STATE_PATH";
@@ -10,6 +10,7 @@ import { pythonJsonDump } from "./json.js";
10
10
  import { createPlainSink } from "./output.js";
11
11
  import { resolveDefaultFrameworkRoot, resolvePath, resolveVersion, runningInsideDeftRepo, } from "./paths.js";
12
12
  import { runPayloadStalenessCheck } from "./payload-staleness.js";
13
+ import { runLocalSignpostChecks } from "./signpost-checks.js";
13
14
  import { classifyTaskfileInclude, formatMissingIncludeSnippet, resolveConsumerTaskfile, } from "./taskfile.js";
14
15
  import { defaultWhich } from "./which.js";
15
16
  export function cmdDoctor(args, seams = {}) {
@@ -33,6 +34,11 @@ export function cmdDoctor(args, seams = {}) {
33
34
  const state = (seams.readState ?? readState)(projectRoot);
34
35
  const decision = decideThrottle(state, nowFn());
35
36
  if (decision.skip) {
37
+ const throttleFindings = [];
38
+ const throttleSink = createPlainSink({ jsonMode, quietMode });
39
+ runLocalSignpostChecks(projectRoot, throttleSink, (finding) => {
40
+ throttleFindings.push(finding);
41
+ }, seams);
36
42
  const hint = decision.dirty
37
43
  ? "run `deft doctor --full` to re-probe or address findings"
38
44
  : "--full forces";
@@ -45,12 +51,17 @@ export function cmdDoctor(args, seams = {}) {
45
51
  last_error_count: decision.lastErrorCount,
46
52
  next_eligible_at: formatIsoZ(decision.nextEligibleAt),
47
53
  hint,
54
+ ...(throttleFindings.length > 0 ? { signpost_findings: throttleFindings } : {}),
48
55
  };
49
56
  process.stdout.write(`${pythonJsonDump(payload)}\n`);
50
57
  }
51
58
  else {
52
59
  process.stdout.write(`${renderDoctorStatusLine(decision, nowFn())}\n`);
53
60
  }
61
+ const signpostWarnings = throttleFindings.filter((f) => f.severity === "warning").length;
62
+ if (signpostWarnings > 0 && !jsonMode) {
63
+ throttleSink.finalWarn(`Signpost advisory: ${signpostWarnings} local layout / npm-migration note(s) above (throttle-skipped full probe).`);
64
+ }
54
65
  return decision.dirty ? 1 : 0;
55
66
  }
56
67
  }
@@ -239,6 +250,19 @@ function runInstallIntegrityChecks(projectRoot, sink, addFinding, seams) {
239
250
  sink.info(`${name}: skip -- ${detail}`);
240
251
  continue;
241
252
  }
253
+ if ((name === "legacy-layout" || name === "canonical-vendored-npm-signpost") &&
254
+ status === "fail") {
255
+ sink.warn(`${name}: ${detail}`);
256
+ addFinding({
257
+ severity: "warning",
258
+ message: detail || `${name} ${status}`,
259
+ check: `install-integrity:${name}`,
260
+ install_check: name,
261
+ status,
262
+ data: entry.data ?? {},
263
+ });
264
+ continue;
265
+ }
242
266
  if (status === "error") {
243
267
  sink.error(`${name}: error -- ${detail}`);
244
268
  }
@@ -8,6 +8,10 @@ export interface PayloadStalenessSeams {
8
8
  ok: boolean;
9
9
  stdout: string;
10
10
  };
11
+ readonly runNpmViewVersion?: () => {
12
+ ok: boolean;
13
+ version: string;
14
+ };
11
15
  }
12
16
  export declare function runPayloadStalenessCheck(projectRoot: string, sink: OutputSink, addFinding: (finding: Finding) => void, seams?: PayloadStalenessSeams): void;
13
17
  //# sourceMappingURL=payload-staleness.d.ts.map
@@ -1,13 +1,13 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { dirname, join } from "node:path";
3
- import { CANONICAL_UPGRADE_COMMAND } from "./constants.js";
3
+ import { CANONICAL_UPGRADE_COMMAND, NPM_PACKAGE_NAME } from "./constants.js";
4
4
  import { locateManifest, parseInstallManifest } from "./manifest.js";
5
5
  import { readTextSafe, resolveDefaultFrameworkRoot } from "./paths.js";
6
6
  function isDeftFrameworkRepo(projectRoot, readText = readTextSafe) {
7
7
  try {
8
8
  const agents = join(projectRoot, "AGENTS.md");
9
9
  const text = readText(agents);
10
- return text !== null && text.includes("Deft — Development Framework (deft repo)");
10
+ return text?.includes("Deft — Development Framework (deft repo)") ?? false;
11
11
  }
12
12
  catch {
13
13
  return false;
@@ -37,6 +37,74 @@ function parseRemoteSha(stdout) {
37
37
  const firstLine = stdout.split("\n").find((ln) => ln.trim()) ?? "";
38
38
  return firstLine.trim().split(/\s+/)[0] ?? "";
39
39
  }
40
+ function parseSemver(version) {
41
+ const normalized = version.trim().replace(/^v/i, "");
42
+ const parts = [];
43
+ for (const segment of normalized.split(".")) {
44
+ const numeric = Number.parseInt(segment.split("-")[0] ?? "", 10);
45
+ if (Number.isNaN(numeric)) {
46
+ break;
47
+ }
48
+ parts.push(numeric);
49
+ }
50
+ return parts.length > 0 ? parts : [0];
51
+ }
52
+ function semverLessThan(left, right) {
53
+ const a = parseSemver(left);
54
+ const b = parseSemver(right);
55
+ const len = Math.max(a.length, b.length);
56
+ for (let i = 0; i < len; i += 1) {
57
+ const av = a[i] ?? 0;
58
+ const bv = b[i] ?? 0;
59
+ if (av < bv) {
60
+ return true;
61
+ }
62
+ if (av > bv) {
63
+ return false;
64
+ }
65
+ }
66
+ return false;
67
+ }
68
+ function manifestVersion(ref, tag) {
69
+ const candidate = (tag || ref).trim().replace(/^refs\/tags\//, "");
70
+ const normalized = candidate.replace(/^v/i, "");
71
+ if (!/^\d+(?:\.\d+)*/.test(normalized)) {
72
+ return "";
73
+ }
74
+ return normalized;
75
+ }
76
+ function defaultNpmViewVersion() {
77
+ const proc = spawnSync("npm", ["view", NPM_PACKAGE_NAME, "version"], {
78
+ encoding: "utf8",
79
+ timeout: 15_000,
80
+ });
81
+ const version = (proc.stdout ?? "").trim().split("\n")[0]?.trim() ?? "";
82
+ return { ok: proc.status === 0 && version.length > 0, version };
83
+ }
84
+ function emitUnverified(checkName, reason, sink, addFinding) {
85
+ const msg = `payload currency UNVERIFIED — ${reason}`;
86
+ sink.warn(msg);
87
+ addFinding({
88
+ severity: "warning",
89
+ message: msg,
90
+ check: checkName,
91
+ status: "unverified",
92
+ });
93
+ }
94
+ function emitStale(checkName, installedLabel, remoteLabel, ref, sink, addFinding, extras = {}, behindWord = "remote") {
95
+ const msg = `Framework payload is stale (installed ${installedLabel} behind ${behindWord} ${remoteLabel} for ref '${ref}'). ` +
96
+ `Recommendation: run \`${CANONICAL_UPGRADE_COMMAND}\` from any shell with Node ≥ 20.`;
97
+ sink.warn(msg);
98
+ addFinding({
99
+ severity: "warning",
100
+ message: msg,
101
+ check: checkName,
102
+ status: "stale",
103
+ ref,
104
+ suggestion: CANONICAL_UPGRADE_COMMAND,
105
+ ...extras,
106
+ });
107
+ }
40
108
  export function runPayloadStalenessCheck(projectRoot, sink, addFinding, seams = {}) {
41
109
  const checkName = "payload-staleness";
42
110
  const readText = seams.readText ?? readTextSafe;
@@ -48,6 +116,7 @@ export function runPayloadStalenessCheck(projectRoot, sink, addFinding, seams =
48
116
  message: "inside framework repo (no install manifest)",
49
117
  check: checkName,
50
118
  status: "skip",
119
+ reason: "not-applicable",
51
120
  });
52
121
  return;
53
122
  }
@@ -64,7 +133,13 @@ export function runPayloadStalenessCheck(projectRoot, sink, addFinding, seams =
64
133
  }
65
134
  if (manifestPath === null || !isFile(manifestPath)) {
66
135
  sink.info(`${checkName}: skip -- no install manifest found (pre-v0.28 or legacy state)`);
67
- addFinding({ severity: "skip", message: "no manifest", check: checkName, status: "skip" });
136
+ addFinding({
137
+ severity: "skip",
138
+ message: "no manifest",
139
+ check: checkName,
140
+ status: "skip",
141
+ reason: "not-applicable",
142
+ });
68
143
  return;
69
144
  }
70
145
  const text = readText(manifestPath);
@@ -75,12 +150,14 @@ export function runPayloadStalenessCheck(projectRoot, sink, addFinding, seams =
75
150
  message: "manifest unreadable",
76
151
  check: checkName,
77
152
  status: "skip",
153
+ reason: "not-applicable",
78
154
  });
79
155
  return;
80
156
  }
81
157
  const manifest = parseInstallManifest(text);
82
158
  const installedSha = (manifest.sha ?? "").trim();
83
159
  const ref = (manifest.ref ?? manifest.tag ?? "").trim();
160
+ const tag = (manifest.tag ?? "").trim();
84
161
  if (!installedSha) {
85
162
  sink.info(`${checkName}: skip -- manifest has no sha (incomplete provenance)`);
86
163
  addFinding({
@@ -88,6 +165,7 @@ export function runPayloadStalenessCheck(projectRoot, sink, addFinding, seams =
88
165
  message: "no sha in manifest",
89
166
  check: checkName,
90
167
  status: "skip",
168
+ reason: "not-applicable",
91
169
  });
92
170
  return;
93
171
  }
@@ -98,6 +176,7 @@ export function runPayloadStalenessCheck(projectRoot, sink, addFinding, seams =
98
176
  message: "no ref/tag in manifest",
99
177
  check: checkName,
100
178
  status: "skip",
179
+ reason: "not-applicable",
101
180
  });
102
181
  return;
103
182
  }
@@ -110,56 +189,47 @@ export function runPayloadStalenessCheck(projectRoot, sink, addFinding, seams =
110
189
  });
111
190
  return { ok: proc.status === 0, stdout: proc.stdout ?? "" };
112
191
  });
192
+ const runNpmView = seams.runNpmViewVersion ?? defaultNpmViewVersion;
113
193
  let remoteResult;
114
194
  try {
115
195
  remoteResult = runLsRemote(deftDir, ref);
116
196
  }
117
197
  catch {
118
- sink.info(`${checkName}: skip -- could not probe remote (Error)`);
119
- addFinding({
120
- severity: "skip",
121
- message: "remote probe failed",
122
- check: checkName,
123
- status: "skip",
124
- });
125
- return;
126
- }
127
- if (!remoteResult.ok) {
128
- sink.info(`${checkName}: skip -- git ls-remote failed (no network or no origin)`);
129
- addFinding({
130
- severity: "skip",
131
- message: "ls-remote unavailable",
132
- check: checkName,
133
- status: "skip",
134
- });
135
- return;
136
- }
137
- const remoteSha = parseRemoteSha(remoteResult.stdout);
138
- if (!remoteSha) {
139
- sink.info(`${checkName}: skip -- ls-remote produced no sha`);
140
- addFinding({
141
- severity: "skip",
142
- message: "no remote sha",
143
- check: checkName,
144
- status: "skip",
145
- });
146
- return;
198
+ remoteResult = { ok: false, stdout: "" };
199
+ }
200
+ if (remoteResult.ok) {
201
+ const remoteSha = parseRemoteSha(remoteResult.stdout);
202
+ if (remoteSha) {
203
+ if (installedSha === remoteSha) {
204
+ sink.info(`${checkName}: current (sha matches remote)`);
205
+ return;
206
+ }
207
+ emitStale(checkName, `sha ${installedSha.slice(0, 8)}...`, `sha ${remoteSha.slice(0, 8)}...`, ref, sink, addFinding, { installed_sha: installedSha, remote_sha: remoteSha, resolver: "git-ls-remote" });
208
+ return;
209
+ }
147
210
  }
148
- if (installedSha === remoteSha) {
149
- sink.info(`${checkName}: current (sha matches remote)`);
211
+ const npmResult = runNpmView();
212
+ const installedVersion = manifestVersion(ref, tag);
213
+ if (npmResult.ok && installedVersion) {
214
+ if (semverLessThan(installedVersion, npmResult.version)) {
215
+ emitStale(checkName, `v${installedVersion}`, `v${npmResult.version}`, ref, sink, addFinding, {
216
+ installed_version: installedVersion,
217
+ remote_version: npmResult.version,
218
+ resolver: "npm-view",
219
+ }, "npm registry");
220
+ return;
221
+ }
222
+ if (installedVersion === npmResult.version.replace(/^v/i, "")) {
223
+ sink.info(`${checkName}: current (version matches npm registry)`);
224
+ return;
225
+ }
226
+ sink.info(`${checkName}: current (installed version >= npm registry)`);
150
227
  return;
151
228
  }
152
- const msg = `Framework payload is stale (installed sha ${installedSha.slice(0, 8)}... behind remote ${remoteSha.slice(0, 8)}... for ref '${ref}'). Recommendation: run the canonical headless upgrader \`${CANONICAL_UPGRADE_COMMAND}\` from your project root to pull the latest payload (drop \`--json\` for human-readable output). On an installer binary predating the headless flags, download the latest deft-install from GitHub Releases first.`;
153
- sink.warn(msg);
154
- addFinding({
155
- severity: "warning",
156
- message: msg,
157
- check: checkName,
158
- status: "stale",
159
- installed_sha: installedSha,
160
- remote_sha: remoteSha,
161
- ref,
162
- suggestion: CANONICAL_UPGRADE_COMMAND,
163
- });
229
+ const reason = remoteResult.ok
230
+ ? "ls-remote produced no sha and npm registry fallback unavailable"
231
+ : "could not reach remote (git ls-remote / npm view both unavailable)";
232
+ sink.info(`${checkName}: skip -- ${reason}`);
233
+ emitUnverified(checkName, reason, sink, addFinding);
164
234
  }
165
235
  //# sourceMappingURL=payload-staleness.js.map
@@ -0,0 +1,9 @@
1
+ import { type CheckSeams } from "./checks.js";
2
+ import type { OutputSink } from "./output.js";
3
+ import type { Finding } from "./types.js";
4
+ export interface LocalSignpostSeams extends CheckSeams {
5
+ readonly runningInsideDeftRepo?: (root: string) => boolean;
6
+ }
7
+ /** Lightweight, local-only signpost probes for the throttle-skip path (#1997). */
8
+ export declare function runLocalSignpostChecks(projectRoot: string, sink: OutputSink, addFinding: (finding: Finding) => void, seams?: LocalSignpostSeams): void;
9
+ //# sourceMappingURL=signpost-checks.d.ts.map
@@ -0,0 +1,28 @@
1
+ import { checkCanonicalVendoredNpmSignpost, checkLegacyLayout } from "./checks.js";
2
+ import { runningInsideDeftRepo } from "./paths.js";
3
+ /** Lightweight, local-only signpost probes for the throttle-skip path (#1997). */
4
+ export function runLocalSignpostChecks(projectRoot, sink, addFinding, seams = {}) {
5
+ const insideDeft = seams.runningInsideDeftRepo?.(projectRoot) ?? runningInsideDeftRepo(projectRoot, seams);
6
+ if (insideDeft) {
7
+ return;
8
+ }
9
+ for (const result of [
10
+ checkLegacyLayout(projectRoot, seams),
11
+ checkCanonicalVendoredNpmSignpost(projectRoot, seams),
12
+ ]) {
13
+ if (result.status === "skip") {
14
+ continue;
15
+ }
16
+ if (result.status === "fail") {
17
+ sink.warn(result.detail);
18
+ addFinding({
19
+ severity: "warning",
20
+ message: result.detail,
21
+ check: result.name,
22
+ status: result.status,
23
+ data: result.data ?? {},
24
+ });
25
+ }
26
+ }
27
+ }
28
+ //# sourceMappingURL=signpost-checks.js.map
@@ -148,6 +148,32 @@ function metaFetchedAt(metaPath) {
148
148
  return null;
149
149
  }
150
150
  }
151
+ /**
152
+ * #1991: report whether a cache entry's upstream issue is terminal-closed.
153
+ *
154
+ * Reads the sibling raw.json (the cached issue payload) and returns true only
155
+ * when its `state` is positively "closed". Unknown / missing state returns
156
+ * false so we never exclude an entry we are not sure about. Closed issues are
157
+ * terminal: their `fetched_at` age is irrelevant to dispatch-queue freshness,
158
+ * and `cache:fetch-all --force` (open-only enumeration) can never refresh them,
159
+ * so the age gate must not wedge on them.
160
+ */
161
+ function metaEntryIsTerminalClosed(metaPath) {
162
+ const rawPath = join(metaPath, "..", "raw.json");
163
+ if (!existsSync(rawPath))
164
+ return false;
165
+ try {
166
+ const raw = JSON.parse(readFileSync(rawPath, "utf8"));
167
+ if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
168
+ const state = raw.state;
169
+ return typeof state === "string" && state.toLowerCase() === "closed";
170
+ }
171
+ }
172
+ catch {
173
+ /* unknown state -> keep in scope */
174
+ }
175
+ return false;
176
+ }
151
177
  function loadScopeRules(projectRoot) {
152
178
  const defPath = join(projectRoot, "vbrief", "PROJECT-DEFINITION.vbrief.json");
153
179
  if (!existsSync(defPath))
@@ -398,10 +424,16 @@ export function evaluate(projectRoot, options = {}) {
398
424
  allMetaPaths.length > 0 &&
399
425
  scopedMetaPaths.length === 0 &&
400
426
  candState === "populated";
401
- // Step 5: Oldest in-scope entry age + drift probe (#1886)
427
+ // Step 5: Oldest in-scope entry age + drift probe (#1886).
428
+ // #1991: judge age over OPEN entries only. Terminal-closed entries are
429
+ // excluded because `cache:fetch-all --force` (open-only enumeration) can never
430
+ // refresh their `fetched_at`, so including them would let a stale closed entry
431
+ // wedge the gate with no working recovery command.
432
+ const rawCandidatePaths = scopedMetaPaths.length > 0 ? scopedMetaPaths : allMetaPaths;
433
+ const ageCandidatePaths = rawCandidatePaths.filter((p) => !metaEntryIsTerminalClosed(p));
402
434
  let minFetchedAt = null;
403
435
  let minMetaPath = null;
404
- for (const p of scopedMetaPaths.length > 0 ? scopedMetaPaths : allMetaPaths) {
436
+ for (const p of ageCandidatePaths) {
405
437
  const ts = metaFetchedAt(p);
406
438
  if (ts !== null && (minFetchedAt === null || ts < minFetchedAt)) {
407
439
  minFetchedAt = ts;
@@ -411,7 +443,11 @@ export function evaluate(projectRoot, options = {}) {
411
443
  const now = nowFn();
412
444
  const ageMs = minFetchedAt !== null ? now.getTime() - minFetchedAt.getTime() : Infinity;
413
445
  const ageH = ageMs / (1000 * 3600);
414
- const stale = ageH > maxAgeHours;
446
+ // When every candidate is terminal-closed (non-empty input, no open entry to
447
+ // judge) there is nothing open whose age can be stale -> fresh. The truly
448
+ // empty-cache case (no entries at all) preserves its prior staleness result.
449
+ const allCandidatesClosed = minFetchedAt === null && rawCandidatePaths.length > 0;
450
+ const stale = allCandidatesClosed ? false : ageH > maxAgeHours;
415
451
  // Step 5b: --allow-stale bypass
416
452
  if (stale && allowStale) {
417
453
  const warning = [
@@ -16,6 +16,10 @@ export declare const NPM_E2E_REHEARSAL_TAG = "e2e-rehearsal";
16
16
  export declare const NPM_INSTALL_TIMEOUT_SECONDS = 600;
17
17
  export declare const NPM_BUILD_TIMEOUT_SECONDS = 600;
18
18
  export declare const NPM_PUBLISH_DRYRUN_TIMEOUT_SECONDS = 180;
19
+ export declare const NPM_PACK_TIMEOUT_SECONDS = 300;
20
+ export declare const NPM_INSTALL_RUN_TIMEOUT_SECONDS = 300;
21
+ /** Fail the install+run smoke when stderr/stdout carries a module-resolution error. */
22
+ export declare const MODULE_NOT_FOUND_MARKERS: readonly ["Cannot find module", "ERR_MODULE_NOT_FOUND", "MODULE_NOT_FOUND"];
19
23
  export declare const RELEASE_ENTRYPOINT_TIMEOUT_SECONDS = 600;
20
24
  export declare const ROLLBACK_ENTRYPOINT_TIMEOUT_SECONDS = 300;
21
25
  export declare const ENTRYPOINT_TIMEOUT_EXIT_CODE = 124;
@@ -16,6 +16,14 @@ export const NPM_E2E_REHEARSAL_TAG = "e2e-rehearsal";
16
16
  export const NPM_INSTALL_TIMEOUT_SECONDS = 600;
17
17
  export const NPM_BUILD_TIMEOUT_SECONDS = 600;
18
18
  export const NPM_PUBLISH_DRYRUN_TIMEOUT_SECONDS = 180;
19
+ export const NPM_PACK_TIMEOUT_SECONDS = 300;
20
+ export const NPM_INSTALL_RUN_TIMEOUT_SECONDS = 300;
21
+ /** Fail the install+run smoke when stderr/stdout carries a module-resolution error. */
22
+ export const MODULE_NOT_FOUND_MARKERS = [
23
+ "Cannot find module",
24
+ "ERR_MODULE_NOT_FOUND",
25
+ "MODULE_NOT_FOUND",
26
+ ];
19
27
  export const RELEASE_ENTRYPOINT_TIMEOUT_SECONDS = 600.0;
20
28
  export const ROLLBACK_ENTRYPOINT_TIMEOUT_SECONDS = 300.0;
21
29
  export const ENTRYPOINT_TIMEOUT_EXIT_CODE = 124;
@@ -28,4 +28,17 @@ export declare function alignNpmPackageVersions(cloneDir: string, version: strin
28
28
  * Returns [ok, reason] like verifyDraftRelease / verifyTag.
29
29
  */
30
30
  export declare function rehearseNpmPublish(cloneDir: string, version: string, seams?: E2ESeams): [boolean, string];
31
+ /**
32
+ * Publish-layout install+run smoke (#1996, #2010): pack the four
33
+ * @deftai/directive* packages, install the tarballs into a clean flat
34
+ * node_modules, then run `directive --version` (exit-0 liveness) and
35
+ * `directive doctor` (deep-import coverage) so import-resolution bugs like
36
+ * #1993 sub-problem 1 surface before a real npm publish. The smoke gates on
37
+ * module-not-found markers, NOT on the doctor's pass/fail verdict -- a full
38
+ * doctor check exits non-zero in a bare consumer layout, which is benign
39
+ * (#2010).
40
+ */
41
+ export declare function rehearseNpmInstallAndRun(cloneDir: string, version: string, seams?: E2ESeams, options?: {
42
+ skipWorkspacePrep?: boolean;
43
+ }): [boolean, string];
31
44
  //# sourceMappingURL=npm-ops.d.ts.map
@@ -1,7 +1,7 @@
1
- import { readFileSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { defaultWhich, spawnText } from "../release/spawn.js";
4
- import { NPM_BUILD_TIMEOUT_SECONDS, NPM_E2E_REHEARSAL_TAG, NPM_INSTALL_TIMEOUT_SECONDS, NPM_PUBLISH_DRYRUN_TIMEOUT_SECONDS, NPM_PUBLISH_PACKAGES, } from "./constants.js";
4
+ import { MODULE_NOT_FOUND_MARKERS, NPM_BUILD_TIMEOUT_SECONDS, NPM_E2E_REHEARSAL_TAG, NPM_INSTALL_RUN_TIMEOUT_SECONDS, NPM_INSTALL_TIMEOUT_SECONDS, NPM_PACK_TIMEOUT_SECONDS, NPM_PUBLISH_DRYRUN_TIMEOUT_SECONDS, NPM_PUBLISH_PACKAGES, } from "./constants.js";
5
5
  /**
6
6
  * npm publish dry-run rehearsal (#1910) -- the TS port of the
7
7
  * scripts/release_e2e.py helpers of the same name. Mirrors
@@ -148,4 +148,122 @@ export function rehearseNpmPublish(cloneDir, version, seams = {}) {
148
148
  `(types -> core -> content -> cli) at v${version}`,
149
149
  ];
150
150
  }
151
+ function moduleResolutionFailure(output) {
152
+ for (const marker of MODULE_NOT_FOUND_MARKERS) {
153
+ if (output.includes(marker))
154
+ return marker;
155
+ }
156
+ return null;
157
+ }
158
+ /**
159
+ * Publish-layout install+run smoke (#1996, #2010): pack the four
160
+ * @deftai/directive* packages, install the tarballs into a clean flat
161
+ * node_modules, then run `directive --version` (exit-0 liveness) and
162
+ * `directive doctor` (deep-import coverage) so import-resolution bugs like
163
+ * #1993 sub-problem 1 surface before a real npm publish. The smoke gates on
164
+ * module-not-found markers, NOT on the doctor's pass/fail verdict -- a full
165
+ * doctor check exits non-zero in a bare consumer layout, which is benign
166
+ * (#2010).
167
+ */
168
+ export function rehearseNpmInstallAndRun(cloneDir, version, seams = {}, options = {}) {
169
+ const which = resolveWhich(seams);
170
+ const npmPath = which("npm");
171
+ if (npmPath === null) {
172
+ return [true, "SKIP (npm not on PATH; Node-less operator)"];
173
+ }
174
+ const pnpmCmd = resolvePnpm(seams);
175
+ if (pnpmCmd === null) {
176
+ return [
177
+ false,
178
+ "npm present but neither pnpm nor corepack is on PATH -- " +
179
+ "cannot build the workspace for install+run smoke",
180
+ ];
181
+ }
182
+ const env = { ...process.env, DEFT_PROJECT_ROOT: cloneDir };
183
+ let ok = true;
184
+ let reason = "";
185
+ if (!options.skipWorkspacePrep) {
186
+ let [ok, reason] = runNpmStep([...pnpmCmd, "install", "--frozen-lockfile"], cloneDir, env, "pnpm install", NPM_INSTALL_TIMEOUT_SECONDS, seams);
187
+ if (!ok)
188
+ return [false, reason];
189
+ [ok, reason] = runNpmStep([...pnpmCmd, "-w", "run", "build"], cloneDir, env, "pnpm build", NPM_BUILD_TIMEOUT_SECONDS, seams);
190
+ if (!ok)
191
+ return [false, reason];
192
+ [ok, reason] = alignNpmPackageVersions(cloneDir, version);
193
+ if (!ok)
194
+ return [false, reason];
195
+ }
196
+ const packDir = join(cloneDir, ".deft-e2e-packs");
197
+ mkdirSync(packDir, { recursive: true });
198
+ const tgzPaths = [];
199
+ for (const pkg of NPM_PUBLISH_PACKAGES) {
200
+ const pkgDir = join(cloneDir, "packages", pkg);
201
+ [ok, reason] = runNpmStep([npmPath, "pack", "--pack-destination", packDir], pkgDir, env, `npm pack packages/${pkg}`, NPM_PACK_TIMEOUT_SECONDS, seams);
202
+ if (!ok)
203
+ return [false, reason];
204
+ const manifest = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8"));
205
+ if (manifest === null ||
206
+ typeof manifest.name !== "string" ||
207
+ typeof manifest.version !== "string") {
208
+ return [false, `version-align FAIL: invalid manifest in packages/${pkg}`];
209
+ }
210
+ const scoped = manifest.name.replaceAll("@", "").replaceAll("/", "-");
211
+ tgzPaths.push(join(packDir, `${scoped}-${manifest.version}.tgz`));
212
+ }
213
+ const consumerDir = join(cloneDir, ".deft-e2e-consumer");
214
+ mkdirSync(consumerDir, { recursive: true });
215
+ writeFileSync(join(consumerDir, "package.json"), `${JSON.stringify({ name: "deft-e2e-consumer", private: true, version: "0.0.0" }, null, 2)}\n`, "utf8");
216
+ const installArgs = [npmPath, "install", ...tgzPaths];
217
+ [ok, reason] = runNpmStep(installArgs, consumerDir, env, "npm install packed tarballs", NPM_INSTALL_RUN_TIMEOUT_SECONDS, seams);
218
+ if (!ok)
219
+ return [false, reason];
220
+ const spawn = seams.spawnText ?? spawnText;
221
+ const cliBin = join(consumerDir, "node_modules", "@deftai", "directive", "dist", "bin.js");
222
+ // Liveness probe (#2010): `--version` loads the cli + core engine via
223
+ // engineInfo(), exercising the cross-package import path that #1993 broke,
224
+ // and reliably exits 0 on a healthy install. This is the clean exit-0 gate;
225
+ // gating the smoke on the doctor's exit code instead is a false positive
226
+ // because a full doctor check exits non-zero in a bare consumer layout.
227
+ const versionRun = spawn(process.execPath, [cliBin, "--version"], {
228
+ cwd: consumerDir,
229
+ env,
230
+ timeoutMs: NPM_INSTALL_RUN_TIMEOUT_SECONDS * 1000,
231
+ });
232
+ const versionOut = `${versionRun.stdout ?? ""}\n${versionRun.stderr ?? ""}`;
233
+ let resolutionHit = moduleResolutionFailure(versionOut);
234
+ if (resolutionHit !== null) {
235
+ return [
236
+ false,
237
+ `install+run smoke: module resolution error on --version (${resolutionHit}): ${versionOut.trim().slice(-800)}`,
238
+ ];
239
+ }
240
+ if (versionRun.status !== 0) {
241
+ return [
242
+ false,
243
+ `install+run smoke: directive --version exited ${versionRun.status}: ${versionOut.trim().slice(-500)}`,
244
+ ];
245
+ }
246
+ // Deep-import probe (#2010): run the doctor verb to exercise the deeper
247
+ // cross-package import graph that #1993 sub-problem 1 broke. The doctor
248
+ // legitimately exits non-zero in a bare consumer layout (e.g. no root
249
+ // Taskfile.yml), so gate ONLY on the module-not-found markers, NOT on the
250
+ // doctor's pass/fail verdict.
251
+ const doctorRun = spawn(process.execPath, [cliBin, "doctor"], {
252
+ cwd: consumerDir,
253
+ env,
254
+ timeoutMs: NPM_INSTALL_RUN_TIMEOUT_SECONDS * 1000,
255
+ });
256
+ const doctorOut = `${doctorRun.stdout ?? ""}\n${doctorRun.stderr ?? ""}`;
257
+ resolutionHit = moduleResolutionFailure(doctorOut);
258
+ if (resolutionHit !== null) {
259
+ return [
260
+ false,
261
+ `install+run smoke: module resolution error on doctor (${resolutionHit}): ${doctorOut.trim().slice(-800)}`,
262
+ ];
263
+ }
264
+ return [
265
+ true,
266
+ `packed + installed 4 packages at v${version}; ran directive --version (exit 0) + doctor without module-not-found`,
267
+ ];
268
+ }
151
269
  //# sourceMappingURL=npm-ops.js.map
@@ -6,7 +6,7 @@ import { dispatchTaskRelease, dispatchTaskReleaseRollback } from "./entrypoint.j
6
6
  import { emit } from "./flags.js";
7
7
  import { verifyDraftRelease } from "./gh-ops.js";
8
8
  import { cloneRepoToTemp, pushMirror, setOriginToTempRepo, verifyTag } from "./git-ops.js";
9
- import { rehearseNpmPublish } from "./npm-ops.js";
9
+ import { rehearseNpmInstallAndRun, rehearseNpmPublish } from "./npm-ops.js";
10
10
  export function runRehearsal(owner, slug, projectRoot, version = REHEARSAL_VERSION, seams = {}, skipNpm = false) {
11
11
  const repoFull = `${owner}/${slug}`;
12
12
  const mkdtemp = seams.mkdtemp ?? ((prefix) => mkdtempSync(join(tmpdir(), prefix)));
@@ -24,6 +24,10 @@ export function runRehearsal(owner, slug, projectRoot, version = REHEARSAL_VERSI
24
24
  ];
25
25
  if (!skipNpm) {
26
26
  steps.push(["npm publish dry-run", () => rehearseNpmPublish(cloneDir, version, seams)]);
27
+ steps.push([
28
+ "npm install+run smoke",
29
+ () => rehearseNpmInstallAndRun(cloneDir, version, seams, { skipWorkspacePrep: true }),
30
+ ]);
27
31
  }
28
32
  steps.push([
29
33
  "task release:rollback",
@@ -36,7 +40,9 @@ export function runRehearsal(owner, slug, projectRoot, version = REHEARSAL_VERSI
36
40
  return [false, `${label}: ${reason}`];
37
41
  }
38
42
  }
39
- const npmNote = skipNpm ? " (npm dry-run skipped)" : " -> npm publish dry-run";
43
+ const npmNote = skipNpm
44
+ ? " (npm dry-run skipped)"
45
+ : " -> npm publish dry-run -> npm install+run smoke";
40
46
  return [
41
47
  true,
42
48
  `pipeline-mirror rehearsal succeeded against ${repoFull} ` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deftai/directive-core",
3
- "version": "0.56.1",
3
+ "version": "0.57.0",
4
4
  "description": "TypeScript engine core for the Directive framework.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -189,6 +189,10 @@
189
189
  "./init-deposit": {
190
190
  "types": "./dist/init-deposit/index.d.ts",
191
191
  "default": "./dist/init-deposit/index.js"
192
+ },
193
+ "./dist/*.js": {
194
+ "types": "./dist/*.d.ts",
195
+ "default": "./dist/*.js"
192
196
  }
193
197
  },
194
198
  "files": [
@@ -205,8 +209,8 @@
205
209
  "provenance": true
206
210
  },
207
211
  "dependencies": {
208
- "@deftai/directive-content": "^0.56.1",
209
- "@deftai/directive-types": "^0.56.1"
212
+ "@deftai/directive-content": "^0.57.0",
213
+ "@deftai/directive-types": "^0.57.0"
210
214
  },
211
215
  "scripts": {
212
216
  "build": "tsc -b",