etymd 0.14.0 → 0.16.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/CHANGELOG.md +52 -16
- package/README.md +114 -47
- package/dist/{approve-QDOH2E7V.js → approve-K5FFTPA4.js} +2 -2
- package/dist/{audit-42OHF7ON.js → audit-HBNXNGD6.js} +5 -4
- package/dist/{brief-ZUQK3CFL.js → brief-DOQ2DQEG.js} +2 -2
- package/dist/{fleet-F3XDEDCV.js → chunk-ANXGT7V3.js} +431 -466
- package/dist/{chunk-HWC6PKJM.js → chunk-EJM47PIF.js} +1 -1
- package/dist/{chunk-PAMWS665.js → chunk-FKDT4RBP.js} +2 -1
- package/dist/{chunk-UFNETE6P.js → chunk-OKJ3RGJB.js} +1 -1
- package/dist/{chunk-LT7JMVV3.js → chunk-UV3YIBDW.js} +43 -730
- package/dist/{chunk-623W6KVC.js → chunk-UYCGP5BS.js} +1 -1
- package/dist/chunk-Y4E6ERCM.js +783 -0
- package/dist/cli.js +78 -22
- package/dist/{doctor-CE5SD4JW.js → doctor-X6XAGKV7.js} +5 -4
- package/dist/fleet-UEFLKIRI.js +489 -0
- package/dist/{gates-F4YPKWJR.js → gates-NVWWKMT2.js} +3 -3
- package/dist/{generate-KEX75XNG.js → generate-YEARCSHW.js} +1 -1
- package/dist/index.d.ts +301 -20
- package/dist/index.js +958 -233
- package/dist/{init-AHA7VRRI.js → init-Z5O2WVRJ.js} +3 -3
- package/dist/premise-4QRU7LRI.js +368 -0
- package/dist/propose-IKE4CUIM.js +252 -0
- package/dist/scan-64Z6SNYF.js +5 -0
- package/dist/{scan-TVX3FKK4.js → scan-H3NF5IV5.js} +2 -2
- package/package.json +5 -5
- package/dist/scan-QRTH6HAP.js +0 -5
package/dist/index.js
CHANGED
|
@@ -30,8 +30,8 @@ var init_package = __esm({
|
|
|
30
30
|
"package.json"() {
|
|
31
31
|
package_default = {
|
|
32
32
|
name: "etymd",
|
|
33
|
-
version: "0.
|
|
34
|
-
description: "Keep your agent instructions true \u2014 verify AGENTS.md, CLAUDE.md, rules
|
|
33
|
+
version: "0.16.0",
|
|
34
|
+
description: "Keep your agent instructions true \u2014 verify AGENTS.md, CLAUDE.md, rules, skills, and the task you hand an agent against the actual repo, with drift caught over time and a regression ledger.",
|
|
35
35
|
keywords: [
|
|
36
36
|
"cli",
|
|
37
37
|
"ai",
|
|
@@ -49,11 +49,11 @@ var init_package = __esm({
|
|
|
49
49
|
author: "Alkis Yuv",
|
|
50
50
|
repository: {
|
|
51
51
|
type: "git",
|
|
52
|
-
url: "git+https://github.com/
|
|
52
|
+
url: "git+https://github.com/fleetorders/etymd.git"
|
|
53
53
|
},
|
|
54
|
-
homepage: "https://github.com/
|
|
54
|
+
homepage: "https://github.com/fleetorders/etymd#readme",
|
|
55
55
|
bugs: {
|
|
56
|
-
url: "https://github.com/
|
|
56
|
+
url: "https://github.com/fleetorders/etymd/issues"
|
|
57
57
|
},
|
|
58
58
|
type: "module",
|
|
59
59
|
bin: {
|
|
@@ -1171,7 +1171,7 @@ function generatePreCommitHook(selfBuild = false) {
|
|
|
1171
1171
|
|
|
1172
1172
|
${localHookCall("pre-commit")}
|
|
1173
1173
|
|
|
1174
|
-
# Content screen \u2014 staged file bytes. Refuses to commit environment,
|
|
1174
|
+
# Content screen \u2014 staged file bytes. Refuses to commit environment, guarded-side or identity
|
|
1175
1175
|
# detail into a repo whose history is (or could become) public. The checker and its patterns
|
|
1176
1176
|
# are machine-local by design, so this is a NO-OP wherever no checker is installed: safe to
|
|
1177
1177
|
# commit anywhere, active only where you opted in.
|
|
@@ -2324,6 +2324,8 @@ var gateIntegrityLens = {
|
|
|
2324
2324
|
init_version();
|
|
2325
2325
|
init_config();
|
|
2326
2326
|
init_facts();
|
|
2327
|
+
|
|
2328
|
+
// src/lenses/instruction-truth/checks.ts
|
|
2327
2329
|
init_util();
|
|
2328
2330
|
|
|
2329
2331
|
// src/lenses/state-freshness.ts
|
|
@@ -2760,6 +2762,13 @@ var PLACEHOLDER_PREFIX_RE = /^(?:my|your)-/i;
|
|
|
2760
2762
|
function isPlaceholderClaim(token) {
|
|
2761
2763
|
return token.split("/").some((seg) => PLACEHOLDER_PREFIX_RE.test(seg) || PLACEHOLDER_SEGMENTS.has(seg.toLowerCase()));
|
|
2762
2764
|
}
|
|
2765
|
+
var NAMESPACE_IDENT = "[A-Za-z][A-Za-z0-9-]{0,15}";
|
|
2766
|
+
var NAMESPACE_STOP = new Set(
|
|
2767
|
+
"note notes see warning caution caveat caveats example examples ex eg ie nb ps re per file files path paths hint tip tips todo fixme step steps rule rules ref refs fact facts output outputs input inputs result results summary status overview next prev then thus hence plus goal goals spec specs context".split(" ")
|
|
2768
|
+
);
|
|
2769
|
+
function isNamespace(ident) {
|
|
2770
|
+
return Boolean(ident) && !NAMESPACE_STOP.has(ident.toLowerCase());
|
|
2771
|
+
}
|
|
2763
2772
|
function claimContext(text, index) {
|
|
2764
2773
|
const start = text.lastIndexOf("\n", index) + 1;
|
|
2765
2774
|
const endRaw = text.indexOf("\n", index);
|
|
@@ -2779,8 +2788,9 @@ ${line}`;
|
|
|
2779
2788
|
}
|
|
2780
2789
|
return line;
|
|
2781
2790
|
}
|
|
2782
|
-
function extractPathClaims(text) {
|
|
2791
|
+
function extractPathClaims(text, opts = {}) {
|
|
2783
2792
|
const prospectiveOnly = /* @__PURE__ */ new Map();
|
|
2793
|
+
const namespacedOnly = /* @__PURE__ */ new Map();
|
|
2784
2794
|
const placeholder = /* @__PURE__ */ new Set();
|
|
2785
2795
|
for (const m of text.matchAll(/`([^`\n]+)`/g)) {
|
|
2786
2796
|
const token = m[1].trim();
|
|
@@ -2802,14 +2812,21 @@ function extractPathClaims(text) {
|
|
|
2802
2812
|
}
|
|
2803
2813
|
const prospective2 = CREATION_CONTEXT_RE.test(claimContext(text, m.index ?? 0));
|
|
2804
2814
|
prospectiveOnly.set(claim, (prospectiveOnly.get(claim) ?? true) && prospective2);
|
|
2815
|
+
if (opts.namespaces) {
|
|
2816
|
+
const nsLead = new RegExp(`(${NAMESPACE_IDENT}):[ \\t]*$`).exec(text.slice(0, m.index ?? 0));
|
|
2817
|
+
const prefixed = isNamespace(nsLead?.[1]);
|
|
2818
|
+
namespacedOnly.set(claim, (namespacedOnly.get(claim) ?? true) && prefixed);
|
|
2819
|
+
}
|
|
2805
2820
|
}
|
|
2806
2821
|
const paths = [];
|
|
2807
2822
|
const prospective = [];
|
|
2823
|
+
const namespaced = [];
|
|
2808
2824
|
for (const [claim, only] of prospectiveOnly) {
|
|
2809
2825
|
if (only) prospective.push(claim);
|
|
2826
|
+
else if (namespacedOnly.get(claim)) namespaced.push(claim);
|
|
2810
2827
|
else paths.push(claim);
|
|
2811
2828
|
}
|
|
2812
|
-
return { paths, prospective, placeholder: [...placeholder] };
|
|
2829
|
+
return { paths, prospective, namespaced, placeholder: [...placeholder] };
|
|
2813
2830
|
}
|
|
2814
2831
|
var LOCAL_REF_LEADINS = new Set(
|
|
2815
2832
|
"decision decisions entry entries ruling rulings record records ledger id ids item items see per in of on at by to as is was are were the a an and or but not with under over from via vs than after before since between through against latest newest earliest only also still now supersedes superseded superseding amends amended extends extended cites cited citing adds added adding wrote written writes locked locks closed closes opened opens resolves resolved reopened recorded number numbers".split(" ")
|
|
@@ -2871,6 +2888,214 @@ function extractDocRefs(text) {
|
|
|
2871
2888
|
return { refs, tildeSkipped };
|
|
2872
2889
|
}
|
|
2873
2890
|
|
|
2891
|
+
// src/lenses/instruction-truth/checks.ts
|
|
2892
|
+
async function buildTruthEnv(root, facts) {
|
|
2893
|
+
const knownScripts = new Set(Object.keys(facts.commands.raw));
|
|
2894
|
+
for (const pkg of facts.packages) {
|
|
2895
|
+
const pkgJson = await readJson(
|
|
2896
|
+
path2.join(root, pkg.dir, "package.json")
|
|
2897
|
+
);
|
|
2898
|
+
for (const key of Object.keys(pkgJson?.scripts ?? {})) knownScripts.add(key);
|
|
2899
|
+
}
|
|
2900
|
+
const bases = [root, ...facts.packages.map((p) => path2.join(root, p.dir))];
|
|
2901
|
+
const pathResolves = async (claim) => {
|
|
2902
|
+
for (const base of bases) {
|
|
2903
|
+
if (await pathExists(path2.join(base, claim))) return true;
|
|
2904
|
+
if (await pathExists(path2.join(base, "src", claim))) return true;
|
|
2905
|
+
if (await pathExists(path2.join(base, "scripts", claim))) return true;
|
|
2906
|
+
}
|
|
2907
|
+
return false;
|
|
2908
|
+
};
|
|
2909
|
+
const binResolves = async (name) => {
|
|
2910
|
+
for (const base of bases) {
|
|
2911
|
+
if (await pathExists(path2.join(base, "node_modules", ".bin", name))) return true;
|
|
2912
|
+
}
|
|
2913
|
+
return false;
|
|
2914
|
+
};
|
|
2915
|
+
const nodeModulesInstalled = await pathExists(path2.join(root, "node_modules"));
|
|
2916
|
+
const manifestExists = await pathExists(path2.join(root, "package.json")) || facts.packages.length > 0;
|
|
2917
|
+
return {
|
|
2918
|
+
root,
|
|
2919
|
+
facts,
|
|
2920
|
+
knownScripts,
|
|
2921
|
+
nodeModulesInstalled,
|
|
2922
|
+
manifestExists,
|
|
2923
|
+
bases,
|
|
2924
|
+
pathResolves,
|
|
2925
|
+
binResolves
|
|
2926
|
+
};
|
|
2927
|
+
}
|
|
2928
|
+
function emptyCounters() {
|
|
2929
|
+
return {
|
|
2930
|
+
filteredSkipped: 0,
|
|
2931
|
+
tildeSkipped: 0,
|
|
2932
|
+
binaryResolved: 0,
|
|
2933
|
+
unverifiableCommands: 0,
|
|
2934
|
+
gitignoredSkipped: 0,
|
|
2935
|
+
prospectiveSkipped: 0,
|
|
2936
|
+
placeholderSkipped: 0,
|
|
2937
|
+
qualifiedRefsSkipped: 0,
|
|
2938
|
+
unresolvableRefs: 0,
|
|
2939
|
+
namespacedSkipped: 0,
|
|
2940
|
+
outsideRepoSkipped: 0
|
|
2941
|
+
};
|
|
2942
|
+
}
|
|
2943
|
+
async function checkTextClaims(env, file, opts, counters) {
|
|
2944
|
+
const findings = [];
|
|
2945
|
+
const disclosures = [];
|
|
2946
|
+
const examined = [];
|
|
2947
|
+
const subject = opts.subject ?? file.path;
|
|
2948
|
+
const { scripts: claimed, filteredSkipped } = extractCommandClaims(file.text);
|
|
2949
|
+
counters.filteredSkipped += filteredSkipped;
|
|
2950
|
+
for (const [script, raw] of claimed) {
|
|
2951
|
+
if (env.knownScripts.has(script)) {
|
|
2952
|
+
examined.push({ kind: "script", value: script, exists: true });
|
|
2953
|
+
continue;
|
|
2954
|
+
}
|
|
2955
|
+
if (await env.binResolves(script)) {
|
|
2956
|
+
counters.binaryResolved += 1;
|
|
2957
|
+
examined.push({ kind: "script", value: script, exists: true });
|
|
2958
|
+
continue;
|
|
2959
|
+
}
|
|
2960
|
+
if (!env.nodeModulesInstalled && env.manifestExists) {
|
|
2961
|
+
counters.unverifiableCommands += 1;
|
|
2962
|
+
examined.push({ kind: "script", value: script, exists: null });
|
|
2963
|
+
continue;
|
|
2964
|
+
}
|
|
2965
|
+
examined.push({ kind: "script", value: script, exists: false });
|
|
2966
|
+
findings.push({
|
|
2967
|
+
lens: opts.lensId,
|
|
2968
|
+
id: `${opts.lensId}/stale-command:${file.path}:${script}`,
|
|
2969
|
+
tier: "risk",
|
|
2970
|
+
claim: `${subject} tells agents to run \`${script}\` \u2014 no such script exists`,
|
|
2971
|
+
evidence: [`${file.path}: \`${raw}\``, "package.json scripts (root + workspaces)"],
|
|
2972
|
+
why: opts.whyCommand ?? "An agent following this instruction runs a command that fails \u2014 or silently skips the check it was meant to run.",
|
|
2973
|
+
action: opts.actionCommand ?? "Update the instruction to the current script name (or restore the script).",
|
|
2974
|
+
effort: "S",
|
|
2975
|
+
confidence: "high"
|
|
2976
|
+
});
|
|
2977
|
+
}
|
|
2978
|
+
const { paths, prospective, placeholder, namespaced } = extractPathClaims(file.text, {
|
|
2979
|
+
namespaces: opts.treatNamespacedPrefixes
|
|
2980
|
+
});
|
|
2981
|
+
counters.prospectiveSkipped += prospective.length;
|
|
2982
|
+
counters.placeholderSkipped += placeholder.length;
|
|
2983
|
+
counters.namespacedSkipped += namespaced.length;
|
|
2984
|
+
const missing = [];
|
|
2985
|
+
for (const claim of paths) {
|
|
2986
|
+
if (await env.pathResolves(claim)) examined.push({ kind: "path", value: claim, exists: true });
|
|
2987
|
+
else missing.push(claim);
|
|
2988
|
+
}
|
|
2989
|
+
const ignoredOut = missing.length ? await git(env.root, ["check-ignore", ...missing]) : null;
|
|
2990
|
+
const gitignored = new Set((ignoredOut ?? "").split("\n").filter(Boolean));
|
|
2991
|
+
let pathFindings = 0;
|
|
2992
|
+
for (const claim of missing) {
|
|
2993
|
+
if (opts.rootedFirstSegments && !opts.rootedFirstSegments.has(claim.split("/")[0] ?? claim)) {
|
|
2994
|
+
counters.outsideRepoSkipped += 1;
|
|
2995
|
+
examined.push({ kind: "path", value: claim, exists: null });
|
|
2996
|
+
continue;
|
|
2997
|
+
}
|
|
2998
|
+
if (gitignored.has(claim)) {
|
|
2999
|
+
counters.gitignoredSkipped += 1;
|
|
3000
|
+
examined.push({ kind: "path", value: claim, exists: null });
|
|
3001
|
+
continue;
|
|
3002
|
+
}
|
|
3003
|
+
examined.push({ kind: "path", value: claim, exists: false });
|
|
3004
|
+
if (pathFindings >= opts.maxPathFindings) {
|
|
3005
|
+
if (pathFindings === opts.maxPathFindings) {
|
|
3006
|
+
disclosures.push(
|
|
3007
|
+
`${file.path}: more than ${opts.maxPathFindings} missing-path claims \u2014 truncated.`
|
|
3008
|
+
);
|
|
3009
|
+
pathFindings += 1;
|
|
3010
|
+
}
|
|
3011
|
+
continue;
|
|
3012
|
+
}
|
|
3013
|
+
pathFindings += 1;
|
|
3014
|
+
findings.push({
|
|
3015
|
+
lens: opts.lensId,
|
|
3016
|
+
id: `${opts.lensId}/stale-path:${file.path}:${claim}`,
|
|
3017
|
+
tier: opts.missingPathTier,
|
|
3018
|
+
claim: `${subject} references \`${claim}\` \u2014 it does not exist in the repo`,
|
|
3019
|
+
evidence: [file.path, `missing: ${claim}`],
|
|
3020
|
+
why: opts.whyPath ?? "Agents navigate by these references; a dead path wastes a lookup and erodes trust in the rest of the file.",
|
|
3021
|
+
action: opts.actionPath ?? "Fix or remove the reference.",
|
|
3022
|
+
effort: "S",
|
|
3023
|
+
confidence: "medium"
|
|
3024
|
+
});
|
|
3025
|
+
}
|
|
3026
|
+
return { findings, disclosures, examined };
|
|
3027
|
+
}
|
|
3028
|
+
async function checkDocRefs(env, file, lensId, counters, subject = file.path) {
|
|
3029
|
+
const findings = [];
|
|
3030
|
+
const examined = [];
|
|
3031
|
+
const { refs, tildeSkipped } = extractDocRefs(file.text);
|
|
3032
|
+
counters.tildeSkipped += tildeSkipped;
|
|
3033
|
+
for (const ref of refs) {
|
|
3034
|
+
const exists = await pathExists(path2.join(env.root, ref));
|
|
3035
|
+
examined.push({ kind: "doc", value: ref, exists });
|
|
3036
|
+
if (exists) continue;
|
|
3037
|
+
findings.push({
|
|
3038
|
+
lens: lensId,
|
|
3039
|
+
id: `${lensId}/dangling-ref:${file.path}:${ref}`,
|
|
3040
|
+
tier: "gap",
|
|
3041
|
+
claim: `${subject} references ${ref} \u2014 no such file exists`,
|
|
3042
|
+
evidence: [file.path, `missing: ${ref}`],
|
|
3043
|
+
why: "The pointer chain agents follow breaks at a file they can never read.",
|
|
3044
|
+
action: `Create ${ref} or remove the reference.`,
|
|
3045
|
+
effort: "S",
|
|
3046
|
+
confidence: "high"
|
|
3047
|
+
});
|
|
3048
|
+
}
|
|
3049
|
+
return { findings, examined };
|
|
3050
|
+
}
|
|
3051
|
+
async function loadDecisionLedger(root, facts) {
|
|
3052
|
+
let ids = null;
|
|
3053
|
+
const sources = [];
|
|
3054
|
+
for (const artifact of facts.artifacts) {
|
|
3055
|
+
if (artifact.kind !== "decisions" || !artifact.exists) continue;
|
|
3056
|
+
const text = await readText(path2.join(root, artifact.path));
|
|
3057
|
+
if (text === null) continue;
|
|
3058
|
+
const entries = parseDecisionEntries(text);
|
|
3059
|
+
if (!entries.length) continue;
|
|
3060
|
+
ids ??= /* @__PURE__ */ new Set();
|
|
3061
|
+
for (const entry of entries) ids.add(entry.num);
|
|
3062
|
+
sources.push(artifact.path);
|
|
3063
|
+
}
|
|
3064
|
+
return { ids, sources };
|
|
3065
|
+
}
|
|
3066
|
+
function checkDecisionRefs(file, ledger, opts, counters) {
|
|
3067
|
+
const findings = [];
|
|
3068
|
+
const examined = [];
|
|
3069
|
+
const subject = opts.subject ?? file.path;
|
|
3070
|
+
const { refs, qualifiedSkipped } = extractDecisionRefs(file.text);
|
|
3071
|
+
counters.qualifiedRefsSkipped += qualifiedSkipped;
|
|
3072
|
+
if (!refs.size) return { findings, examined };
|
|
3073
|
+
if (!ledger.ids) {
|
|
3074
|
+
counters.unresolvableRefs += refs.size;
|
|
3075
|
+
for (const asWritten of refs.values()) {
|
|
3076
|
+
examined.push({ kind: "decision", value: asWritten, exists: null });
|
|
3077
|
+
}
|
|
3078
|
+
return { findings, examined };
|
|
3079
|
+
}
|
|
3080
|
+
for (const [num, asWritten] of refs) {
|
|
3081
|
+
const exists = ledger.ids.has(num);
|
|
3082
|
+
examined.push({ kind: "decision", value: asWritten, exists });
|
|
3083
|
+
if (exists) continue;
|
|
3084
|
+
findings.push({
|
|
3085
|
+
lens: opts.lensId,
|
|
3086
|
+
id: `${opts.lensId}/dead-decision-ref:${file.path}:${asWritten}`,
|
|
3087
|
+
tier: "gap",
|
|
3088
|
+
claim: `${subject} cites ${asWritten} \u2014 no such entry exists in ${ledger.sources.join(", ")}`,
|
|
3089
|
+
evidence: [file.path, `${ledger.sources.join(", ")}: no ${asWritten} entry`],
|
|
3090
|
+
why: opts.why ?? "A state doc is read as ground truth on return; a citation the decision record cannot back sends readers to a ruling that was never written.",
|
|
3091
|
+
action: opts.action ?? "Fix the reference \u2014 or record the missing decision.",
|
|
3092
|
+
effort: "S",
|
|
3093
|
+
confidence: "medium"
|
|
3094
|
+
});
|
|
3095
|
+
}
|
|
3096
|
+
return { findings, examined };
|
|
3097
|
+
}
|
|
3098
|
+
|
|
2874
3099
|
// src/lenses/instruction-truth/lens.ts
|
|
2875
3100
|
var LENS_ID4 = "instruction-truth";
|
|
2876
3101
|
var MAX_PATH_FINDINGS_PER_FILE = 15;
|
|
@@ -2965,99 +3190,17 @@ var instructionTruthLens = {
|
|
|
2965
3190
|
})
|
|
2966
3191
|
);
|
|
2967
3192
|
}
|
|
2968
|
-
const
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
}
|
|
2975
|
-
const pathResolves = async (claim) => {
|
|
2976
|
-
const bases = [root, ...facts.packages.map((p) => path2.join(root, p.dir))];
|
|
2977
|
-
for (const base of bases) {
|
|
2978
|
-
if (await pathExists(path2.join(base, claim))) return true;
|
|
2979
|
-
if (await pathExists(path2.join(base, "src", claim))) return true;
|
|
2980
|
-
if (await pathExists(path2.join(base, "scripts", claim))) return true;
|
|
2981
|
-
}
|
|
2982
|
-
return false;
|
|
3193
|
+
const env = await buildTruthEnv(root, facts);
|
|
3194
|
+
const counters = emptyCounters();
|
|
3195
|
+
const claimOpts = {
|
|
3196
|
+
lensId: LENS_ID4,
|
|
3197
|
+
missingPathTier: "gap",
|
|
3198
|
+
maxPathFindings: MAX_PATH_FINDINGS_PER_FILE
|
|
2983
3199
|
};
|
|
2984
|
-
const binResolves = async (name) => {
|
|
2985
|
-
const bases = [root, ...facts.packages.map((p) => path2.join(root, p.dir))];
|
|
2986
|
-
for (const base of bases) {
|
|
2987
|
-
if (await pathExists(path2.join(base, "node_modules", ".bin", name))) return true;
|
|
2988
|
-
}
|
|
2989
|
-
return false;
|
|
2990
|
-
};
|
|
2991
|
-
const nodeModulesInstalled = await pathExists(path2.join(root, "node_modules"));
|
|
2992
|
-
const manifestExists = await pathExists(path2.join(root, "package.json")) || facts.packages.length > 0;
|
|
2993
|
-
let totalFilteredSkipped = 0;
|
|
2994
|
-
let totalTildeSkipped = 0;
|
|
2995
|
-
let binaryResolved = 0;
|
|
2996
|
-
let unverifiableCommands = 0;
|
|
2997
|
-
let gitignoredSkipped = 0;
|
|
2998
|
-
let prospectiveSkipped = 0;
|
|
2999
|
-
let placeholderSkipped = 0;
|
|
3000
3200
|
const auditClaims = async (file) => {
|
|
3001
|
-
const
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
if (knownScripts.has(script)) continue;
|
|
3005
|
-
if (await binResolves(script)) {
|
|
3006
|
-
binaryResolved += 1;
|
|
3007
|
-
continue;
|
|
3008
|
-
}
|
|
3009
|
-
if (!nodeModulesInstalled && manifestExists) {
|
|
3010
|
-
unverifiableCommands += 1;
|
|
3011
|
-
continue;
|
|
3012
|
-
}
|
|
3013
|
-
findings.push(
|
|
3014
|
-
finding2({
|
|
3015
|
-
id: `${LENS_ID4}/stale-command:${file.path}:${script}`,
|
|
3016
|
-
tier: "risk",
|
|
3017
|
-
claim: `${file.path} tells agents to run \`${script}\` \u2014 no such script exists`,
|
|
3018
|
-
evidence: [`${file.path}: \`${raw}\``, "package.json scripts (root + workspaces)"],
|
|
3019
|
-
why: "An agent following this instruction runs a command that fails \u2014 or silently skips the check it was meant to run.",
|
|
3020
|
-
action: "Update the instruction to the current script name (or restore the script).",
|
|
3021
|
-
effort: "S",
|
|
3022
|
-
confidence: "high"
|
|
3023
|
-
})
|
|
3024
|
-
);
|
|
3025
|
-
}
|
|
3026
|
-
const { paths, prospective, placeholder } = extractPathClaims(file.text);
|
|
3027
|
-
prospectiveSkipped += prospective.length;
|
|
3028
|
-
placeholderSkipped += placeholder.length;
|
|
3029
|
-
const missing = [];
|
|
3030
|
-
for (const claim of paths) {
|
|
3031
|
-
if (!await pathResolves(claim)) missing.push(claim);
|
|
3032
|
-
}
|
|
3033
|
-
const ignoredOut = missing.length ? await git(root, ["check-ignore", ...missing]) : null;
|
|
3034
|
-
const gitignored = new Set((ignoredOut ?? "").split("\n").filter(Boolean));
|
|
3035
|
-
let pathFindings = 0;
|
|
3036
|
-
for (const claim of missing) {
|
|
3037
|
-
if (gitignored.has(claim)) {
|
|
3038
|
-
gitignoredSkipped += 1;
|
|
3039
|
-
continue;
|
|
3040
|
-
}
|
|
3041
|
-
if (pathFindings >= MAX_PATH_FINDINGS_PER_FILE) {
|
|
3042
|
-
disclosures.push(
|
|
3043
|
-
`${file.path}: more than ${MAX_PATH_FINDINGS_PER_FILE} missing-path claims \u2014 truncated.`
|
|
3044
|
-
);
|
|
3045
|
-
break;
|
|
3046
|
-
}
|
|
3047
|
-
pathFindings += 1;
|
|
3048
|
-
findings.push(
|
|
3049
|
-
finding2({
|
|
3050
|
-
id: `${LENS_ID4}/stale-path:${file.path}:${claim}`,
|
|
3051
|
-
tier: "gap",
|
|
3052
|
-
claim: `${file.path} references \`${claim}\` \u2014 it does not exist in the repo`,
|
|
3053
|
-
evidence: [file.path, `missing: ${claim}`],
|
|
3054
|
-
why: "Agents navigate by these references; a dead path wastes a lookup and erodes trust in the rest of the file.",
|
|
3055
|
-
action: "Fix or remove the reference.",
|
|
3056
|
-
effort: "S",
|
|
3057
|
-
confidence: "medium"
|
|
3058
|
-
})
|
|
3059
|
-
);
|
|
3060
|
-
}
|
|
3201
|
+
const result = await checkTextClaims(env, file, claimOpts, counters);
|
|
3202
|
+
findings.push(...result.findings);
|
|
3203
|
+
disclosures.push(...result.disclosures);
|
|
3061
3204
|
};
|
|
3062
3205
|
for (const file of files) {
|
|
3063
3206
|
await auditClaims(file);
|
|
@@ -3081,66 +3224,15 @@ var instructionTruthLens = {
|
|
|
3081
3224
|
break;
|
|
3082
3225
|
}
|
|
3083
3226
|
}
|
|
3084
|
-
|
|
3085
|
-
totalTildeSkipped += tildeSkipped;
|
|
3086
|
-
for (const ref of docRefs) {
|
|
3087
|
-
if (await pathExists(path2.join(root, ref))) continue;
|
|
3088
|
-
findings.push(
|
|
3089
|
-
finding2({
|
|
3090
|
-
id: `${LENS_ID4}/dangling-ref:${file.path}:${ref}`,
|
|
3091
|
-
tier: "gap",
|
|
3092
|
-
claim: `${file.path} references ${ref} \u2014 no such file exists`,
|
|
3093
|
-
evidence: [file.path, `missing: ${ref}`],
|
|
3094
|
-
why: "The pointer chain agents follow breaks at a file they can never read.",
|
|
3095
|
-
action: `Create ${ref} or remove the reference.`,
|
|
3096
|
-
effort: "S",
|
|
3097
|
-
confidence: "high"
|
|
3098
|
-
})
|
|
3099
|
-
);
|
|
3100
|
-
}
|
|
3227
|
+
findings.push(...(await checkDocRefs(env, file, LENS_ID4, counters)).findings);
|
|
3101
3228
|
}
|
|
3102
3229
|
const auditedPaths = new Set(files.map((f) => f.path));
|
|
3103
3230
|
const stateDocs = await listStateDocuments(root, facts);
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
let ledgerIds = null;
|
|
3107
|
-
const ledgerSources = [];
|
|
3108
|
-
if (stateDocs.length) {
|
|
3109
|
-
for (const artifact of facts.artifacts) {
|
|
3110
|
-
if (artifact.kind !== "decisions" || !artifact.exists) continue;
|
|
3111
|
-
const text = await readText(path2.join(root, artifact.path));
|
|
3112
|
-
if (text === null) continue;
|
|
3113
|
-
const entries = parseDecisionEntries(text);
|
|
3114
|
-
if (!entries.length) continue;
|
|
3115
|
-
ledgerIds ??= /* @__PURE__ */ new Set();
|
|
3116
|
-
for (const entry of entries) ledgerIds.add(entry.num);
|
|
3117
|
-
ledgerSources.push(artifact.path);
|
|
3118
|
-
}
|
|
3119
|
-
}
|
|
3231
|
+
const ledger = stateDocs.length ? await loadDecisionLedger(root, facts) : { ids: null, sources: [] };
|
|
3232
|
+
const ledgerSources = ledger.sources;
|
|
3120
3233
|
for (const doc of stateDocs) {
|
|
3121
3234
|
if (!auditedPaths.has(doc.path)) await auditClaims(doc);
|
|
3122
|
-
|
|
3123
|
-
qualifiedRefsSkipped += qualifiedSkipped;
|
|
3124
|
-
if (!refs.size) continue;
|
|
3125
|
-
if (!ledgerIds) {
|
|
3126
|
-
unresolvableRefs += refs.size;
|
|
3127
|
-
continue;
|
|
3128
|
-
}
|
|
3129
|
-
for (const [num, asWritten] of refs) {
|
|
3130
|
-
if (ledgerIds.has(num)) continue;
|
|
3131
|
-
findings.push(
|
|
3132
|
-
finding2({
|
|
3133
|
-
id: `${LENS_ID4}/dead-decision-ref:${doc.path}:${asWritten}`,
|
|
3134
|
-
tier: "gap",
|
|
3135
|
-
claim: `${doc.path} cites ${asWritten} \u2014 no such entry exists in ${ledgerSources.join(", ")}`,
|
|
3136
|
-
evidence: [doc.path, `${ledgerSources.join(", ")}: no ${asWritten} entry`],
|
|
3137
|
-
why: "A state doc is read as ground truth on return; a citation the decision record cannot back sends readers to a ruling that was never written.",
|
|
3138
|
-
action: "Fix the reference \u2014 or record the missing decision.",
|
|
3139
|
-
effort: "S",
|
|
3140
|
-
confidence: "medium"
|
|
3141
|
-
})
|
|
3142
|
-
);
|
|
3143
|
-
}
|
|
3235
|
+
findings.push(...checkDecisionRefs(doc, ledger, { lensId: LENS_ID4 }, counters).findings);
|
|
3144
3236
|
}
|
|
3145
3237
|
if (ctx.baseline) {
|
|
3146
3238
|
findings.push(
|
|
@@ -3163,34 +3255,34 @@ var instructionTruthLens = {
|
|
|
3163
3255
|
"No committed baseline (.etymd/baseline.json) \u2014 drift over time is not measurable; run `etymd init` to approve one."
|
|
3164
3256
|
);
|
|
3165
3257
|
}
|
|
3166
|
-
if (binaryResolved) {
|
|
3258
|
+
if (counters.binaryResolved) {
|
|
3167
3259
|
disclosures.push(
|
|
3168
|
-
`${binaryResolved} command claim(s) are installed binaries (node_modules/.bin), not package scripts \u2014 treated as true.`
|
|
3260
|
+
`${counters.binaryResolved} command claim(s) are installed binaries (node_modules/.bin), not package scripts \u2014 treated as true.`
|
|
3169
3261
|
);
|
|
3170
3262
|
}
|
|
3171
|
-
if (unverifiableCommands) {
|
|
3263
|
+
if (counters.unverifiableCommands) {
|
|
3172
3264
|
disclosures.push(
|
|
3173
|
-
`node_modules is not installed \u2014 ${unverifiableCommands} command claim(s) matching no package script could not be checked against installed binaries; skipped, not flagged.`
|
|
3265
|
+
`node_modules is not installed \u2014 ${counters.unverifiableCommands} command claim(s) matching no package script could not be checked against installed binaries; skipped, not flagged.`
|
|
3174
3266
|
);
|
|
3175
3267
|
}
|
|
3176
|
-
if (gitignoredSkipped) {
|
|
3268
|
+
if (counters.gitignoredSkipped) {
|
|
3177
3269
|
disclosures.push(
|
|
3178
|
-
`${gitignoredSkipped} missing path claim(s) are gitignored (machine-local, e.g. .env) \u2014 existence is not verifiable from the repo; skipped, not flagged.`
|
|
3270
|
+
`${counters.gitignoredSkipped} missing path claim(s) are gitignored (machine-local, e.g. .env) \u2014 existence is not verifiable from the repo; skipped, not flagged.`
|
|
3179
3271
|
);
|
|
3180
3272
|
}
|
|
3181
|
-
if (prospectiveSkipped) {
|
|
3273
|
+
if (counters.prospectiveSkipped) {
|
|
3182
3274
|
disclosures.push(
|
|
3183
|
-
`${prospectiveSkipped} path claim(s) sit in create-this prose (the file instructs generating them) \u2014 forward-looking, not stale; skipped, not flagged.`
|
|
3275
|
+
`${counters.prospectiveSkipped} path claim(s) sit in create-this prose (the file instructs generating them) \u2014 forward-looking, not stale; skipped, not flagged.`
|
|
3184
3276
|
);
|
|
3185
3277
|
}
|
|
3186
|
-
if (placeholderSkipped) {
|
|
3278
|
+
if (counters.placeholderSkipped) {
|
|
3187
3279
|
disclosures.push(
|
|
3188
|
-
`${placeholderSkipped} path claim(s) are naming stand-ins (e.g. \`my-custom-skill\`) rather than real references; skipped, not flagged.`
|
|
3280
|
+
`${counters.placeholderSkipped} path claim(s) are naming stand-ins (e.g. \`my-custom-skill\`) rather than real references; skipped, not flagged.`
|
|
3189
3281
|
);
|
|
3190
3282
|
}
|
|
3191
|
-
if (
|
|
3283
|
+
if (counters.tildeSkipped) {
|
|
3192
3284
|
disclosures.push(
|
|
3193
|
-
`${
|
|
3285
|
+
`${counters.tildeSkipped} well-known doc mention(s) sit inside \`~/\` home paths (e.g. \`~/.claude/CLAUDE.md\`) \u2014 machine-global files, not this repo's; skipped, not flagged.`
|
|
3194
3286
|
);
|
|
3195
3287
|
}
|
|
3196
3288
|
if (stateDocs.length) {
|
|
@@ -3198,14 +3290,14 @@ var instructionTruthLens = {
|
|
|
3198
3290
|
`Checked ${stateDocs.length} state document(s) for command, path, and decision-reference claims (same skip classes as instruction files); decision ids resolved against ${ledgerSources.length ? ledgerSources.join(", ") : "nothing \u2014 no decisions file with D-NNN entries"}.`
|
|
3199
3291
|
);
|
|
3200
3292
|
}
|
|
3201
|
-
if (qualifiedRefsSkipped) {
|
|
3293
|
+
if (counters.qualifiedRefsSkipped) {
|
|
3202
3294
|
disclosures.push(
|
|
3203
|
-
`${qualifiedRefsSkipped} decision reference(s) name another record (e.g. a fleet-level ledger) \u2014 not claims about this repo's decisions; skipped, not flagged.`
|
|
3295
|
+
`${counters.qualifiedRefsSkipped} decision reference(s) name another record (e.g. a fleet-level ledger) \u2014 not claims about this repo's decisions; skipped, not flagged.`
|
|
3204
3296
|
);
|
|
3205
3297
|
}
|
|
3206
|
-
if (unresolvableRefs) {
|
|
3298
|
+
if (counters.unresolvableRefs) {
|
|
3207
3299
|
disclosures.push(
|
|
3208
|
-
`${unresolvableRefs} decision reference(s) could not be resolved \u2014 no decisions file with \`## D-NNN\` entries; skipped, not flagged.`
|
|
3300
|
+
`${counters.unresolvableRefs} decision reference(s) could not be resolved \u2014 no decisions file with \`## D-NNN\` entries; skipped, not flagged.`
|
|
3209
3301
|
);
|
|
3210
3302
|
}
|
|
3211
3303
|
if (excluded.length) {
|
|
@@ -3220,7 +3312,7 @@ var instructionTruthLens = {
|
|
|
3220
3312
|
);
|
|
3221
3313
|
}
|
|
3222
3314
|
disclosures.push(
|
|
3223
|
-
`Checked ${files.length} instruction file(s); commands resolved against root + ${facts.packages.length} workspace manifest(s) plus installed binaries; paths matched against root and package roots. Heuristics: workspace-filtered commands skipped (${
|
|
3315
|
+
`Checked ${files.length} instruction file(s); commands resolved against root + ${facts.packages.length} workspace manifest(s) plus installed binaries; paths matched against root and package roots. Heuristics: workspace-filtered commands skipped (${counters.filteredSkipped}); tokens without a recognized extension treated as prose (a dir claim needs a trailing slash); gitignored claims unverifiable; create-this and stand-in path claims skipped; absolute/globbed/placeholder tokens skipped; doc mentions inside \`~/\` home paths skipped; framework-pattern staleness not checked.`
|
|
3224
3316
|
);
|
|
3225
3317
|
return {
|
|
3226
3318
|
lens: LENS_ID4,
|
|
@@ -3402,14 +3494,14 @@ function expandTilde(p) {
|
|
|
3402
3494
|
if (p.startsWith("~/")) return path2.join(os.homedir(), p.slice(2));
|
|
3403
3495
|
return p;
|
|
3404
3496
|
}
|
|
3405
|
-
var REBUILD_RECIPE = 'rebuild it beside the manifest: {"machineProfile":"
|
|
3497
|
+
var REBUILD_RECIPE = 'rebuild it beside the manifest: {"machineProfile":"guarded","dirs":{"<name>":"~/path/to/worktree"}}';
|
|
3406
3498
|
var SAFE_NAME = /^[^\s/\\:.][^\s/\\:]*$/;
|
|
3407
3499
|
function rejectUnsafeName(name, where, manifest, manifestFile) {
|
|
3408
3500
|
if (SAFE_NAME.test(name)) return false;
|
|
3409
3501
|
manifest.problems.push({
|
|
3410
3502
|
kind: "bad-shape",
|
|
3411
3503
|
file: manifestFile,
|
|
3412
|
-
detail: `${where} name ${JSON.stringify(name)} is not a single path-safe segment \u2014 entry skipped (names build finding ids and
|
|
3504
|
+
detail: `${where} name ${JSON.stringify(name)} is not a single path-safe segment \u2014 entry skipped (names build finding ids and guarded/<name>/ persistence paths)`
|
|
3413
3505
|
});
|
|
3414
3506
|
return true;
|
|
3415
3507
|
}
|
|
@@ -3450,7 +3542,7 @@ async function readLocal(localPath, problems) {
|
|
|
3450
3542
|
}
|
|
3451
3543
|
}
|
|
3452
3544
|
function loadRegistryEntries(projects, manifest, manifestFile) {
|
|
3453
|
-
const
|
|
3545
|
+
const guardedNames = [];
|
|
3454
3546
|
for (const [i, rawEntry] of projects.entries()) {
|
|
3455
3547
|
const rec = asRecord(rawEntry);
|
|
3456
3548
|
const name = rec ? asString(rec.name) : void 0;
|
|
@@ -3463,8 +3555,8 @@ function loadRegistryEntries(projects, manifest, manifestFile) {
|
|
|
3463
3555
|
continue;
|
|
3464
3556
|
}
|
|
3465
3557
|
if (rejectUnsafeName(name, `projects[${i}]`, manifest, manifestFile)) continue;
|
|
3466
|
-
const profile = rec.profile === "
|
|
3467
|
-
if (rec.profile !== void 0 && rec.profile !== "
|
|
3558
|
+
const profile = rec.profile === "guarded" ? "guarded" : "personal";
|
|
3559
|
+
if (rec.profile !== void 0 && rec.profile !== "guarded" && rec.profile !== "personal") {
|
|
3468
3560
|
manifest.problems.push({
|
|
3469
3561
|
kind: "bad-shape",
|
|
3470
3562
|
file: manifestFile,
|
|
@@ -3502,12 +3594,12 @@ function loadRegistryEntries(projects, manifest, manifestFile) {
|
|
|
3502
3594
|
contract,
|
|
3503
3595
|
links: asStringMap(rec.links)
|
|
3504
3596
|
};
|
|
3505
|
-
if (entry.profile === "
|
|
3506
|
-
|
|
3597
|
+
if (entry.profile === "guarded" || entry.private) {
|
|
3598
|
+
guardedNames.push(name);
|
|
3507
3599
|
if (!manifest.localPresent) {
|
|
3508
3600
|
entry.unresolved = `registry.local.json is absent \u2014 ${REBUILD_RECIPE}`;
|
|
3509
3601
|
} else if (manifest.machineProfile === "personal") {
|
|
3510
|
-
entry.unresolved = '
|
|
3602
|
+
entry.unresolved = 'guarded entries do not resolve under machineProfile "personal" \u2014 absent on this machine by design';
|
|
3511
3603
|
} else {
|
|
3512
3604
|
const dir = manifest.localDirs[name];
|
|
3513
3605
|
if (!dir) {
|
|
@@ -3523,11 +3615,11 @@ function loadRegistryEntries(projects, manifest, manifestFile) {
|
|
|
3523
3615
|
}
|
|
3524
3616
|
manifest.entries.push(entry);
|
|
3525
3617
|
}
|
|
3526
|
-
if (
|
|
3618
|
+
if (guardedNames.length && !manifest.localPresent) {
|
|
3527
3619
|
manifest.problems.push({
|
|
3528
3620
|
kind: "local-missing",
|
|
3529
3621
|
file: path2.basename(manifest.localPath),
|
|
3530
|
-
detail: `${
|
|
3622
|
+
detail: `${guardedNames.length} guarded/private entr${guardedNames.length === 1 ? "y" : "ies"} (${guardedNames.join(", ")}) cannot resolve without it \u2014 ${REBUILD_RECIPE}`
|
|
3531
3623
|
});
|
|
3532
3624
|
}
|
|
3533
3625
|
}
|
|
@@ -3576,7 +3668,7 @@ async function loadFleetManifest(manifestPath) {
|
|
|
3576
3668
|
shape: "registry",
|
|
3577
3669
|
manifestPath: abs,
|
|
3578
3670
|
dir,
|
|
3579
|
-
|
|
3671
|
+
guardedHosts: [],
|
|
3580
3672
|
labels: {},
|
|
3581
3673
|
localDirs: {},
|
|
3582
3674
|
localPath: path2.join(dir, "registry.local.json"),
|
|
@@ -3609,8 +3701,8 @@ async function loadFleetManifest(manifestPath) {
|
|
|
3609
3701
|
manifest.localPresent = local.present;
|
|
3610
3702
|
manifest.localDirs = asStringMap(local.data.dirs);
|
|
3611
3703
|
manifest.labels = asStringMap(local.data.labels);
|
|
3612
|
-
manifest.
|
|
3613
|
-
manifest.machineProfile = local.data.machineProfile === "
|
|
3704
|
+
manifest.guardedHosts = Array.isArray(local.data.guardedHosts) ? local.data.guardedHosts.filter((h) => typeof h === "string") : [];
|
|
3705
|
+
manifest.machineProfile = local.data.machineProfile === "guarded" || local.data.machineProfile === "personal" ? local.data.machineProfile : void 0;
|
|
3614
3706
|
const rootRaw = asString(local.data.root) ?? asString(parsed.root);
|
|
3615
3707
|
if (rootRaw) manifest.root = path2.resolve(dir, expandTilde(rootRaw));
|
|
3616
3708
|
const orientationRec = asRecord(parsed.orientation);
|
|
@@ -3653,10 +3745,136 @@ async function loadFleetManifest(manifestPath) {
|
|
|
3653
3745
|
init_config();
|
|
3654
3746
|
init_facts();
|
|
3655
3747
|
init_util();
|
|
3748
|
+
|
|
3749
|
+
// src/engine/milestones.ts
|
|
3750
|
+
var MILESTONE_COLUMNS = [
|
|
3751
|
+
"id",
|
|
3752
|
+
"milestone",
|
|
3753
|
+
"goal",
|
|
3754
|
+
"status",
|
|
3755
|
+
"next",
|
|
3756
|
+
"effort",
|
|
3757
|
+
"depends-on"
|
|
3758
|
+
];
|
|
3759
|
+
var MILESTONE_GOALS = ["1", "2", "3"];
|
|
3760
|
+
var MILESTONE_STATUSES = ["planned", "active", "blocked", "done"];
|
|
3761
|
+
var MILESTONE_EFFORTS = ["S", "M", "L"];
|
|
3762
|
+
var NONE_MARKERS = /* @__PURE__ */ new Set(["", "\u2014", "-", "\u2013", "none"]);
|
|
3763
|
+
function splitRow(line) {
|
|
3764
|
+
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
|
|
3765
|
+
return trimmed.split("|").map((c) => c.trim());
|
|
3766
|
+
}
|
|
3767
|
+
function isSeparatorRow(line) {
|
|
3768
|
+
const cells = splitRow(line);
|
|
3769
|
+
return cells.length > 0 && cells.every((c) => /^:?-{1,}:?$/.test(c));
|
|
3770
|
+
}
|
|
3771
|
+
function firstTable(lines, startLine) {
|
|
3772
|
+
for (let i = startLine; i < lines.length - 1; i++) {
|
|
3773
|
+
const line = lines[i] ?? "";
|
|
3774
|
+
const next = lines[i + 1] ?? "";
|
|
3775
|
+
if (!line.trim().startsWith("|") || !isSeparatorRow(next)) continue;
|
|
3776
|
+
const header = splitRow(line).map((h) => h.toLowerCase());
|
|
3777
|
+
const rows = [];
|
|
3778
|
+
for (let j = i + 2; j < lines.length; j++) {
|
|
3779
|
+
const body = lines[j] ?? "";
|
|
3780
|
+
if (!body.trim().startsWith("|")) break;
|
|
3781
|
+
rows.push({ line: j + 1, cells: splitRow(body) });
|
|
3782
|
+
}
|
|
3783
|
+
return { header, rows, headerLine: i + 1 };
|
|
3784
|
+
}
|
|
3785
|
+
return null;
|
|
3786
|
+
}
|
|
3787
|
+
function parseList(cell) {
|
|
3788
|
+
if (NONE_MARKERS.has(cell.trim().toLowerCase())) return [];
|
|
3789
|
+
return cell.split(",").map((s) => s.trim()).filter(Boolean);
|
|
3790
|
+
}
|
|
3791
|
+
function checkColumns(table, expected, problems) {
|
|
3792
|
+
const got = table.header.join(" | ");
|
|
3793
|
+
const want = expected.join(" | ");
|
|
3794
|
+
if (got !== want) {
|
|
3795
|
+
problems.push(
|
|
3796
|
+
`line ${table.headerLine}: table columns are \`${got}\`; the standard is \`${want}\``
|
|
3797
|
+
);
|
|
3798
|
+
return false;
|
|
3799
|
+
}
|
|
3800
|
+
return true;
|
|
3801
|
+
}
|
|
3802
|
+
function checkVocabulary(line, field, value, allowed, problems) {
|
|
3803
|
+
if (allowed.includes(value)) return true;
|
|
3804
|
+
problems.push(
|
|
3805
|
+
`line ${line}: ${field} is \`${value}\`; allowed: ${allowed.map((a) => `\`${a}\``).join(", ")}`
|
|
3806
|
+
);
|
|
3807
|
+
return false;
|
|
3808
|
+
}
|
|
3809
|
+
function parseMilestones(text) {
|
|
3810
|
+
const problems = [];
|
|
3811
|
+
const lines = text.split(/\r?\n/);
|
|
3812
|
+
const headingIdx = lines.findIndex((l) => /^#\s/.test(l));
|
|
3813
|
+
if (headingIdx === -1 || lines[headingIdx]?.trim() !== "# Milestones") {
|
|
3814
|
+
problems.push(
|
|
3815
|
+
headingIdx === -1 ? "no heading: the file must start with `# Milestones`" : `line ${headingIdx + 1}: first heading is \`${lines[headingIdx]?.trim()}\`; it must be \`# Milestones\``
|
|
3816
|
+
);
|
|
3817
|
+
}
|
|
3818
|
+
const table = firstTable(lines, headingIdx === -1 ? 0 : headingIdx + 1);
|
|
3819
|
+
if (!table) {
|
|
3820
|
+
problems.push(
|
|
3821
|
+
`no milestones table: expected \`| ${MILESTONE_COLUMNS.join(" | ")} |\` after the heading`
|
|
3822
|
+
);
|
|
3823
|
+
return { rows: [], problems };
|
|
3824
|
+
}
|
|
3825
|
+
if (!checkColumns(table, MILESTONE_COLUMNS, problems)) return { rows: [], problems };
|
|
3826
|
+
const rows = [];
|
|
3827
|
+
const ids = /* @__PURE__ */ new Set();
|
|
3828
|
+
for (const { line, cells } of table.rows) {
|
|
3829
|
+
if (cells.length !== MILESTONE_COLUMNS.length) {
|
|
3830
|
+
problems.push(`line ${line}: ${cells.length} cells, expected ${MILESTONE_COLUMNS.length}`);
|
|
3831
|
+
continue;
|
|
3832
|
+
}
|
|
3833
|
+
const [id, milestone, goal, status, next, effort, deps] = cells;
|
|
3834
|
+
let ok = true;
|
|
3835
|
+
if (!/^M\d+$/.test(id)) {
|
|
3836
|
+
problems.push(`line ${line}: id \`${id}\` is not \`M<number>\``);
|
|
3837
|
+
ok = false;
|
|
3838
|
+
} else if (ids.has(id)) {
|
|
3839
|
+
problems.push(`line ${line}: id \`${id}\` is used twice`);
|
|
3840
|
+
ok = false;
|
|
3841
|
+
}
|
|
3842
|
+
if (!milestone) {
|
|
3843
|
+
problems.push(`line ${line}: milestone title is empty`);
|
|
3844
|
+
ok = false;
|
|
3845
|
+
}
|
|
3846
|
+
if (!next) {
|
|
3847
|
+
problems.push(`line ${line}: next step is empty \u2014 a milestone without a next step is a wish`);
|
|
3848
|
+
ok = false;
|
|
3849
|
+
}
|
|
3850
|
+
ok = checkVocabulary(line, "goal", goal, MILESTONE_GOALS, problems) && ok;
|
|
3851
|
+
ok = checkVocabulary(line, "status", status, MILESTONE_STATUSES, problems) && ok;
|
|
3852
|
+
ok = checkVocabulary(line, "effort", effort, MILESTONE_EFFORTS, problems) && ok;
|
|
3853
|
+
if (!ok) continue;
|
|
3854
|
+
ids.add(id);
|
|
3855
|
+
rows.push({
|
|
3856
|
+
id,
|
|
3857
|
+
milestone,
|
|
3858
|
+
goal,
|
|
3859
|
+
status,
|
|
3860
|
+
next,
|
|
3861
|
+
effort,
|
|
3862
|
+
dependsOn: parseList(deps)
|
|
3863
|
+
});
|
|
3864
|
+
}
|
|
3865
|
+
for (const r of rows) {
|
|
3866
|
+
for (const d of r.dependsOn) {
|
|
3867
|
+
if (!ids.has(d)) problems.push(`${r.id} depends on \`${d}\`, which is not in the table`);
|
|
3868
|
+
}
|
|
3869
|
+
}
|
|
3870
|
+
return { rows, problems };
|
|
3871
|
+
}
|
|
3872
|
+
|
|
3873
|
+
// src/engine/fleet.ts
|
|
3656
3874
|
var pExecFile3 = promisify(execFile);
|
|
3657
3875
|
var FLEET_LENS = "fleet-manifest";
|
|
3658
3876
|
var FLEET_JSON_SCHEMA = "fleet-experimental-0.2";
|
|
3659
|
-
var
|
|
3877
|
+
var GUARDED_WALL_ARTIFACTS = ["PROJECT_CONTEXT.md", "DECISIONS.md"];
|
|
3660
3878
|
function recurringClasses(projects) {
|
|
3661
3879
|
const byClass = /* @__PURE__ */ new Map();
|
|
3662
3880
|
for (const p of projects) {
|
|
@@ -3689,12 +3907,12 @@ function finding3(id, tier, claim, evidence, why, action) {
|
|
|
3689
3907
|
confidence: "high"
|
|
3690
3908
|
};
|
|
3691
3909
|
}
|
|
3692
|
-
function
|
|
3693
|
-
const
|
|
3694
|
-
const root = path2.resolve(
|
|
3695
|
-
if (path2.dirname(root) !==
|
|
3910
|
+
function guardedPersistenceRoot(manifest, name) {
|
|
3911
|
+
const guardedDir = path2.join(manifest.dir, "guarded");
|
|
3912
|
+
const root = path2.resolve(guardedDir, name);
|
|
3913
|
+
if (path2.dirname(root) !== guardedDir || root === guardedDir) {
|
|
3696
3914
|
throw new Error(
|
|
3697
|
-
`entry name ${JSON.stringify(name)} escapes the
|
|
3915
|
+
`entry name ${JSON.stringify(name)} escapes the guarded persistence zone \u2014 refusing to resolve a ledger root for it`
|
|
3698
3916
|
);
|
|
3699
3917
|
}
|
|
3700
3918
|
return root;
|
|
@@ -3727,7 +3945,7 @@ async function realpathOr(p) {
|
|
|
3727
3945
|
return path2.resolve(p);
|
|
3728
3946
|
}
|
|
3729
3947
|
}
|
|
3730
|
-
function
|
|
3948
|
+
function emailMatchesGuardedHosts(email, hosts) {
|
|
3731
3949
|
const domain = email.split("@")[1]?.toLowerCase();
|
|
3732
3950
|
if (!domain) return false;
|
|
3733
3951
|
return hosts.some((h) => {
|
|
@@ -3778,7 +3996,7 @@ async function checkManifest(manifest) {
|
|
|
3778
3996
|
);
|
|
3779
3997
|
}
|
|
3780
3998
|
seen.add(entry.name);
|
|
3781
|
-
if (entry.profile !== "
|
|
3999
|
+
if (entry.profile !== "guarded" && !entry.private && !entry.trust) {
|
|
3782
4000
|
const bad = entry.trustRaw;
|
|
3783
4001
|
findings.push(
|
|
3784
4002
|
finding3(
|
|
@@ -3816,14 +4034,14 @@ async function checkManifest(manifest) {
|
|
|
3816
4034
|
)
|
|
3817
4035
|
);
|
|
3818
4036
|
}
|
|
3819
|
-
if (entry.unresolved && manifest.localPresent && manifest.machineProfile !== "personal" && (entry.profile === "
|
|
4037
|
+
if (entry.unresolved && manifest.localPresent && manifest.machineProfile !== "personal" && (entry.profile === "guarded" || entry.private)) {
|
|
3820
4038
|
findings.push(
|
|
3821
4039
|
finding3(
|
|
3822
4040
|
`${FLEET_LENS}/dangling-dir:${entry.name}`,
|
|
3823
4041
|
"gap",
|
|
3824
4042
|
`\`${entry.name}\` has no usable directory mapping`,
|
|
3825
4043
|
[`${localFile}: ${entry.unresolved}`],
|
|
3826
|
-
"An unmapped
|
|
4044
|
+
"An unmapped guarded entry silently drops out of every sweep on the machine that most needs it.",
|
|
3827
4045
|
`Add a \`dirs\` mapping for \`${entry.name}\` in ${localFile}.`
|
|
3828
4046
|
)
|
|
3829
4047
|
);
|
|
@@ -3887,28 +4105,28 @@ async function checkManifest(manifest) {
|
|
|
3887
4105
|
);
|
|
3888
4106
|
}
|
|
3889
4107
|
if (manifest.machineProfile === "personal") {
|
|
3890
|
-
const
|
|
3891
|
-
if (
|
|
4108
|
+
const guardedCount = manifest.entries.filter((e) => e.profile === "guarded" || e.private).length;
|
|
4109
|
+
if (guardedCount) {
|
|
3892
4110
|
disclosures.push(
|
|
3893
|
-
`machineProfile "personal": ${
|
|
4111
|
+
`machineProfile "personal": ${guardedCount} guarded entr${guardedCount === 1 ? "y" : "ies"} deliberately absent on this machine \u2014 disclosed, not dangling.`
|
|
3894
4112
|
);
|
|
3895
4113
|
}
|
|
3896
4114
|
}
|
|
3897
4115
|
return { findings, disclosures };
|
|
3898
4116
|
}
|
|
3899
|
-
async function
|
|
4117
|
+
async function checkGuardedWallArtifacts(entries, findings) {
|
|
3900
4118
|
for (const entry of entries) {
|
|
3901
|
-
if (entry.profile !== "
|
|
3902
|
-
for (const artifact of
|
|
4119
|
+
if (entry.profile !== "guarded" || !entry.resolvedRoot) continue;
|
|
4120
|
+
for (const artifact of GUARDED_WALL_ARTIFACTS) {
|
|
3903
4121
|
if (await pathExists(path2.join(entry.resolvedRoot, artifact))) {
|
|
3904
4122
|
findings.push(
|
|
3905
4123
|
finding3(
|
|
3906
|
-
`${FLEET_LENS}/
|
|
4124
|
+
`${FLEET_LENS}/guarded-artifact-in-repo:${entry.name}:${artifact}`,
|
|
3907
4125
|
"risk",
|
|
3908
|
-
`
|
|
4126
|
+
`guarded entry \`${entry.name}\` carries ${artifact} inside its worktree`,
|
|
3909
4127
|
[`${entry.resolvedRoot}/${artifact}`],
|
|
3910
|
-
"Agent artifacts for
|
|
3911
|
-
`Move it to the fleet's
|
|
4128
|
+
"Agent artifacts for guarded repos live outside the repo by policy \u2014 an in-repo copy is one push away from colleagues' eyes and will diverge from the manifest-side truth.",
|
|
4129
|
+
`Move it to the fleet's guarded/${entry.name}/ zone and leave a pointer if the harness needs one.`
|
|
3912
4130
|
)
|
|
3913
4131
|
);
|
|
3914
4132
|
}
|
|
@@ -3916,9 +4134,9 @@ async function checkCorpWallArtifacts(entries, findings) {
|
|
|
3916
4134
|
}
|
|
3917
4135
|
}
|
|
3918
4136
|
async function checkCoverage(manifest, findings, disclosures) {
|
|
3919
|
-
if (!manifest.
|
|
4137
|
+
if (!manifest.guardedHosts.length) {
|
|
3920
4138
|
disclosures.push(
|
|
3921
|
-
"Coverage check skipped \u2014 no
|
|
4139
|
+
"Coverage check skipped \u2014 no guardedHosts in the local manifest, so a guarded remote cannot be recognized."
|
|
3922
4140
|
);
|
|
3923
4141
|
return;
|
|
3924
4142
|
}
|
|
@@ -3939,16 +4157,16 @@ async function checkCoverage(manifest, findings, disclosures) {
|
|
|
3939
4157
|
if (!await pathExists(path2.join(dir, ".git"))) continue;
|
|
3940
4158
|
const remotes = await git(dir, ["config", "--get-regexp", "^remote\\..*\\.url"]);
|
|
3941
4159
|
if (!remotes) continue;
|
|
3942
|
-
const
|
|
3943
|
-
if (
|
|
4160
|
+
const guardedRemote = manifest.guardedHosts.find((h) => remotes.includes(h));
|
|
4161
|
+
if (guardedRemote) {
|
|
3944
4162
|
findings.push(
|
|
3945
4163
|
finding3(
|
|
3946
|
-
`${FLEET_LENS}/unregistered-
|
|
4164
|
+
`${FLEET_LENS}/unregistered-guarded-remote:${d.name}`,
|
|
3947
4165
|
"risk",
|
|
3948
|
-
`\`${d.name}\` under the fleet root has a
|
|
3949
|
-
[`${dir}: remote matches ${
|
|
3950
|
-
"The wall is only as good as its census \u2014 an unregistered
|
|
3951
|
-
"Register it as a
|
|
4166
|
+
`\`${d.name}\` under the fleet root has a guarded remote but is not registered`,
|
|
4167
|
+
[`${dir}: remote matches ${guardedRemote}`],
|
|
4168
|
+
"The wall is only as good as its census \u2014 an unregistered guarded checkout is guarded material no sweep, wall check, or transfer plan knows exists.",
|
|
4169
|
+
"Register it as a guarded entry (opaque alias + local dirs mapping) or move it out of the fleet root."
|
|
3952
4170
|
)
|
|
3953
4171
|
);
|
|
3954
4172
|
}
|
|
@@ -3996,7 +4214,7 @@ async function checkHygieneNeedles(manifest, findings, disclosures) {
|
|
|
3996
4214
|
[
|
|
3997
4215
|
...Object.values(manifest.labels),
|
|
3998
4216
|
...Object.values(manifest.localDirs).map((d) => path2.basename(expandNeedleDir(d))),
|
|
3999
|
-
...manifest.
|
|
4217
|
+
...manifest.guardedHosts
|
|
4000
4218
|
].filter((n) => n.length >= 3)
|
|
4001
4219
|
)
|
|
4002
4220
|
];
|
|
@@ -4023,7 +4241,7 @@ async function checkHygieneNeedles(manifest, findings, disclosures) {
|
|
|
4023
4241
|
"risk",
|
|
4024
4242
|
`${file} in public-repo \`${entry.name}\` carries a needle from the fleet's private local manifest`,
|
|
4025
4243
|
[`${file}: contains "${needle}"`],
|
|
4026
|
-
"A public repo that names a private label,
|
|
4244
|
+
"A public repo that names a private label, guarded directory, or guarded host joins the public and guarded identities for anyone who greps.",
|
|
4027
4245
|
"Scrub the needle from the tracked file (rewrite history if it already shipped)."
|
|
4028
4246
|
)
|
|
4029
4247
|
);
|
|
@@ -4034,10 +4252,10 @@ async function checkHygieneNeedles(manifest, findings, disclosures) {
|
|
|
4034
4252
|
function expandNeedleDir(dir) {
|
|
4035
4253
|
return dir.replace(/\/+$/, "");
|
|
4036
4254
|
}
|
|
4037
|
-
async function
|
|
4038
|
-
if (!manifest.
|
|
4255
|
+
async function checkGuardedEmails(manifest, findings, disclosures) {
|
|
4256
|
+
if (!manifest.guardedHosts.length) {
|
|
4039
4257
|
disclosures.push(
|
|
4040
|
-
"
|
|
4258
|
+
"Guarded-email check skipped \u2014 no guardedHosts in the local manifest, so a guarded domain cannot be recognized."
|
|
4041
4259
|
);
|
|
4042
4260
|
return;
|
|
4043
4261
|
}
|
|
@@ -4046,21 +4264,23 @@ async function checkCorpEmails(manifest, findings, disclosures) {
|
|
|
4046
4264
|
const emails = await git(entry.resolvedRoot, ["log", "-30", "--format=%ae"]);
|
|
4047
4265
|
if (emails === null) {
|
|
4048
4266
|
disclosures.push(
|
|
4049
|
-
`
|
|
4267
|
+
`Guarded-email check could not run in \`${entry.name}\` (no readable commit history) \u2014 undetermined, not clean.`
|
|
4050
4268
|
);
|
|
4051
4269
|
continue;
|
|
4052
4270
|
}
|
|
4053
4271
|
const lines = emails.split("\n").filter(Boolean);
|
|
4054
|
-
const matched = [
|
|
4272
|
+
const matched = [
|
|
4273
|
+
...new Set(lines.filter((e) => emailMatchesGuardedHosts(e, manifest.guardedHosts)))
|
|
4274
|
+
];
|
|
4055
4275
|
if (matched.length) {
|
|
4056
4276
|
const count = lines.filter((e) => matched.includes(e)).length;
|
|
4057
4277
|
findings.push(
|
|
4058
4278
|
finding3(
|
|
4059
|
-
`${FLEET_LENS}/
|
|
4279
|
+
`${FLEET_LENS}/guarded-email:${entry.name}`,
|
|
4060
4280
|
"risk",
|
|
4061
|
-
`recent commits on personal entry \`${entry.name}\` are authored with a
|
|
4281
|
+
`recent commits on personal entry \`${entry.name}\` are authored with a guarded-host email`,
|
|
4062
4282
|
[`${count} of the last ${lines.length} commits: ${matched.join(", ")}`],
|
|
4063
|
-
"A
|
|
4283
|
+
"A guarded email in a personal repo's history ties the two identities together permanently \u2014 and silently, since the machine's global git identity is usually the guarded one.",
|
|
4064
4284
|
"Set the repo-local identity to the personal one and amend the unpushed commits."
|
|
4065
4285
|
)
|
|
4066
4286
|
);
|
|
@@ -4074,7 +4294,7 @@ async function checkGateDrift(manifest, findings, disclosures) {
|
|
|
4074
4294
|
for (const entry of manifest.entries) {
|
|
4075
4295
|
const root = entry.resolvedRoot;
|
|
4076
4296
|
if (!root || !await isDirectory(root)) continue;
|
|
4077
|
-
if (entry.profile === "
|
|
4297
|
+
if (entry.profile === "guarded" || entry.private) continue;
|
|
4078
4298
|
if (entry.gates === "none") {
|
|
4079
4299
|
disclosures.push(`${entry.name}: gates declared \`none\` \u2014 deliberately absent, not missing`);
|
|
4080
4300
|
continue;
|
|
@@ -4127,11 +4347,11 @@ async function checkGateDrift(manifest, findings, disclosures) {
|
|
|
4127
4347
|
async function collectWallFindings(manifest) {
|
|
4128
4348
|
const findings = [];
|
|
4129
4349
|
const disclosures = [];
|
|
4130
|
-
await
|
|
4350
|
+
await checkGuardedWallArtifacts(manifest.entries, findings);
|
|
4131
4351
|
await checkCoverage(manifest, findings, disclosures);
|
|
4132
4352
|
await checkManifestRepoMachinePaths(manifest, findings, disclosures);
|
|
4133
4353
|
await checkHygieneNeedles(manifest, findings, disclosures);
|
|
4134
|
-
await
|
|
4354
|
+
await checkGuardedEmails(manifest, findings, disclosures);
|
|
4135
4355
|
await checkGateDrift(manifest, findings, disclosures);
|
|
4136
4356
|
return { findings, disclosures };
|
|
4137
4357
|
}
|
|
@@ -4197,13 +4417,13 @@ async function sweepEntry(manifest, entry, opts) {
|
|
|
4197
4417
|
);
|
|
4198
4418
|
}
|
|
4199
4419
|
}
|
|
4200
|
-
const
|
|
4201
|
-
const optedIn = !
|
|
4420
|
+
const guarded = entry.profile === "guarded";
|
|
4421
|
+
const optedIn = !guarded && await pathExists(path2.join(root, ETYMD_DIR));
|
|
4202
4422
|
const result = await runAudit(root, {
|
|
4203
4423
|
kind: opts.kind,
|
|
4204
|
-
persistLedger: !
|
|
4205
|
-
readOnlyRoot:
|
|
4206
|
-
ledgerRoot:
|
|
4424
|
+
persistLedger: !guarded && opts.persistLedgers === true && optedIn,
|
|
4425
|
+
readOnlyRoot: guarded,
|
|
4426
|
+
ledgerRoot: guarded ? guardedPersistenceRoot(manifest, entry.name) : void 0,
|
|
4207
4427
|
stateBudgets: stateBudgetsFor(entry),
|
|
4208
4428
|
upstreamRemote
|
|
4209
4429
|
});
|
|
@@ -4211,11 +4431,43 @@ async function sweepEntry(manifest, entry, opts) {
|
|
|
4211
4431
|
if (entry.contract.placement === "none") {
|
|
4212
4432
|
project.findings = project.findings.filter((f) => !f.id.endsWith("/no-contract"));
|
|
4213
4433
|
}
|
|
4434
|
+
const milestonesFile = entry.contract.milestones;
|
|
4435
|
+
if (milestonesFile && milestonesFile !== "none") {
|
|
4436
|
+
const text = await readText(path2.join(root, milestonesFile));
|
|
4437
|
+
if (text === null) {
|
|
4438
|
+
project.findings.push(
|
|
4439
|
+
finding3(
|
|
4440
|
+
`${FLEET_LENS}/milestones-missing:${entry.name}`,
|
|
4441
|
+
"gap",
|
|
4442
|
+
`\`${entry.name}\` declares milestones at \`${milestonesFile}\` and the file is absent`,
|
|
4443
|
+
[
|
|
4444
|
+
`${path2.basename(manifest.manifestPath)}: ${entry.name}.contract.milestones = ${milestonesFile}`
|
|
4445
|
+
],
|
|
4446
|
+
"The fleet board rolls every project's plan up from this file; a declared file that is absent is a row the board cannot fill.",
|
|
4447
|
+
'Create it (heading `# Milestones`, then the standard table), or declare `milestones: "none"` if this project deliberately carries no plan.'
|
|
4448
|
+
)
|
|
4449
|
+
);
|
|
4450
|
+
} else {
|
|
4451
|
+
const doc = parseMilestones(text);
|
|
4452
|
+
doc.problems.forEach((problem, i) => {
|
|
4453
|
+
project.findings.push(
|
|
4454
|
+
finding3(
|
|
4455
|
+
`${FLEET_LENS}/milestones-shape:${entry.name}:${i + 1}`,
|
|
4456
|
+
"gap",
|
|
4457
|
+
`\`${entry.name}\` ${milestonesFile}: ${problem}`,
|
|
4458
|
+
[`${milestonesFile}`],
|
|
4459
|
+
"A milestones file the board cannot parse is a plan the fleet cannot see.",
|
|
4460
|
+
`Fix the table to the standard shape: \`| ${MILESTONE_COLUMNS.join(" | ")} |\`.`
|
|
4461
|
+
)
|
|
4462
|
+
);
|
|
4463
|
+
});
|
|
4464
|
+
}
|
|
4465
|
+
}
|
|
4214
4466
|
for (const f of project.findings) project.counts[f.tier] += 1;
|
|
4215
4467
|
project.stateAgeDays = stateAgeDays(result.facts);
|
|
4216
4468
|
project.staleAfterDays = result.config.config.state.staleAfterDays;
|
|
4217
4469
|
for (const [key, value] of Object.entries(entry.contract)) {
|
|
4218
|
-
if (key === "placement" || !value) continue;
|
|
4470
|
+
if (key === "placement" || key === "milestones" || !value) continue;
|
|
4219
4471
|
const exists = await pathExists(path2.join(root, value.replace(/#.*$/, "")));
|
|
4220
4472
|
project.disclosures.push(
|
|
4221
4473
|
`contract override \`${key}: ${value}\` \u2014 registered, existence-checked only (${exists ? "present" : "MISSING"})`
|
|
@@ -4258,8 +4510,481 @@ async function sweepFleet(manifest, opts = {}) {
|
|
|
4258
4510
|
};
|
|
4259
4511
|
}
|
|
4260
4512
|
|
|
4513
|
+
// src/engine/propose.ts
|
|
4514
|
+
var PROPOSAL_SCHEMA = "proposal/1";
|
|
4515
|
+
var RUBRIC_CRITERIA = ["severity", "economy", "confidence", "breadth"];
|
|
4516
|
+
function parseRubric(text, file) {
|
|
4517
|
+
const lines = [];
|
|
4518
|
+
const seen = /* @__PURE__ */ new Map();
|
|
4519
|
+
const rows = text.split(/\r?\n/);
|
|
4520
|
+
for (const [i, raw] of rows.entries()) {
|
|
4521
|
+
const line = raw.trim();
|
|
4522
|
+
if (!line || line.startsWith("#")) continue;
|
|
4523
|
+
const at = i + 1;
|
|
4524
|
+
const refuse = (why) => new Error(
|
|
4525
|
+
`${file}:${at} \`${raw.trim()}\` \u2014 ${why} (criteria: ${RUBRIC_CRITERIA.join(", ")})`
|
|
4526
|
+
);
|
|
4527
|
+
const sep = line.indexOf(":");
|
|
4528
|
+
if (sep < 0) throw refuse("expected `criterion: <weight>`");
|
|
4529
|
+
const criterion = line.slice(0, sep).trim();
|
|
4530
|
+
const weightRaw = line.slice(sep + 1).trim();
|
|
4531
|
+
if (!RUBRIC_CRITERIA.includes(criterion)) {
|
|
4532
|
+
throw refuse(`unknown criterion \`${criterion}\``);
|
|
4533
|
+
}
|
|
4534
|
+
if (!/^\d+$/.test(weightRaw) || Number(weightRaw) < 1) {
|
|
4535
|
+
throw refuse(`weight \`${weightRaw}\` is not a positive integer`);
|
|
4536
|
+
}
|
|
4537
|
+
const known = criterion;
|
|
4538
|
+
if (seen.has(known)) {
|
|
4539
|
+
throw new Error(
|
|
4540
|
+
`${file}:${at} \`${raw.trim()}\` \u2014 criterion \`${criterion}\` is already weighted on line ${seen.get(known)}; one line per criterion`
|
|
4541
|
+
);
|
|
4542
|
+
}
|
|
4543
|
+
seen.set(known, at);
|
|
4544
|
+
lines.push({ criterion: known, weight: Number(weightRaw) });
|
|
4545
|
+
}
|
|
4546
|
+
if (!lines.length) {
|
|
4547
|
+
throw new Error(`${file} carries no criterion lines \u2014 a rubric with no criteria scores nothing`);
|
|
4548
|
+
}
|
|
4549
|
+
return { lines };
|
|
4550
|
+
}
|
|
4551
|
+
var TIER_VALUE = { risk: 3, gap: 2, polish: 1 };
|
|
4552
|
+
var EFFORT_VALUE = { S: 3, M: 2, L: 1 };
|
|
4553
|
+
var CONFIDENCE_VALUE = { high: 3, medium: 2, low: 1 };
|
|
4554
|
+
function findingSubject(project, f) {
|
|
4555
|
+
return {
|
|
4556
|
+
id: `${project.name}:${f.id}`,
|
|
4557
|
+
kind: "finding",
|
|
4558
|
+
classId: f.id.split(":")[0] ?? f.id,
|
|
4559
|
+
tier: f.tier,
|
|
4560
|
+
effort: f.effort,
|
|
4561
|
+
confidence: f.confidence,
|
|
4562
|
+
projects: [project.name],
|
|
4563
|
+
action: f.action ?? "",
|
|
4564
|
+
evidence: f.evidence
|
|
4565
|
+
};
|
|
4566
|
+
}
|
|
4567
|
+
function classSubject(classId, members) {
|
|
4568
|
+
const pick = (order, at, dir) => {
|
|
4569
|
+
const values = members.map((m) => order[at(m.finding)]);
|
|
4570
|
+
const value = dir === 1 ? Math.max(...values) : Math.min(...values);
|
|
4571
|
+
return Object.entries(order).find(([, v]) => v === value)[0];
|
|
4572
|
+
};
|
|
4573
|
+
const actions = [...new Set(members.map((m) => m.finding.action).filter(Boolean))].sort();
|
|
4574
|
+
return {
|
|
4575
|
+
id: `class:${classId}`,
|
|
4576
|
+
kind: "class",
|
|
4577
|
+
classId,
|
|
4578
|
+
tier: pick(TIER_VALUE, (f) => f.tier, 1),
|
|
4579
|
+
effort: pick(EFFORT_VALUE, (f) => f.effort, -1),
|
|
4580
|
+
confidence: pick(CONFIDENCE_VALUE, (f) => f.confidence, -1),
|
|
4581
|
+
projects: [...new Set(members.map((m) => m.project.name))].sort(),
|
|
4582
|
+
action: actions.join("; "),
|
|
4583
|
+
evidence: members.flatMap((m) => m.finding.evidence)
|
|
4584
|
+
};
|
|
4585
|
+
}
|
|
4586
|
+
var CRITERION_VALUE = {
|
|
4587
|
+
severity: (s) => TIER_VALUE[s.tier],
|
|
4588
|
+
economy: (s) => EFFORT_VALUE[s.effort],
|
|
4589
|
+
confidence: (s) => CONFIDENCE_VALUE[s.confidence],
|
|
4590
|
+
breadth: (s) => Math.min(3, s.projects.length)
|
|
4591
|
+
};
|
|
4592
|
+
var FILENAME_RE = /^[^\s:]+\.[A-Za-z0-9]+$/;
|
|
4593
|
+
function extractFiles(evidence) {
|
|
4594
|
+
const files = /* @__PURE__ */ new Set();
|
|
4595
|
+
for (const line of evidence) {
|
|
4596
|
+
const token = line.split(/[\s:]/)[0];
|
|
4597
|
+
if (token && (token.includes("/") || FILENAME_RE.test(token))) files.add(token);
|
|
4598
|
+
}
|
|
4599
|
+
return [...files].sort();
|
|
4600
|
+
}
|
|
4601
|
+
function extractGates(files) {
|
|
4602
|
+
return files.filter((f) => f.startsWith(".githooks/") || f.startsWith(".github/workflows/"));
|
|
4603
|
+
}
|
|
4604
|
+
function reversibility(files) {
|
|
4605
|
+
if (!files.length) return "undetermined";
|
|
4606
|
+
const generated = (f) => f.startsWith(".etymd/") || f.startsWith(".githooks/");
|
|
4607
|
+
return files.every(generated) ? "regenerable" : "git-reversible";
|
|
4608
|
+
}
|
|
4609
|
+
function toProposal(s, rubric) {
|
|
4610
|
+
const matched = [];
|
|
4611
|
+
let score = 0;
|
|
4612
|
+
for (const line of rubric.lines) {
|
|
4613
|
+
const value = CRITERION_VALUE[line.criterion](s);
|
|
4614
|
+
score += line.weight * value;
|
|
4615
|
+
if (value >= 2) matched.push({ criterion: line.criterion, weight: line.weight, value });
|
|
4616
|
+
}
|
|
4617
|
+
const files = extractFiles(s.evidence);
|
|
4618
|
+
return {
|
|
4619
|
+
id: s.id,
|
|
4620
|
+
schema: PROPOSAL_SCHEMA,
|
|
4621
|
+
class: s.classId,
|
|
4622
|
+
kind: s.kind,
|
|
4623
|
+
projects: s.projects,
|
|
4624
|
+
action: s.action,
|
|
4625
|
+
effort: s.effort,
|
|
4626
|
+
confidence: s.confidence,
|
|
4627
|
+
score,
|
|
4628
|
+
matched,
|
|
4629
|
+
implications: {
|
|
4630
|
+
projects: s.projects,
|
|
4631
|
+
files,
|
|
4632
|
+
gates: extractGates(files),
|
|
4633
|
+
reversibility: reversibility(files)
|
|
4634
|
+
}
|
|
4635
|
+
};
|
|
4636
|
+
}
|
|
4637
|
+
function buildProposals(sweep, rubricName, rubric) {
|
|
4638
|
+
const disclosures = [];
|
|
4639
|
+
const personal = sweep.projects.filter((p) => p.profile === "personal");
|
|
4640
|
+
const guarded = sweep.projects.filter((p) => p.profile !== "personal");
|
|
4641
|
+
if (guarded.length) {
|
|
4642
|
+
disclosures.push(
|
|
4643
|
+
`guarded entries excluded from proposals: ${guarded.map((p) => p.name).sort().join(", ")}`
|
|
4644
|
+
);
|
|
4645
|
+
}
|
|
4646
|
+
const notAudited = personal.filter((p) => !p.resolvedRoot || p.unresolved);
|
|
4647
|
+
if (notAudited.length) {
|
|
4648
|
+
disclosures.push(
|
|
4649
|
+
`not audited, so not proposed: ${notAudited.map((p) => p.name).sort().join(", ")}`
|
|
4650
|
+
);
|
|
4651
|
+
}
|
|
4652
|
+
const subjects = [];
|
|
4653
|
+
for (const project of personal) {
|
|
4654
|
+
for (const f of project.findings) {
|
|
4655
|
+
if (f.kind === "improvement") subjects.push(findingSubject(project, f));
|
|
4656
|
+
}
|
|
4657
|
+
}
|
|
4658
|
+
const byClass = /* @__PURE__ */ new Map();
|
|
4659
|
+
for (const project of personal) {
|
|
4660
|
+
for (const f of project.findings) {
|
|
4661
|
+
const classId = f.id.split(":")[0] ?? f.id;
|
|
4662
|
+
const entry = byClass.get(classId) ?? [];
|
|
4663
|
+
entry.push({ project, finding: f });
|
|
4664
|
+
byClass.set(classId, entry);
|
|
4665
|
+
}
|
|
4666
|
+
}
|
|
4667
|
+
for (const [classId, members] of byClass) {
|
|
4668
|
+
if (new Set(members.map((m) => m.project.name)).size >= 2) {
|
|
4669
|
+
subjects.push(classSubject(classId, members));
|
|
4670
|
+
}
|
|
4671
|
+
}
|
|
4672
|
+
const proposals = subjects.map((s) => toProposal(s, rubric)).sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
|
|
4673
|
+
return {
|
|
4674
|
+
schema: PROPOSAL_SCHEMA,
|
|
4675
|
+
manifest: sweep.manifest,
|
|
4676
|
+
rubric: rubricName,
|
|
4677
|
+
criteria: rubric.lines,
|
|
4678
|
+
proposals,
|
|
4679
|
+
disclosures: [
|
|
4680
|
+
...disclosures,
|
|
4681
|
+
"implications.files are path-shaped tokens extracted from finding evidence \u2014 evidence lines carrying no path are not represented there"
|
|
4682
|
+
]
|
|
4683
|
+
};
|
|
4684
|
+
}
|
|
4685
|
+
|
|
4686
|
+
// src/engine/premise.ts
|
|
4687
|
+
init_facts();
|
|
4688
|
+
init_scan();
|
|
4689
|
+
init_util();
|
|
4690
|
+
var PREMISE_LENS = "premise";
|
|
4691
|
+
var PREMISE_BRIEF_FILE = path2.join(ETYMD_DIR, "premise-brief.md");
|
|
4692
|
+
var TASK_LABEL = "task";
|
|
4693
|
+
var MAX_PATH_FINDINGS = 15;
|
|
4694
|
+
var CODE_SPAN_RE = /(```[\s\S]*?```|`[^`\n]+`)/g;
|
|
4695
|
+
var RUN_RE = /\b(?:pnpm|yarn|npm|bun)\s+run\s+(?!-)([A-Za-z0-9:._-]+)|\bnpm\s+(?:test|start)\b/g;
|
|
4696
|
+
var BARE_PM_RE = /\b(?:pnpm|yarn|bun)\s+(?!run\b)(?!-)[A-Za-z0-9:._-]+/g;
|
|
4697
|
+
var STOP_WORDS = new Set(
|
|
4698
|
+
"a an the this that these those it its them all any both each every some your our my their and or but to in on at of for with from by as is are was were be will would can could should may might must do does did not no so if then than when where which who what how also again now first once twice after before via into over only just too very".split(" ")
|
|
4699
|
+
);
|
|
4700
|
+
var WRAP_RE = /^([([{"']*)(.*?)([.,;:!?)\]}"']*)$/;
|
|
4701
|
+
var TRAIL_RE = /[.,;:!?)\]}"']*$/;
|
|
4702
|
+
var HOSTNAME_RE = /^[a-z0-9-]+(?:\.[a-z0-9-]+)*\.([a-z]{2,})$/i;
|
|
4703
|
+
function promoteBareTokens(text, ctx) {
|
|
4704
|
+
const skips = {
|
|
4705
|
+
bareInvocations: 0,
|
|
4706
|
+
proseScripts: 0,
|
|
4707
|
+
hostnameLike: 0,
|
|
4708
|
+
unrootedDirs: 0,
|
|
4709
|
+
namespaced: 0
|
|
4710
|
+
};
|
|
4711
|
+
const promoted = text.split(CODE_SPAN_RE).map((segment, i) => i % 2 === 1 ? segment : promoteProse(segment, ctx, skips)).join("");
|
|
4712
|
+
return { text: promoted, skips };
|
|
4713
|
+
}
|
|
4714
|
+
function promoteProse(segment, ctx, skips) {
|
|
4715
|
+
skips.bareInvocations += segment.match(BARE_PM_RE)?.length ?? 0;
|
|
4716
|
+
const withCommands = segment.replace(RUN_RE, (m, script) => {
|
|
4717
|
+
const trail = TRAIL_RE.exec(m)?.[0] ?? "";
|
|
4718
|
+
const core = trail ? m.slice(0, -trail.length) : m;
|
|
4719
|
+
if (script !== void 0) {
|
|
4720
|
+
const name = script.replace(TRAIL_RE, "");
|
|
4721
|
+
if (name.length < 2 || STOP_WORDS.has(name.toLowerCase())) {
|
|
4722
|
+
skips.proseScripts += 1;
|
|
4723
|
+
return m;
|
|
4724
|
+
}
|
|
4725
|
+
}
|
|
4726
|
+
return `\`${core}\`${trail}`;
|
|
4727
|
+
});
|
|
4728
|
+
const parts = withCommands.split(/(\s+)/);
|
|
4729
|
+
for (let i = 0; i < parts.length; i += 2) {
|
|
4730
|
+
parts[i] = promoteToken(parts[i], i >= 2 ? parts[i - 2] : "", ctx, skips);
|
|
4731
|
+
}
|
|
4732
|
+
return parts.join("");
|
|
4733
|
+
}
|
|
4734
|
+
function namespaceOf(core, prev) {
|
|
4735
|
+
const ns = new RegExp(`^(${NAMESPACE_IDENT}):`).exec(core)?.[1] ?? new RegExp(`^(${NAMESPACE_IDENT}):$`).exec(prev)?.[1];
|
|
4736
|
+
return ns && !NAMESPACE_STOP.has(ns.toLowerCase()) ? ns : null;
|
|
4737
|
+
}
|
|
4738
|
+
function promoteToken(token, prev, ctx, skips) {
|
|
4739
|
+
const m = WRAP_RE.exec(token);
|
|
4740
|
+
if (!m) return token;
|
|
4741
|
+
const [, lead = "", core = "", trail = ""] = m;
|
|
4742
|
+
if (!core || core.includes("://")) return token;
|
|
4743
|
+
const namespace = namespaceOf(core, prev);
|
|
4744
|
+
const body = namespace && core.startsWith(`${namespace}:`) ? core.slice(namespace.length + 1) : core;
|
|
4745
|
+
if (!PATH_TOKEN_RE.test(body)) return token;
|
|
4746
|
+
const first = body.slice(0, body.indexOf("/"));
|
|
4747
|
+
const hostSuffix = HOSTNAME_RE.exec(first)?.[1]?.toLowerCase();
|
|
4748
|
+
if (hostSuffix && !KNOWN_EXTENSIONS.has(hostSuffix)) {
|
|
4749
|
+
skips.hostnameLike += 1;
|
|
4750
|
+
return token;
|
|
4751
|
+
}
|
|
4752
|
+
if (body.endsWith("/")) {
|
|
4753
|
+
if (!ctx.rootedDirs.has(first)) {
|
|
4754
|
+
skips.unrootedDirs += 1;
|
|
4755
|
+
return token;
|
|
4756
|
+
}
|
|
4757
|
+
} else {
|
|
4758
|
+
const ext = body.toLowerCase().match(/\.([a-z0-9]{1,8})$/)?.[1];
|
|
4759
|
+
if (!ext || !KNOWN_EXTENSIONS.has(ext)) return token;
|
|
4760
|
+
}
|
|
4761
|
+
if (namespace) {
|
|
4762
|
+
skips.namespaced += 1;
|
|
4763
|
+
return token;
|
|
4764
|
+
}
|
|
4765
|
+
return `${lead}\`${body}\`${trail}`;
|
|
4766
|
+
}
|
|
4767
|
+
async function listRootedDirs(env) {
|
|
4768
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
4769
|
+
for (const base of env.bases) {
|
|
4770
|
+
for (const sub of ["", "src", "scripts"]) {
|
|
4771
|
+
const entries = await promises.readdir(path2.join(base, sub), { withFileTypes: true }).catch(() => []);
|
|
4772
|
+
for (const entry of entries) if (entry.isDirectory()) dirs.add(entry.name);
|
|
4773
|
+
}
|
|
4774
|
+
}
|
|
4775
|
+
return dirs;
|
|
4776
|
+
}
|
|
4777
|
+
async function runPremise(opts) {
|
|
4778
|
+
const root = opts.root;
|
|
4779
|
+
const task = opts.task.trim();
|
|
4780
|
+
const facts = await scanProject(root);
|
|
4781
|
+
const env = await buildTruthEnv(root, facts);
|
|
4782
|
+
const counters = emptyCounters();
|
|
4783
|
+
const rootedDirs = await listRootedDirs(env);
|
|
4784
|
+
const promoted = promoteBareTokens(task, { rootedDirs });
|
|
4785
|
+
const text = { path: TASK_LABEL, text: promoted.text };
|
|
4786
|
+
const findings = [];
|
|
4787
|
+
const disclosures = [];
|
|
4788
|
+
const entities = [];
|
|
4789
|
+
const claims = await checkTextClaims(
|
|
4790
|
+
env,
|
|
4791
|
+
text,
|
|
4792
|
+
{
|
|
4793
|
+
lensId: PREMISE_LENS,
|
|
4794
|
+
subject: "The task",
|
|
4795
|
+
missingPathTier: "risk",
|
|
4796
|
+
maxPathFindings: MAX_PATH_FINDINGS,
|
|
4797
|
+
rootedFirstSegments: rootedDirs,
|
|
4798
|
+
treatNamespacedPrefixes: true,
|
|
4799
|
+
whyCommand: "The task is built on a command that does not exist \u2014 an agent will run it and fail, or quietly substitute something else and report success.",
|
|
4800
|
+
actionCommand: "Fix the task before handing it over: name the real script, or restore it.",
|
|
4801
|
+
whyPath: "The task is ABOUT this path. Acting on it means editing, testing, or reasoning about something that is not there \u2014 the wrong problem, solved precisely.",
|
|
4802
|
+
actionPath: "Find where the behaviour actually lives and rewrite the task to name it."
|
|
4803
|
+
},
|
|
4804
|
+
counters
|
|
4805
|
+
);
|
|
4806
|
+
findings.push(...claims.findings);
|
|
4807
|
+
disclosures.push(...claims.disclosures);
|
|
4808
|
+
entities.push(...claims.examined);
|
|
4809
|
+
const docs = await checkDocRefs(env, text, PREMISE_LENS, counters, "The task");
|
|
4810
|
+
findings.push(...docs.findings);
|
|
4811
|
+
entities.push(...docs.examined);
|
|
4812
|
+
const ledger = await loadDecisionLedger(root, facts);
|
|
4813
|
+
const decisions = checkDecisionRefs(
|
|
4814
|
+
text,
|
|
4815
|
+
ledger,
|
|
4816
|
+
{
|
|
4817
|
+
lensId: PREMISE_LENS,
|
|
4818
|
+
subject: "The task",
|
|
4819
|
+
why: "The task presupposes a ruling that was never recorded; an agent will act on a decision nobody made.",
|
|
4820
|
+
action: "Point the task at the entry that exists \u2014 or record the missing decision first."
|
|
4821
|
+
},
|
|
4822
|
+
counters
|
|
4823
|
+
);
|
|
4824
|
+
findings.push(...decisions.findings);
|
|
4825
|
+
entities.push(...decisions.examined);
|
|
4826
|
+
if (!entities.length) {
|
|
4827
|
+
disclosures.push(
|
|
4828
|
+
"The task names no path with a directory, no package script, no well-known doc, and no decision id \u2014 nothing etymd can check by reading the repo. Everything it presupposes is in the brief."
|
|
4829
|
+
);
|
|
4830
|
+
}
|
|
4831
|
+
disclosures.push(
|
|
4832
|
+
"In prose, a path needs a directory and a recognized extension (a directory claim needs a trailing slash and a first segment that exists here), and a script needs the `run` form (`npm run X`, `pnpm run X` \u2026) or the `npm test` / `npm start` shorthands; anything backticked is read exactly as an instruction file would be. A bare file name (a lone `config.ts`) is prose."
|
|
4833
|
+
);
|
|
4834
|
+
const { skips } = promoted;
|
|
4835
|
+
if (skips.bareInvocations) {
|
|
4836
|
+
disclosures.push(
|
|
4837
|
+
`${skips.bareInvocations} bare \`pnpm X\` / \`yarn X\` / \`bun X\` mention(s) in prose were not read as scripts \u2014 in a sentence that shape is a phrase as often as an invocation; write \`\u2026 run X\` or backtick it to have it checked.`
|
|
4838
|
+
);
|
|
4839
|
+
}
|
|
4840
|
+
if (skips.proseScripts) {
|
|
4841
|
+
disclosures.push(
|
|
4842
|
+
`${skips.proseScripts} \`\u2026 run X\` mention(s) put a function word or a one-letter stand-in where a script name would be; read as prose, skipped.`
|
|
4843
|
+
);
|
|
4844
|
+
}
|
|
4845
|
+
if (skips.hostnameLike) {
|
|
4846
|
+
disclosures.push(
|
|
4847
|
+
`${skips.hostnameLike} slash token(s) start with a host name (e.g. \`github.com/\u2026\`) \u2014 a URL, not a repo path; skipped.`
|
|
4848
|
+
);
|
|
4849
|
+
}
|
|
4850
|
+
if (skips.unrootedDirs) {
|
|
4851
|
+
disclosures.push(
|
|
4852
|
+
`${skips.unrootedDirs} slash-joined phrase(s) ending in \`/\` start with no directory that exists here (e.g. \`input/output/\`) \u2014 read as prose; skipped, not flagged. Backtick one to have it checked.`
|
|
4853
|
+
);
|
|
4854
|
+
}
|
|
4855
|
+
if (skips.namespaced) {
|
|
4856
|
+
disclosures.push(
|
|
4857
|
+
`${skips.namespaced} path mention(s) in prose carry a namespace prefix (\`pc:src/x.ts\`, \`lk: src/x.ts\`) \u2014 another repo's tree, never this one; skipped, not flagged.`
|
|
4858
|
+
);
|
|
4859
|
+
}
|
|
4860
|
+
if (counters.namespacedSkipped) {
|
|
4861
|
+
disclosures.push(
|
|
4862
|
+
`${counters.namespacedSkipped} backticked path mention(s) sit directly after a namespace prefix (\`pc: \`src/x.ts\`\`) \u2014 another repo's tree, not this one; skipped, not flagged.`
|
|
4863
|
+
);
|
|
4864
|
+
}
|
|
4865
|
+
if (counters.outsideRepoSkipped) {
|
|
4866
|
+
disclosures.push(
|
|
4867
|
+
`${counters.outsideRepoSkipped} path(s) the task names start at no directory of this repo \u2014 typical of a path quoted from another repository or a scratchpad clone; outside this repo, not missing here. Skipped, not flagged.`
|
|
4868
|
+
);
|
|
4869
|
+
}
|
|
4870
|
+
if (counters.unverifiableCommands) {
|
|
4871
|
+
disclosures.push(
|
|
4872
|
+
`node_modules is not installed \u2014 ${counters.unverifiableCommands} command(s) matching no package script could not be checked against installed binaries; skipped, not flagged.`
|
|
4873
|
+
);
|
|
4874
|
+
}
|
|
4875
|
+
if (counters.binaryResolved) {
|
|
4876
|
+
disclosures.push(
|
|
4877
|
+
`${counters.binaryResolved} command(s) resolve to installed binaries (node_modules/.bin), not package scripts \u2014 treated as true.`
|
|
4878
|
+
);
|
|
4879
|
+
}
|
|
4880
|
+
if (counters.gitignoredSkipped) {
|
|
4881
|
+
disclosures.push(
|
|
4882
|
+
`${counters.gitignoredSkipped} missing path(s) are gitignored (machine-local) \u2014 existence is not verifiable from the repo; skipped, not flagged.`
|
|
4883
|
+
);
|
|
4884
|
+
}
|
|
4885
|
+
if (counters.prospectiveSkipped) {
|
|
4886
|
+
disclosures.push(
|
|
4887
|
+
`${counters.prospectiveSkipped} path(s) sit in create-this prose (the task says to create them) \u2014 forward-looking, not missing; skipped.`
|
|
4888
|
+
);
|
|
4889
|
+
}
|
|
4890
|
+
if (counters.placeholderSkipped) {
|
|
4891
|
+
disclosures.push(
|
|
4892
|
+
`${counters.placeholderSkipped} path(s) are naming stand-ins (e.g. \`my-feature\`) rather than real references; skipped.`
|
|
4893
|
+
);
|
|
4894
|
+
}
|
|
4895
|
+
if (counters.filteredSkipped) {
|
|
4896
|
+
disclosures.push(
|
|
4897
|
+
`${counters.filteredSkipped} workspace-filtered command(s) (\`--filter\`, \`-C\` \u2026) were not resolved; skipped.`
|
|
4898
|
+
);
|
|
4899
|
+
}
|
|
4900
|
+
if (counters.tildeSkipped) {
|
|
4901
|
+
disclosures.push(
|
|
4902
|
+
`${counters.tildeSkipped} well-known doc mention(s) sit inside \`~/\` home paths (e.g. \`~/.claude/CLAUDE.md\`) \u2014 machine-global files, not this repo's; skipped, not flagged.`
|
|
4903
|
+
);
|
|
4904
|
+
}
|
|
4905
|
+
if (counters.qualifiedRefsSkipped) {
|
|
4906
|
+
disclosures.push(
|
|
4907
|
+
`${counters.qualifiedRefsSkipped} decision reference(s) name another record \u2014 not this repo's; skipped.`
|
|
4908
|
+
);
|
|
4909
|
+
}
|
|
4910
|
+
if (counters.unresolvableRefs) {
|
|
4911
|
+
disclosures.push(
|
|
4912
|
+
`${counters.unresolvableRefs} decision reference(s) could not be resolved \u2014 no decisions file with \`## D-NNN\` entries; skipped, not flagged.`
|
|
4913
|
+
);
|
|
4914
|
+
}
|
|
4915
|
+
disclosures.push(
|
|
4916
|
+
"What the task PRESUPPOSES \u2014 that the named things are the ones meant, that the mechanism it assumes actually runs, that the state it assumes holds \u2014 cannot be checked by reading files. The brief hands those to the agent."
|
|
4917
|
+
);
|
|
4918
|
+
disclosures.push("Task findings are not remembered between runs \u2014 no ledger is written.");
|
|
4919
|
+
const ranked = rankFindings(findings);
|
|
4920
|
+
const brief = renderBrief(task, ranked, entities);
|
|
4921
|
+
let briefPath = null;
|
|
4922
|
+
if (opts.writeBrief !== false && await pathExists(path2.join(root, ETYMD_DIR))) {
|
|
4923
|
+
await promises.writeFile(path2.join(root, PREMISE_BRIEF_FILE), brief, "utf8");
|
|
4924
|
+
briefPath = PREMISE_BRIEF_FILE;
|
|
4925
|
+
}
|
|
4926
|
+
return {
|
|
4927
|
+
schema: "premise/1",
|
|
4928
|
+
task,
|
|
4929
|
+
source: opts.source ?? "argument",
|
|
4930
|
+
name: facts.name,
|
|
4931
|
+
findings: ranked,
|
|
4932
|
+
disclosures,
|
|
4933
|
+
entities,
|
|
4934
|
+
brief,
|
|
4935
|
+
briefPath
|
|
4936
|
+
};
|
|
4937
|
+
}
|
|
4938
|
+
function mark(exists) {
|
|
4939
|
+
return exists === null ? "?" : exists ? "\u2713" : "\u2717";
|
|
4940
|
+
}
|
|
4941
|
+
function kindLabel(kind) {
|
|
4942
|
+
return kind === "script" ? "script " : kind === "decision" ? "decision " : "";
|
|
4943
|
+
}
|
|
4944
|
+
function renderBrief(task, findings, entities) {
|
|
4945
|
+
const oneLine = task.replace(/\s+/g, " ").trim();
|
|
4946
|
+
const title = oneLine.length > 100 ? `${oneLine.slice(0, 97)}\u2026` : oneLine;
|
|
4947
|
+
const checked = entities.length ? entities.map(
|
|
4948
|
+
(e) => `- ${mark(e.exists)} ${kindLabel(e.kind)}\`${e.value}\`${e.exists === null ? " \u2014 could not be checked" : e.exists ? " \u2014 exists" : " \u2014 missing (see the finding)"}`
|
|
4949
|
+
).join("\n") : "- (nothing the task names could be checked by reading the repo)";
|
|
4950
|
+
const failed = findings.filter((f) => f.tier === "risk");
|
|
4951
|
+
const failedLine = failed.length ? `
|
|
4952
|
+
**${failed.length} premise(s) already failed** \u2014 the task names things that do not exist. Resolve those before anything below.
|
|
4953
|
+
` : "";
|
|
4954
|
+
return `# Premise brief \u2014 ${title}
|
|
4955
|
+
|
|
4956
|
+
The task, verbatim:
|
|
4957
|
+
|
|
4958
|
+
> ${task.replace(/\n/g, "\n> ")}
|
|
4959
|
+
|
|
4960
|
+
etymd checked what the task names against the repo. What it could NOT check is listed under
|
|
4961
|
+
"only you can verify"; check those before acting, and if one fails, say so first \u2014 everything
|
|
4962
|
+
else is downstream of it.
|
|
4963
|
+
${failedLine}
|
|
4964
|
+
## What the task names, and whether it exists
|
|
4965
|
+
|
|
4966
|
+
${checked}
|
|
4967
|
+
|
|
4968
|
+
## Premises only you can verify
|
|
4969
|
+
|
|
4970
|
+
1. **The named things are the ones MEANT.** A file that exists can still be the wrong file, and a
|
|
4971
|
+
script that exists can still not be the one the task has in mind. Confirm each is where the
|
|
4972
|
+
behaviour actually lives before touching it.
|
|
4973
|
+
2. **The mechanism the task assumes is working.** A task shaped like "make X faster / less flaky /
|
|
4974
|
+
fresh after Y" assumes X and Y already do what it says. Run them once. If the assumed
|
|
4975
|
+
mechanism does not run, the task is downstream of a defect it does not mention.
|
|
4976
|
+
3. **The state the task assumes holds.** Read it; do not trust the task's description of it.
|
|
4977
|
+
|
|
4978
|
+
## If a premise fails
|
|
4979
|
+
|
|
4980
|
+
Report it first, in one line: what the task presupposes, and what you found instead. Do not
|
|
4981
|
+
answer the original task as if the premise held \u2014 a wrong problem solved precisely is still the
|
|
4982
|
+
wrong problem.
|
|
4983
|
+
`;
|
|
4984
|
+
}
|
|
4985
|
+
|
|
4261
4986
|
// src/index.ts
|
|
4262
4987
|
init_version();
|
|
4263
4988
|
init_version2();
|
|
4264
4989
|
|
|
4265
|
-
export { CONFIG_FILE, DECISIONS_FORMAT_MARKER, DEFAULT_CONFIG, EXTRACTION_THRESHOLD, FLEET_JSON_SCHEMA, FLEET_LENS, LENSES, NAME, PACK_VERSION, TOTAL_BUDGET_WORDS, VERSION, applyFiles, baselineCarriesMachinePath, baselinePath, buildGateInventory, cacheFactsPath, checkManifest, collectWallFindings, configPath, contextEconomyLens,
|
|
4990
|
+
export { CONFIG_FILE, DECISIONS_FORMAT_MARKER, DEFAULT_CONFIG, EXTRACTION_THRESHOLD, FLEET_JSON_SCHEMA, FLEET_LENS, LENSES, NAME, PACK_VERSION, PREMISE_BRIEF_FILE, PREMISE_LENS, PROPOSAL_SCHEMA, RUBRIC_CRITERIA, TOTAL_BUDGET_WORDS, VERSION, applyFiles, baselineCarriesMachinePath, baselinePath, buildGateInventory, buildProposals, buildTruthEnv, cacheFactsPath, checkDecisionRefs, checkDocRefs, checkManifest, checkTextClaims, collectWallFindings, configPath, contextEconomyLens, deriveProfile, emptyCounters, expandTilde, extractCommandClaims, extractPathClaims, gateIntegrityLens, guardedPersistenceRoot, instructionTruthLens, isAlwaysAppliedCursorRule, listInstructionFiles, loadDecisionLedger, loadFleetManifest, measureContext, parseRubric, planWorkflow, promoteBareTokens, rankFindings, readBaseline, readCachedFacts, readConfig, readLedger, reconcileLedger, renderBrief, runAudit, runPremise, scanProject, stateFreshnessLens, sweepFleet, withoutMachinePath, writeBaseline, writeCachedFacts, writeLedger };
|