@deftai/directive-core 0.69.0 → 0.71.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/branch/evaluate.js +7 -0
- package/dist/deposit/contain.d.ts +41 -0
- package/dist/deposit/contain.js +113 -0
- package/dist/deposit/copy-tree.js +8 -2
- package/dist/doctor/checks.d.ts +11 -0
- package/dist/doctor/checks.js +75 -2
- package/dist/doctor/doctor-state.js +2 -2
- package/dist/doctor/main.js +54 -1
- package/dist/doctor/manifest.d.ts +20 -0
- package/dist/doctor/manifest.js +22 -0
- package/dist/doctor/types.d.ts +8 -0
- package/dist/eval/crud-telemetry.d.ts +59 -0
- package/dist/eval/crud-telemetry.js +307 -0
- package/dist/eval/health.d.ts +52 -0
- package/dist/eval/health.js +240 -0
- package/dist/eval/readback.d.ts +38 -0
- package/dist/eval/readback.js +209 -0
- package/dist/eval/report.d.ts +53 -0
- package/dist/eval/report.js +161 -0
- package/dist/eval/run.d.ts +79 -0
- package/dist/eval/run.js +309 -0
- package/dist/events/attribution-constants.d.ts +13 -0
- package/dist/events/attribution-constants.js +18 -0
- package/dist/events/attribution-ledger.d.ts +43 -0
- package/dist/events/attribution-ledger.js +61 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/init-deposit/gitignore.js +5 -5
- package/dist/init-deposit/init-deposit.js +6 -0
- package/dist/init-deposit/refresh.js +5 -0
- package/dist/intake/issue-ingest.d.ts +11 -0
- package/dist/intake/issue-ingest.js +86 -11
- package/dist/layout/resolve.d.ts +2 -2
- package/dist/layout/resolve.js +2 -2
- package/dist/lifecycle/events.js +2 -0
- package/dist/policy/index.d.ts +2 -1
- package/dist/policy/index.js +15 -2
- package/dist/policy/plan-extensions.d.ts +31 -0
- package/dist/policy/plan-extensions.js +45 -0
- package/dist/policy/value-feedback.d.ts +56 -0
- package/dist/policy/value-feedback.js +284 -0
- package/dist/policy/wip.d.ts +2 -2
- package/dist/policy/wip.js +2 -2
- package/dist/preflight-cache/evaluate.js +4 -3
- package/dist/scope/audit-log.js +3 -3
- package/dist/scope/transition.js +16 -5
- package/dist/session/session-start.js +20 -0
- package/dist/slice/record.js +3 -3
- package/dist/swarm/routing.js +10 -3
- package/dist/triage/actions/candidates-log.d.ts +4 -4
- package/dist/triage/actions/candidates-log.js +4 -7
- package/dist/triage/bootstrap/gitignore.d.ts +7 -3
- package/dist/triage/bootstrap/gitignore.js +67 -37
- package/dist/triage/bootstrap/index.d.ts +1 -1
- package/dist/triage/bootstrap/index.js +4 -3
- package/dist/triage/bulk/index.d.ts +11 -2
- package/dist/triage/bulk/index.js +43 -6
- package/dist/triage/cache-path.d.ts +39 -0
- package/dist/triage/cache-path.js +98 -0
- package/dist/triage/help/registry-data.d.ts +53 -20
- package/dist/triage/help/registry-data.js +104 -20
- package/dist/triage/index.d.ts +2 -0
- package/dist/triage/index.js +2 -0
- package/dist/triage/queue/audit.js +2 -2
- package/dist/triage/queue/cache.js +2 -2
- package/dist/triage/reconcile/reconcile.js +4 -3
- package/dist/triage/scope/cli.d.ts +2 -1
- package/dist/triage/scope/cli.js +30 -6
- package/dist/triage/scope/mutations-core.js +3 -2
- package/dist/triage/scope/mutations.d.ts +10 -0
- package/dist/triage/scope/mutations.js +22 -0
- package/dist/triage/subscribe/index.d.ts +1 -1
- package/dist/triage/subscribe/index.js +4 -3
- package/dist/triage/summary/index.d.ts +2 -2
- package/dist/triage/summary/index.js +4 -3
- package/dist/triage/summary/reconcilable.d.ts +1 -1
- package/dist/triage/summary/reconcilable.js +3 -2
- package/dist/triage/welcome/constants.d.ts +3 -4
- package/dist/triage/welcome/constants.js +3 -4
- package/dist/triage/welcome/index.d.ts +1 -0
- package/dist/triage/welcome/index.js +1 -0
- package/dist/triage/welcome/onboard.d.ts +35 -0
- package/dist/triage/welcome/onboard.js +94 -0
- package/dist/triage/welcome/prior-state.js +3 -2
- package/dist/triage/welcome/summary.js +5 -4
- package/dist/umbrella-current-shape/index.d.ts +25 -1
- package/dist/umbrella-current-shape/index.js +84 -4
- package/dist/value/adoption-emit.d.ts +14 -0
- package/dist/value/adoption-emit.js +67 -0
- package/dist/value/adoption-registry.d.ts +62 -0
- package/dist/value/adoption-registry.js +214 -0
- package/dist/value/feedback-file.d.ts +67 -0
- package/dist/value/feedback-file.js +353 -0
- package/dist/value/friction-emit.d.ts +10 -0
- package/dist/value/friction-emit.js +24 -0
- package/dist/value/readback.d.ts +86 -0
- package/dist/value/readback.js +503 -0
- package/dist/vbrief-build/project-definition-io.d.ts +6 -0
- package/dist/vbrief-build/project-definition-io.js +7 -2
- package/dist/vbrief-validate/decomposition.js +1 -1
- package/dist/vbrief-validate/precutover.js +7 -3
- package/dist/wip-cap/evaluate.js +3 -0
- package/package.json +35 -3
package/dist/branch/evaluate.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { recordBypassSignal, recordGateCatch } from "../events/attribution-ledger.js";
|
|
1
2
|
import { disclosureLine } from "../policy/disclosure.js";
|
|
2
3
|
import { ENV_BYPASS, resolvePolicy } from "../policy/resolve.js";
|
|
3
4
|
import { currentBranch, GitNotFoundError } from "./git.js";
|
|
@@ -42,6 +43,7 @@ export function evaluate(projectRoot, options = {}) {
|
|
|
42
43
|
const defaultBranches = options.defaultBranches ?? DEFAULT_BRANCHES;
|
|
43
44
|
const allowMissingProjectDefinition = options.allowMissingProjectDefinition ?? false;
|
|
44
45
|
if (setupExemptionActive()) {
|
|
46
|
+
recordBypassSignal(projectRoot, "verify:branch", `${ENV_SETUP_EXEMPTION}=1 setup-interview exemption`);
|
|
45
47
|
return {
|
|
46
48
|
exitCode: 0,
|
|
47
49
|
message: "✓ deft branch-protection: setup-interview exemption active " +
|
|
@@ -97,6 +99,9 @@ export function evaluate(projectRoot, options = {}) {
|
|
|
97
99
|
}
|
|
98
100
|
const result = resolvePolicy(projectRoot);
|
|
99
101
|
if (result.allowDirectCommits) {
|
|
102
|
+
if (result.source === "env-bypass") {
|
|
103
|
+
recordBypassSignal(projectRoot, "verify:branch", `${ENV_BYPASS}=1 on default branch '${branch}'`);
|
|
104
|
+
}
|
|
100
105
|
return {
|
|
101
106
|
exitCode: 0,
|
|
102
107
|
message: `⚠ deft branch-protection: on default branch '${branch}', but ` +
|
|
@@ -105,6 +110,7 @@ export function evaluate(projectRoot, options = {}) {
|
|
|
105
110
|
}
|
|
106
111
|
if (result.source === "default-fail-closed" && result.error !== null) {
|
|
107
112
|
if (allowMissingProjectDefinition && result.error.includes("not found")) {
|
|
113
|
+
recordBypassSignal(projectRoot, "verify:branch", "--allow-missing-project-definition bootstrap on default branch");
|
|
108
114
|
return {
|
|
109
115
|
exitCode: 0,
|
|
110
116
|
message: "✓ deft branch-protection: PROJECT-DEFINITION missing AND " +
|
|
@@ -128,6 +134,7 @@ export function evaluate(projectRoot, options = {}) {
|
|
|
128
134
|
recovery,
|
|
129
135
|
};
|
|
130
136
|
}
|
|
137
|
+
recordGateCatch(projectRoot, "verify:branch", `blocked commit on default branch '${branch}'`);
|
|
131
138
|
return {
|
|
132
139
|
exitCode: 1,
|
|
133
140
|
message: buildBlockMessage(branch, result),
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* contain.ts — deposit-boundary symlink-escape guard (#2305).
|
|
3
|
+
*
|
|
4
|
+
* The framework deposit derives its destination as
|
|
5
|
+
* `deftDir = join(projectDir, ".deft/core")` and copies the (trusted) content
|
|
6
|
+
* payload into it. If a malicious project repo commits `.deft` (or `.deft/core`,
|
|
7
|
+
* or a parent) as a symlink that escapes the tree — e.g. `.deft -> ../../evil` —
|
|
8
|
+
* the deposit would write/replace framework content THROUGH that symlink,
|
|
9
|
+
* outside the resolved project tree, under the victim's account.
|
|
10
|
+
*
|
|
11
|
+
* `assertDepositContained` refuses that: it anchors on `realpath(projectDir)`,
|
|
12
|
+
* walks each existing component from the project root down to `deftDir`, rejects
|
|
13
|
+
* any component that is a symlink escaping the project tree, and asserts the
|
|
14
|
+
* deepest existing ancestor of `deftDir` resolves back UNDER the project tree
|
|
15
|
+
* using path-SEGMENT containment (not string `startsWith`).
|
|
16
|
+
*
|
|
17
|
+
* Callers MUST invoke it BEFORE the first copy/mkdir/reconstitute so a refusal
|
|
18
|
+
* deposits nothing.
|
|
19
|
+
*
|
|
20
|
+
* Refs #2305.
|
|
21
|
+
*/
|
|
22
|
+
/** Non-zero exit code for a deposit-containment refusal (needs-action). */
|
|
23
|
+
export declare const DEPOSIT_CONTAINMENT_REFUSED_EXIT_CODE = 2;
|
|
24
|
+
/** Thrown by the deposit/refresh path when the destination escapes the tree. */
|
|
25
|
+
export declare class DepositContainmentError extends Error {
|
|
26
|
+
readonly projectDir: string;
|
|
27
|
+
readonly deftDir: string;
|
|
28
|
+
readonly offendingPath: string;
|
|
29
|
+
constructor(message: string, details: {
|
|
30
|
+
projectDir: string;
|
|
31
|
+
deftDir: string;
|
|
32
|
+
offendingPath: string;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Refuse the deposit when `.deft` / `.deft/core` (or a parent) is a symlink that
|
|
37
|
+
* escapes the resolved project tree. Throws {@link DepositContainmentError};
|
|
38
|
+
* MUST be called BEFORE any deposit write so a refusal deposits nothing.
|
|
39
|
+
*/
|
|
40
|
+
export declare function assertDepositContained(projectDir: string, deftDir: string): void;
|
|
41
|
+
//# sourceMappingURL=contain.d.ts.map
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* contain.ts — deposit-boundary symlink-escape guard (#2305).
|
|
3
|
+
*
|
|
4
|
+
* The framework deposit derives its destination as
|
|
5
|
+
* `deftDir = join(projectDir, ".deft/core")` and copies the (trusted) content
|
|
6
|
+
* payload into it. If a malicious project repo commits `.deft` (or `.deft/core`,
|
|
7
|
+
* or a parent) as a symlink that escapes the tree — e.g. `.deft -> ../../evil` —
|
|
8
|
+
* the deposit would write/replace framework content THROUGH that symlink,
|
|
9
|
+
* outside the resolved project tree, under the victim's account.
|
|
10
|
+
*
|
|
11
|
+
* `assertDepositContained` refuses that: it anchors on `realpath(projectDir)`,
|
|
12
|
+
* walks each existing component from the project root down to `deftDir`, rejects
|
|
13
|
+
* any component that is a symlink escaping the project tree, and asserts the
|
|
14
|
+
* deepest existing ancestor of `deftDir` resolves back UNDER the project tree
|
|
15
|
+
* using path-SEGMENT containment (not string `startsWith`).
|
|
16
|
+
*
|
|
17
|
+
* Callers MUST invoke it BEFORE the first copy/mkdir/reconstitute so a refusal
|
|
18
|
+
* deposits nothing.
|
|
19
|
+
*
|
|
20
|
+
* Refs #2305.
|
|
21
|
+
*/
|
|
22
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
23
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
24
|
+
/** Non-zero exit code for a deposit-containment refusal (needs-action). */
|
|
25
|
+
export const DEPOSIT_CONTAINMENT_REFUSED_EXIT_CODE = 2;
|
|
26
|
+
/** Thrown by the deposit/refresh path when the destination escapes the tree. */
|
|
27
|
+
export class DepositContainmentError extends Error {
|
|
28
|
+
projectDir;
|
|
29
|
+
deftDir;
|
|
30
|
+
offendingPath;
|
|
31
|
+
constructor(message, details) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = "DepositContainmentError";
|
|
34
|
+
this.projectDir = details.projectDir;
|
|
35
|
+
this.deftDir = details.deftDir;
|
|
36
|
+
this.offendingPath = details.offendingPath;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Path-SEGMENT containment: is `child` equal to `parent` or nested under it?
|
|
41
|
+
* Uses `path.relative` so `/foo` is NOT treated as containing `/foobar`
|
|
42
|
+
* (`relative("/foo", "/foobar")` is `"../foobar"`, which is rejected).
|
|
43
|
+
*/
|
|
44
|
+
function isContained(parent, child) {
|
|
45
|
+
if (parent === child) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
const rel = relative(parent, child);
|
|
49
|
+
return rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Refuse the deposit when `.deft` / `.deft/core` (or a parent) is a symlink that
|
|
53
|
+
* escapes the resolved project tree. Throws {@link DepositContainmentError};
|
|
54
|
+
* MUST be called BEFORE any deposit write so a refusal deposits nothing.
|
|
55
|
+
*/
|
|
56
|
+
export function assertDepositContained(projectDir, deftDir) {
|
|
57
|
+
const projectAbs = resolve(projectDir);
|
|
58
|
+
let projectReal;
|
|
59
|
+
try {
|
|
60
|
+
projectReal = realpathSync(projectAbs);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// The project directory does not exist yet (e.g. `directive init <new-path>`).
|
|
64
|
+
// Nothing under it can exist, so there is no pre-existing symlink to escape
|
|
65
|
+
// through -- the downstream init flow creates fresh, clean directories. Let
|
|
66
|
+
// the deposit proceed; a missing content payload / mkdir failure surfaces its
|
|
67
|
+
// own error later.
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const deftAbs = resolve(deftDir);
|
|
71
|
+
const rel = relative(projectAbs, deftAbs);
|
|
72
|
+
if (rel.length === 0 || rel.startsWith("..") || isAbsolute(rel)) {
|
|
73
|
+
throw new DepositContainmentError(`deposit refused: deposit target ${deftAbs} is not nested under the project tree ${projectAbs}`, { projectDir: projectAbs, deftDir: deftAbs, offendingPath: deftAbs });
|
|
74
|
+
}
|
|
75
|
+
const segments = rel.split(/[\\/]+/).filter((segment) => segment.length > 0);
|
|
76
|
+
let current = projectAbs;
|
|
77
|
+
let deepestExistingReal = projectReal;
|
|
78
|
+
for (const segment of segments) {
|
|
79
|
+
current = join(current, segment);
|
|
80
|
+
let info;
|
|
81
|
+
try {
|
|
82
|
+
info = lstatSync(current);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// This component does not exist yet; nothing below it can exist either.
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
if (info.isSymbolicLink()) {
|
|
89
|
+
let linkReal;
|
|
90
|
+
try {
|
|
91
|
+
linkReal = realpathSync(current);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
throw new DepositContainmentError(`deposit refused: ${current} is a broken/dangling symlink on the deposit path`, { projectDir: projectAbs, deftDir: deftAbs, offendingPath: current });
|
|
95
|
+
}
|
|
96
|
+
if (!isContained(projectReal, linkReal)) {
|
|
97
|
+
throw new DepositContainmentError(`deposit refused: ${current} is a symlink escaping the project tree ` +
|
|
98
|
+
`(resolves to ${linkReal}, outside ${projectReal})`, { projectDir: projectAbs, deftDir: deftAbs, offendingPath: current });
|
|
99
|
+
}
|
|
100
|
+
deepestExistingReal = linkReal;
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
deepestExistingReal = realpathSync(current);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Defense-in-depth: the deepest EXISTING ancestor of deftDir must resolve back
|
|
107
|
+
// under the project tree (segment containment, not string startsWith).
|
|
108
|
+
if (!isContained(projectReal, deepestExistingReal)) {
|
|
109
|
+
throw new DepositContainmentError(`deposit refused: deposit path escapes the project tree ` +
|
|
110
|
+
`(${deepestExistingReal} is outside ${projectReal})`, { projectDir: projectAbs, deftDir: deftAbs, offendingPath: deepestExistingReal });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=contain.js.map
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Refs #1942 S1, #1477.
|
|
9
9
|
*/
|
|
10
10
|
import { createReadStream, createWriteStream } from "node:fs";
|
|
11
|
-
import { chmod, mkdir, readdir, stat } from "node:fs/promises";
|
|
11
|
+
import { chmod, lstat, mkdir, readdir, stat } from "node:fs/promises";
|
|
12
12
|
import { dirname, join } from "node:path";
|
|
13
13
|
import { pipeline } from "node:stream/promises";
|
|
14
14
|
const DEFAULT_FILE_MODE = 0o644;
|
|
@@ -34,7 +34,13 @@ async function copyDirContents(src, dst) {
|
|
|
34
34
|
for (const entry of entries) {
|
|
35
35
|
const srcPath = join(src, entry.name);
|
|
36
36
|
const dstPath = join(dst, entry.name);
|
|
37
|
-
|
|
37
|
+
// #2305: lstat (not stat) so a symlinked entry is NOT followed silently.
|
|
38
|
+
// The deposit copies regular files/dirs only; a symlink in the (trusted)
|
|
39
|
+
// payload is skipped rather than dereferenced.
|
|
40
|
+
const srcStat = await lstat(srcPath);
|
|
41
|
+
if (srcStat.isSymbolicLink()) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
38
44
|
if (srcStat.isDirectory()) {
|
|
39
45
|
await copyDirContents(srcPath, dstPath);
|
|
40
46
|
}
|
package/dist/doctor/checks.d.ts
CHANGED
|
@@ -19,6 +19,17 @@ export declare function checkLegacyLayout(projectRoot: string, seams?: CheckSeam
|
|
|
19
19
|
* been stamped npm-managed. Local-only (no network).
|
|
20
20
|
*/
|
|
21
21
|
export declare function checkCanonicalVendoredNpmSignpost(projectRoot: string, seams?: CheckSeams): CheckResult;
|
|
22
|
+
/**
|
|
23
|
+
* #2294: report whether the located install manifest can surface a version.
|
|
24
|
+
* A legacy `deft-install` deposit made without a release pin writes empty
|
|
25
|
+
* `tag`/`ref` and only a short `sha`, leaving the framework version silently
|
|
26
|
+
* unreportable. Since the Go installer is a frozen legacy bridge (#1912) and
|
|
27
|
+
* the Python/Go rails are retired (#1933/#2022/#2068), the fix is doctor-side:
|
|
28
|
+
* turn the silent blank into a visible, actionable finding. Advisory only --
|
|
29
|
+
* exit-code-exempt like `canonical-vendored-npm-signpost` -- because a sha-only
|
|
30
|
+
* deposit is still a working install, just an unpinned one.
|
|
31
|
+
*/
|
|
32
|
+
export declare function checkManifestVersionReportable(projectRoot: string, installRoot: string | null, seams?: CheckSeams): CheckResult;
|
|
22
33
|
export declare function deriveExitCode(checks: readonly CheckResult[], errors: readonly string[]): number;
|
|
23
34
|
export declare function runChecksImpl(projectRoot: string, seams?: CheckSeams & {
|
|
24
35
|
isDir?: (p: string) => boolean;
|
package/dist/doctor/checks.js
CHANGED
|
@@ -4,7 +4,7 @@ import { detectCanonicalVendoredManifest, isNpmManaged, NPM_MANAGED_SENTINEL_KEY
|
|
|
4
4
|
import { resolveLifecycleRoot } from "../layout/resolve.js";
|
|
5
5
|
import { findSkillPathsInText } from "../text/redos-safe.js";
|
|
6
6
|
import { CANONICAL_UPGRADE_COMMAND, GO_BRIDGE_RELEASES_URL, UPGRADING_DOC_URL, } from "./constants.js";
|
|
7
|
-
import { isDeprecationRedirectStub, locateManifest, manifestCandidatePaths, manifestTagToVersion, parseInstallManifest, parseInstallRootFromAgentsMd, parseManifest, } from "./manifest.js";
|
|
7
|
+
import { isDeprecationRedirectStub, locateManifest, manifestCandidatePaths, manifestReportableVersion, manifestTagToVersion, parseInstallManifest, parseInstallRootFromAgentsMd, parseManifest, } from "./manifest.js";
|
|
8
8
|
import { readTextSafe } from "./paths.js";
|
|
9
9
|
function readText(path, seams) {
|
|
10
10
|
return (seams.readText ?? readTextSafe)(path);
|
|
@@ -366,11 +366,82 @@ export function checkCanonicalVendoredNpmSignpost(projectRoot, seams = {}) {
|
|
|
366
366
|
},
|
|
367
367
|
};
|
|
368
368
|
}
|
|
369
|
+
/**
|
|
370
|
+
* #2294: report whether the located install manifest can surface a version.
|
|
371
|
+
* A legacy `deft-install` deposit made without a release pin writes empty
|
|
372
|
+
* `tag`/`ref` and only a short `sha`, leaving the framework version silently
|
|
373
|
+
* unreportable. Since the Go installer is a frozen legacy bridge (#1912) and
|
|
374
|
+
* the Python/Go rails are retired (#1933/#2022/#2068), the fix is doctor-side:
|
|
375
|
+
* turn the silent blank into a visible, actionable finding. Advisory only --
|
|
376
|
+
* exit-code-exempt like `canonical-vendored-npm-signpost` -- because a sha-only
|
|
377
|
+
* deposit is still a working install, just an unpinned one.
|
|
378
|
+
*/
|
|
379
|
+
export function checkManifestVersionReportable(projectRoot, installRoot, seams = {}) {
|
|
380
|
+
const isFile = seams.isFile ?? ((p) => readText(p, seams) !== null);
|
|
381
|
+
const manifestPath = locateManifest(projectRoot, installRoot, isFile);
|
|
382
|
+
if (manifestPath === null) {
|
|
383
|
+
return {
|
|
384
|
+
name: "manifest-version-reportable",
|
|
385
|
+
status: "skip",
|
|
386
|
+
detail: "No install manifest found; no framework version to report.",
|
|
387
|
+
data: { manifest_path: null },
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
const text = readText(manifestPath, seams);
|
|
391
|
+
if (text === null) {
|
|
392
|
+
return {
|
|
393
|
+
name: "manifest-version-reportable",
|
|
394
|
+
status: "skip",
|
|
395
|
+
detail: `Install manifest at ${manifestPath} is unreadable.`,
|
|
396
|
+
data: { manifest_path: manifestPath },
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
const manifest = parseInstallManifest(text);
|
|
400
|
+
const reportable = manifestReportableVersion(manifest);
|
|
401
|
+
if (reportable.version !== null) {
|
|
402
|
+
return {
|
|
403
|
+
name: "manifest-version-reportable",
|
|
404
|
+
status: "pass",
|
|
405
|
+
detail: `Framework version resolves to ${reportable.version} (from manifest ${reportable.source}) at ${manifestPath}.`,
|
|
406
|
+
data: {
|
|
407
|
+
manifest_path: manifestPath,
|
|
408
|
+
version: reportable.version,
|
|
409
|
+
source: reportable.source,
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
if (reportable.sha !== null) {
|
|
414
|
+
const shortSha = reportable.sha.slice(0, 8);
|
|
415
|
+
return {
|
|
416
|
+
name: "manifest-version-reportable",
|
|
417
|
+
status: "fail",
|
|
418
|
+
detail: `Install manifest at ${manifestPath} carries no semver tag/ref (sha ${shortSha} only), ` +
|
|
419
|
+
"so the framework version is unreportable. This happens on legacy `deft-install` deposits " +
|
|
420
|
+
`made without a release pin (#2294). Run \`${CANONICAL_UPGRADE_COMMAND}\` then \`directive update\` ` +
|
|
421
|
+
`to obtain a pinned npm-managed manifest. See ${UPGRADING_DOC_URL}.`,
|
|
422
|
+
data: {
|
|
423
|
+
manifest_path: manifestPath,
|
|
424
|
+
version: null,
|
|
425
|
+
source: reportable.source,
|
|
426
|
+
sha: reportable.sha,
|
|
427
|
+
upgrade_command: CANONICAL_UPGRADE_COMMAND,
|
|
428
|
+
upgrading_doc_url: UPGRADING_DOC_URL,
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
return {
|
|
433
|
+
name: "manifest-version-reportable",
|
|
434
|
+
status: "skip",
|
|
435
|
+
detail: `Install manifest at ${manifestPath} has neither a semver tag/ref nor a sha; no provenance to report.`,
|
|
436
|
+
data: { manifest_path: manifestPath, version: null, sha: null, source: reportable.source },
|
|
437
|
+
};
|
|
438
|
+
}
|
|
369
439
|
export function deriveExitCode(checks, errors) {
|
|
440
|
+
const exitExempt = new Set(["canonical-vendored-npm-signpost", "manifest-version-reportable"]);
|
|
370
441
|
if (errors.length > 0 || checks.some((c) => c.status === "error")) {
|
|
371
442
|
return 2;
|
|
372
443
|
}
|
|
373
|
-
if (checks.some((c) => c.status === "fail" && c.name
|
|
444
|
+
if (checks.some((c) => c.status === "fail" && !exitExempt.has(c.name))) {
|
|
374
445
|
return 1;
|
|
375
446
|
}
|
|
376
447
|
return 0;
|
|
@@ -402,6 +473,7 @@ export function runChecksImpl(projectRoot, seams = {}) {
|
|
|
402
473
|
data: { agents_md_path: agentsMdPath },
|
|
403
474
|
});
|
|
404
475
|
checks.push(checkManifestAgreement(projectRoot, null, seams));
|
|
476
|
+
checks.push(checkManifestVersionReportable(projectRoot, null, seams));
|
|
405
477
|
checks.push(checkLegacyLayout(projectRoot, seams));
|
|
406
478
|
checks.push(checkCanonicalVendoredNpmSignpost(projectRoot, seams));
|
|
407
479
|
return {
|
|
@@ -415,6 +487,7 @@ export function runChecksImpl(projectRoot, seams = {}) {
|
|
|
415
487
|
checks.push(checkQuickStartResolves(projectRoot, installRoot, seams));
|
|
416
488
|
checks.push(checkSkillPathsResolve(projectRoot, agentsMdText, seams));
|
|
417
489
|
checks.push(checkManifestAgreement(projectRoot, installRoot, seams));
|
|
490
|
+
checks.push(checkManifestVersionReportable(projectRoot, installRoot, seams));
|
|
418
491
|
checks.push(checkInstallPathConsistency(projectRoot, installRoot, seams));
|
|
419
492
|
checks.push(checkLegacyLayout(projectRoot, seams));
|
|
420
493
|
checks.push(checkCanonicalVendoredNpmSignpost(projectRoot, seams));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { resolveTriageCachePath } from "../triage/cache-path.js";
|
|
4
4
|
import { CLEAN_WINDOW_HOURS, DIRTY_WINDOW_HOURS, ENV_STATE_PATH } from "./constants.js";
|
|
5
5
|
const STATE_FILENAME = "doctor-state.json";
|
|
6
6
|
export function statePath(projectRoot) {
|
|
@@ -10,7 +10,7 @@ export function statePath(projectRoot) {
|
|
|
10
10
|
? join(process.env.HOME ?? projectRoot, override.slice(1))
|
|
11
11
|
: override;
|
|
12
12
|
}
|
|
13
|
-
return
|
|
13
|
+
return resolveTriageCachePath(projectRoot, STATE_FILENAME);
|
|
14
14
|
}
|
|
15
15
|
function parseIso(ts) {
|
|
16
16
|
if (typeof ts !== "string" || !ts) {
|
package/dist/doctor/main.js
CHANGED
|
@@ -2,6 +2,8 @@ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { evaluate as evaluateAgentsMdAdvisory } from "../agents-md-advisory/evaluate.js";
|
|
4
4
|
import { contentRoot } from "../content-root.js";
|
|
5
|
+
import { describeShadowedPlanExtension, detectShadowedPlanExtensions, } from "../policy/plan-extensions.js";
|
|
6
|
+
import { loadProjectDefinition } from "../policy/resolve.js";
|
|
5
7
|
import { checkLocalEngineIntegrity, classify, detectPackageManager, evaluateSkew, reconcileVersions, plan as resolvePlan, } from "../resolution/index.js";
|
|
6
8
|
import { resolveUserMdPath } from "../user-config/resolve-user-md.js";
|
|
7
9
|
import { agentsRefreshPlan, hasV3ManagedMarker } from "./agents-md.js";
|
|
@@ -257,6 +259,11 @@ export function cmdDoctor(args, seams = {}) {
|
|
|
257
259
|
if (!jsonMode) {
|
|
258
260
|
sink.blank();
|
|
259
261
|
}
|
|
262
|
+
sink.info("Checking plan.policy namespacing (shadowed bare keys)...");
|
|
263
|
+
runPlanExtensionShadowCheck(projectRoot, sink, addFinding, seams);
|
|
264
|
+
if (!jsonMode) {
|
|
265
|
+
sink.blank();
|
|
266
|
+
}
|
|
260
267
|
sink.info("Checking optional root Taskfile.yml include...");
|
|
261
268
|
runTaskfileIncludeCheck(projectRoot, fixMode, jsonMode, sink, addFinding, seams);
|
|
262
269
|
let resolution = null;
|
|
@@ -364,7 +371,9 @@ function runInstallIntegrityChecks(projectRoot, sink, addFinding, seams) {
|
|
|
364
371
|
sink.info(`${name}: skip -- ${detail}`);
|
|
365
372
|
continue;
|
|
366
373
|
}
|
|
367
|
-
if ((name === "legacy-layout" ||
|
|
374
|
+
if ((name === "legacy-layout" ||
|
|
375
|
+
name === "canonical-vendored-npm-signpost" ||
|
|
376
|
+
name === "manifest-version-reportable") &&
|
|
368
377
|
status === "fail") {
|
|
369
378
|
sink.warn(`${name}: ${detail}`);
|
|
370
379
|
addFinding({
|
|
@@ -618,6 +627,50 @@ function runUserMdResolutionCheck(projectRoot, sink, addFinding, seams) {
|
|
|
618
627
|
});
|
|
619
628
|
return result;
|
|
620
629
|
}
|
|
630
|
+
/**
|
|
631
|
+
* Loud shadow diagnostic (#2301): flag every plan-extension key whose bare form
|
|
632
|
+
* (e.g. `plan.policy`) coexists with the namespaced form (`plan.x-directive/policy`).
|
|
633
|
+
* Because the reader is namespace-first, the bare block is silently ignored --
|
|
634
|
+
* the exact "edit takes no effect, no warning" trap behind #2295. Emits a
|
|
635
|
+
* `warning` finding per shadowed key; never an `error`, so `deft doctor` stays
|
|
636
|
+
* green on a merely-legibility issue. A clean project reports a `skip` finding.
|
|
637
|
+
*/
|
|
638
|
+
function runPlanExtensionShadowCheck(projectRoot, sink, addFinding, seams) {
|
|
639
|
+
const checkName = "plan-extension-shadow";
|
|
640
|
+
const detect = seams.detectPlanExtensionShadows ??
|
|
641
|
+
((root) => {
|
|
642
|
+
const [data] = loadProjectDefinition(root);
|
|
643
|
+
return data === null ? [] : detectShadowedPlanExtensions(data.plan);
|
|
644
|
+
});
|
|
645
|
+
try {
|
|
646
|
+
const shadows = detect(projectRoot);
|
|
647
|
+
if (shadows.length === 0) {
|
|
648
|
+
const cleanMessage = `${checkName}: no shadowed bare plan keys`;
|
|
649
|
+
sink.success(cleanMessage);
|
|
650
|
+
addFinding({ severity: "skip", message: cleanMessage, check: checkName, status: "clean" });
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
for (const shadow of shadows) {
|
|
654
|
+
const message = `plan.policy shadow -- ${describeShadowedPlanExtension(shadow)}`.replace(/\r?\n/g, " ");
|
|
655
|
+
sink.warn(message);
|
|
656
|
+
addFinding({
|
|
657
|
+
severity: "warning",
|
|
658
|
+
message,
|
|
659
|
+
check: checkName,
|
|
660
|
+
status: "shadowed",
|
|
661
|
+
namespaced_key: shadow.namespacedKey,
|
|
662
|
+
legacy_key: shadow.legacyKey,
|
|
663
|
+
shadowed_sub_keys: [...shadow.shadowedSubKeys],
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
catch (exc) {
|
|
668
|
+
const detail = `${exc instanceof Error ? exc.name : "Error"}: ${exc}`.replace(/\r?\n/g, " ");
|
|
669
|
+
const message = `${checkName}: probe failed -- ${detail}`;
|
|
670
|
+
sink.warn(message);
|
|
671
|
+
addFinding({ severity: "warning", message, check: checkName });
|
|
672
|
+
}
|
|
673
|
+
}
|
|
621
674
|
/**
|
|
622
675
|
* Never emit a bare `task ...` remediation in a project without Taskfile wiring
|
|
623
676
|
* (#2267). The `directive` surface always works; `task deft:X` is optional and
|
|
@@ -2,6 +2,26 @@ import { readTextSafe } from "./paths.js";
|
|
|
2
2
|
export declare function parseManifest(text: string): Record<string, string>;
|
|
3
3
|
export declare function parseInstallManifest(text: string): Record<string, string>;
|
|
4
4
|
export declare function manifestTagToVersion(manifest: Record<string, string>): string | null;
|
|
5
|
+
/** Reportability classes for an install manifest's version provenance (#2294). */
|
|
6
|
+
export type ManifestVersionSource = "tag" | "ref" | "sha" | "none";
|
|
7
|
+
export interface ReportableVersion {
|
|
8
|
+
/** Semver derived from `tag`/`ref` (leading `v` stripped), else null. */
|
|
9
|
+
readonly version: string | null;
|
|
10
|
+
/** Recorded commit `sha`, trimmed, else null. */
|
|
11
|
+
readonly sha: string | null;
|
|
12
|
+
/** Where a reportable identity was found. */
|
|
13
|
+
readonly source: ManifestVersionSource;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Classify what a manifest can report as its version (#2294). A legacy
|
|
17
|
+
* `deft-install` deposit made without a release pin writes empty `tag`/`ref`
|
|
18
|
+
* and only a short `sha`; `manifestTagToVersion` then returns null and the
|
|
19
|
+
* version is silently unreportable. This helper distinguishes a pinned
|
|
20
|
+
* semver (`tag`/`ref`) from a sha-only deposit from a manifest with no
|
|
21
|
+
* provenance at all, so callers can surface an actionable signal instead of a
|
|
22
|
+
* blank.
|
|
23
|
+
*/
|
|
24
|
+
export declare function manifestReportableVersion(manifest: Record<string, string>): ReportableVersion;
|
|
5
25
|
export declare function manifestCandidatePaths(projectRoot: string, installRoot: string | null): string[];
|
|
6
26
|
export declare function locateManifest(projectRoot: string, installRoot: string | null, isFile?: (p: string) => boolean): string | null;
|
|
7
27
|
export declare function parseInstallRootFromAgentsMd(text: string): string | null;
|
package/dist/doctor/manifest.js
CHANGED
|
@@ -43,6 +43,28 @@ export function manifestTagToVersion(manifest) {
|
|
|
43
43
|
}
|
|
44
44
|
return null;
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Classify what a manifest can report as its version (#2294). A legacy
|
|
48
|
+
* `deft-install` deposit made without a release pin writes empty `tag`/`ref`
|
|
49
|
+
* and only a short `sha`; `manifestTagToVersion` then returns null and the
|
|
50
|
+
* version is silently unreportable. This helper distinguishes a pinned
|
|
51
|
+
* semver (`tag`/`ref`) from a sha-only deposit from a manifest with no
|
|
52
|
+
* provenance at all, so callers can surface an actionable signal instead of a
|
|
53
|
+
* blank.
|
|
54
|
+
*/
|
|
55
|
+
export function manifestReportableVersion(manifest) {
|
|
56
|
+
const version = manifestTagToVersion(manifest);
|
|
57
|
+
if (version !== null) {
|
|
58
|
+
const tag = typeof manifest.tag === "string" ? manifest.tag.trim() : "";
|
|
59
|
+
return { version, sha: readSha(manifest), source: tag ? "tag" : "ref" };
|
|
60
|
+
}
|
|
61
|
+
const sha = readSha(manifest);
|
|
62
|
+
return { version: null, sha, source: sha !== null ? "sha" : "none" };
|
|
63
|
+
}
|
|
64
|
+
function readSha(manifest) {
|
|
65
|
+
const raw = typeof manifest.sha === "string" ? manifest.sha.trim() : "";
|
|
66
|
+
return raw || null;
|
|
67
|
+
}
|
|
46
68
|
export function manifestCandidatePaths(projectRoot, installRoot) {
|
|
47
69
|
const raw = [];
|
|
48
70
|
if (installRoot) {
|
package/dist/doctor/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AdvisoryEvaluateResult } from "../agents-md-advisory/evaluate.js";
|
|
2
|
+
import type { ShadowedPlanExtension } from "../policy/plan-extensions.js";
|
|
2
3
|
import type { EngineProbeResult } from "../resolution/classify.js";
|
|
3
4
|
import type { ResolutionMode } from "../resolution/index.js";
|
|
4
5
|
import type { ResolveUserMdResult } from "../user-config/resolve-user-md.js";
|
|
@@ -127,5 +128,12 @@ export interface DoctorSeams {
|
|
|
127
128
|
* first-hit-wins resolver scoped to the project root.
|
|
128
129
|
*/
|
|
129
130
|
readonly resolveUserMd?: (projectRoot: string) => ResolveUserMdResult;
|
|
131
|
+
/**
|
|
132
|
+
* Plan-extension shadow detector seam (#2301). Injected so the doctor
|
|
133
|
+
* shadow-diagnostic surface stays deterministic + offline in tests. Defaults
|
|
134
|
+
* to loading PROJECT-DEFINITION and running `detectShadowedPlanExtensions` on
|
|
135
|
+
* its `plan` object; returns [] when no project definition is present.
|
|
136
|
+
*/
|
|
137
|
+
readonly detectPlanExtensionShadows?: (projectRoot: string) => readonly ShadowedPlanExtension[];
|
|
130
138
|
}
|
|
131
139
|
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export declare const CRUD_METRICS_HISTORY_REL = "results/crud-metrics.jsonl";
|
|
2
|
+
export type CrudOperation = "create" | "read" | "update" | "delete";
|
|
3
|
+
export type ByteDiffMinimality = "surgical" | "whole-file-rewrite";
|
|
4
|
+
export interface CrudOperationMetric {
|
|
5
|
+
readonly directiveVersion: string;
|
|
6
|
+
readonly operation: CrudOperation;
|
|
7
|
+
readonly path: string;
|
|
8
|
+
readonly schemaValid: boolean;
|
|
9
|
+
readonly schemaErrors: readonly string[];
|
|
10
|
+
readonly fieldInventionCount: number;
|
|
11
|
+
readonly inventedKeys: readonly string[];
|
|
12
|
+
readonly byteDiffMinimality: ByteDiffMinimality | null;
|
|
13
|
+
readonly byteDiffChangedRatio: number | null;
|
|
14
|
+
readonly recordedAt: string;
|
|
15
|
+
}
|
|
16
|
+
export interface CrudResult {
|
|
17
|
+
readonly ok: boolean;
|
|
18
|
+
readonly content?: string;
|
|
19
|
+
readonly error?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface InstrumentedVbriefCrudOptions {
|
|
22
|
+
readonly directiveVersion?: string;
|
|
23
|
+
readonly now?: () => Date;
|
|
24
|
+
}
|
|
25
|
+
/** Whole-file rewrites change at least half of the bytes or re-serialize with high drift. */
|
|
26
|
+
export declare const BYTE_DIFF_WHOLE_FILE_THRESHOLD = 0.5;
|
|
27
|
+
/** Ratio of edit distance to the longer string length. */
|
|
28
|
+
export declare function computeChangedByteRatio(before: string, after: string): number;
|
|
29
|
+
/** Classify whether an update preserved bytes (surgical) or rewrote the file. */
|
|
30
|
+
export declare function classifyByteDiffMinimality(before: string, after: string): {
|
|
31
|
+
readonly kind: ByteDiffMinimality;
|
|
32
|
+
readonly changedRatio: number;
|
|
33
|
+
};
|
|
34
|
+
/** Absolute path to the versioned CRUD metrics ledger (#1703 Tier 1). */
|
|
35
|
+
export declare function crudMetricsHistoryPath(projectRoot: string): string;
|
|
36
|
+
/** Append CRUD operation metrics to the versioned ledger (#1703 Tier 1). */
|
|
37
|
+
export declare function persistCrudMetrics(projectRoot: string, metrics: readonly CrudOperationMetric[]): void;
|
|
38
|
+
/**
|
|
39
|
+
* Instrumented vBRIEF/xBRIEF CRUD chokepoint (#1703 Tier 1).
|
|
40
|
+
* Every operation emits a version-tagged metric covering schema validity,
|
|
41
|
+
* field invention, and (for updates) byte-diff minimality.
|
|
42
|
+
* Delete intentionally succeeds on schema-invalid files so corrupt artifacts can be removed.
|
|
43
|
+
*/
|
|
44
|
+
export declare class InstrumentedVbriefCrud {
|
|
45
|
+
private readonly directiveVersion;
|
|
46
|
+
private readonly now;
|
|
47
|
+
private readonly metrics;
|
|
48
|
+
constructor(options?: InstrumentedVbriefCrudOptions);
|
|
49
|
+
getMetrics(): readonly CrudOperationMetric[];
|
|
50
|
+
clearMetrics(): void;
|
|
51
|
+
create(path: string, content: string): CrudResult;
|
|
52
|
+
read(path: string): CrudResult;
|
|
53
|
+
update(path: string, content: string, options?: {
|
|
54
|
+
trustedWrite?: boolean;
|
|
55
|
+
}): CrudResult;
|
|
56
|
+
delete(path: string): CrudResult;
|
|
57
|
+
private recordMetric;
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=crud-telemetry.d.ts.map
|