@chrisdudek/yg 5.0.0-alpha.6 → 5.0.1
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/README.md +1 -1
- package/dist/bin.js +206 -232
- package/graph-schemas/yg-config.yaml +5 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Your agent will ignore CLAUDE.md. Yggdrasil makes sure it doesn't.**
|
|
4
4
|
|
|
5
|
-
Architecture rules your agent can't ignore.
|
|
5
|
+
Architecture rules your agent can't ignore. Before it edits a file, your agent gets the few rules that apply — and writes to them. After, every change is checked before it moves on: by a script that runs locally for free, or by an LLM reviewer. A rule written as a script *runs* — your agent can't quietly optimize it away the way it drops a line in CLAUDE.md. Works with Claude Code, Cursor, Copilot, Codex, Cline, and more. Checks run against your code, not your diffs; the feedback is specific; the agent has to fix before it can move on.
|
|
6
6
|
|
|
7
7
|
See the [main README](https://github.com/krzysztofdudek/Yggdrasil#readme) for documentation, or visit
|
|
8
8
|
[krzysztofdudek.github.io/Yggdrasil](https://krzysztofdudek.github.io/Yggdrasil/).
|
package/dist/bin.js
CHANGED
|
@@ -6,7 +6,7 @@ import { Command as Command2 } from "commander";
|
|
|
6
6
|
// src/cli/init.ts
|
|
7
7
|
import chalk2 from "chalk";
|
|
8
8
|
import { existsSync as existsSync2 } from "fs";
|
|
9
|
-
import { mkdir as mkdir3, writeFile as writeFile4, readdir as readdir3, readFile as
|
|
9
|
+
import { mkdir as mkdir3, writeFile as writeFile4, readdir as readdir3, readFile as readFile11, stat as stat3 } from "fs/promises";
|
|
10
10
|
import path13 from "path";
|
|
11
11
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
12
12
|
import * as p from "@clack/prompts";
|
|
@@ -145,7 +145,7 @@ Fix a refusal in one of two ways: declare the relation in the node's \`yg-node.y
|
|
|
145
145
|
|
|
146
146
|
### Verification and the lock
|
|
147
147
|
|
|
148
|
-
A verdict is valid exactly while the inputs that produced it hash to the stored value. Any input change \u2014 an edited subject file, an edited \`content.md\` / \`check.mjs\`, an edited \`scope\`, a tier
|
|
148
|
+
A verdict is valid exactly while the inputs that produced it hash to the stored value. Any input change \u2014 an edited subject file, an edited \`content.md\` / \`check.mjs\`, an edited \`scope\`, or a change to which named tier the aspect uses \u2014 makes the pair **unverified**, and \`yg check --approve\` re-verifies it. (A status flip is NOT an input, and neither is a tier's underlying config \u2014 only the tier NAME folds in, so swapping the model or provider behind a named tier never invalidates a verdict.) States are: **verified / unverified / refused**.
|
|
149
149
|
|
|
150
150
|
\`yg check\` writes nothing and makes no LLM calls: it re-hashes each lock verdict and reports (on plain \`yg check\` it never runs an aspect reviewer or a deterministic \`check.mjs\`), and it runs the built-in relation-conformance check live (parse + resolve). So CI runs it cheap and keyless. \`yg check --approve\` fills the unverified pairs and then reports.
|
|
151
151
|
|
|
@@ -473,7 +473,7 @@ context.
|
|
|
473
473
|
|
|
474
474
|
**Flow** \u2014 when you see a sequence of steps toward a business goal. Not code call sequences \u2014 real-world processes. "User places an order" = flow. "Handler calls service" = relation between nodes. Read \`schemas/yg-flow.yaml\` and \`yg knowledge read flows\` before creating.
|
|
475
475
|
|
|
476
|
-
**Node** \u2014 one per cohesive feature area. Not per directory, not per file. If a node covers >3 distinct workflows, split into children. Size is bounded by the reviewer prompt, not the node: an LLM aspect assembles its content.md, references, and the unit's subject files into one prompt, checked against the resolved tier's \`max_prompt_chars\`. Exceeding it is a blocking \`prompt-too-large\` error naming the pair, with remedies in safety order: narrow \`scope.files\` (when the overflow is non-target payload), switch the aspect to \`per: file\` (ONLY if the rule is file-local), split the node, or raise the limit / move to a higher tier (a
|
|
476
|
+
**Node** \u2014 one per cohesive feature area. Not per directory, not per file. If a node covers >3 distinct workflows, split into children. Size is bounded by the reviewer prompt, not the node: an LLM aspect assembles its content.md, references, and the unit's subject files into one prompt, checked against the resolved tier's \`max_prompt_chars\`. Exceeding it is a blocking \`prompt-too-large\` error naming the pair, with remedies in safety order: narrow \`scope.files\` (when the overflow is non-target payload), switch the aspect to \`per: file\` (ONLY if the rule is file-local), split the node, or raise the limit / move to a higher-capability tier (re-pointing an aspect to a different named tier re-verifies that aspect's pairs; editing a tier's own config does not \u2014 only the tier name is a verdict input). Deterministic checks read files programmatically with no prompt and are never subject to the gate. Read \`schemas/yg-node.yaml\` before creating.
|
|
477
477
|
|
|
478
478
|
**Port / relation** \u2014 when a critical aspect must cross a node boundary, or when a new typed dependency is needed. Bare relations do NOT propagate aspects; ports do. Six relation types exist (\`calls\`, \`uses\`, \`extends\`, \`implements\`, \`emits\`, \`listens\`); event relations must be paired. Deep dive: \`yg knowledge read ports-and-relations\`.
|
|
479
479
|
|
|
@@ -1129,9 +1129,16 @@ async function testOllama(model, endpoint) {
|
|
|
1129
1129
|
body: JSON.stringify({
|
|
1130
1130
|
model,
|
|
1131
1131
|
messages: [{ role: "user", content: "Respond with OK" }],
|
|
1132
|
-
stream: false
|
|
1132
|
+
stream: false,
|
|
1133
|
+
// Mirror the real reviewer (OllamaProvider.verifyAspect): disable the
|
|
1134
|
+
// model's reasoning trace so a "thinking" model does not burn the probe
|
|
1135
|
+
// on a long chain-of-thought for a trivial prompt.
|
|
1136
|
+
think: false
|
|
1133
1137
|
}),
|
|
1134
|
-
|
|
1138
|
+
// A large local model is cold-loaded from disk on the first request; 15s was
|
|
1139
|
+
// too tight and produced false "connection failed" on working setups. Match
|
|
1140
|
+
// the real reviewer's tolerance (apiFetch default).
|
|
1141
|
+
signal: AbortSignal.timeout(6e4)
|
|
1135
1142
|
});
|
|
1136
1143
|
if (!res.ok) {
|
|
1137
1144
|
const msg = `HTTP ${res.status} ${res.statusText}`;
|
|
@@ -1287,9 +1294,9 @@ import path10 from "path";
|
|
|
1287
1294
|
import { gt as gt3, lt, valid as valid3 } from "semver";
|
|
1288
1295
|
|
|
1289
1296
|
// src/io/config-parser.ts
|
|
1290
|
-
import { readFile as
|
|
1297
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
1291
1298
|
import path4 from "path";
|
|
1292
|
-
import { parse as
|
|
1299
|
+
import { parse as parseYaml3 } from "yaml";
|
|
1293
1300
|
|
|
1294
1301
|
// src/utils/known-providers.ts
|
|
1295
1302
|
var KNOWN_PROVIDERS = [
|
|
@@ -1303,6 +1310,39 @@ var KNOWN_PROVIDERS = [
|
|
|
1303
1310
|
"gemini-cli"
|
|
1304
1311
|
];
|
|
1305
1312
|
|
|
1313
|
+
// src/io/secrets-parser.ts
|
|
1314
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
1315
|
+
import { join } from "path";
|
|
1316
|
+
import { parse as parseYaml2 } from "yaml";
|
|
1317
|
+
function isPlainObject(v) {
|
|
1318
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1319
|
+
}
|
|
1320
|
+
function deepMerge(base, overlay) {
|
|
1321
|
+
const out = { ...base };
|
|
1322
|
+
for (const [key, ov] of Object.entries(overlay)) {
|
|
1323
|
+
const bv = out[key];
|
|
1324
|
+
out[key] = isPlainObject(bv) && isPlainObject(ov) ? deepMerge(bv, ov) : ov;
|
|
1325
|
+
}
|
|
1326
|
+
return out;
|
|
1327
|
+
}
|
|
1328
|
+
async function loadConfigOverlay(yggRoot) {
|
|
1329
|
+
let content14;
|
|
1330
|
+
try {
|
|
1331
|
+
content14 = await readFile3(join(yggRoot, "yg-secrets.yaml"), "utf-8");
|
|
1332
|
+
} catch (err) {
|
|
1333
|
+
debugWrite(`[secrets-parser] readFile: ${err.message}`);
|
|
1334
|
+
return void 0;
|
|
1335
|
+
}
|
|
1336
|
+
const raw = parseYaml2(content14);
|
|
1337
|
+
if (raw === null || raw === void 0) return void 0;
|
|
1338
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
1339
|
+
throw new Error(
|
|
1340
|
+
"yg-secrets.yaml: top level must be a YAML mapping (it is a deep-merge overlay over yg-config.yaml)"
|
|
1341
|
+
);
|
|
1342
|
+
}
|
|
1343
|
+
return raw;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1306
1346
|
// src/io/config-parser.ts
|
|
1307
1347
|
var ConfigParseError = class extends Error {
|
|
1308
1348
|
constructor(messageData, code) {
|
|
@@ -1371,15 +1411,17 @@ var PROVIDER_DEFAULTS = {
|
|
|
1371
1411
|
};
|
|
1372
1412
|
async function parseConfig(filePath) {
|
|
1373
1413
|
const filename = path4.basename(filePath);
|
|
1374
|
-
const content14 = await
|
|
1375
|
-
const
|
|
1376
|
-
if (!
|
|
1414
|
+
const content14 = await readFile4(filePath, "utf-8");
|
|
1415
|
+
const baseRaw = parseYaml3(content14);
|
|
1416
|
+
if (!baseRaw || typeof baseRaw !== "object") {
|
|
1377
1417
|
throw new ConfigParseError({
|
|
1378
1418
|
what: `${filename} is empty or not a valid YAML mapping`,
|
|
1379
1419
|
why: "the top-level structure must be a YAML mapping with keys like reviewer, quality, parallel",
|
|
1380
1420
|
next: "restore the file from version control, or regenerate it via `yg init`"
|
|
1381
1421
|
}, "config-invalid");
|
|
1382
1422
|
}
|
|
1423
|
+
const overlay = await loadConfigOverlay(path4.dirname(filePath));
|
|
1424
|
+
const raw = overlay ? deepMerge(baseRaw, overlay) : baseRaw;
|
|
1383
1425
|
const version = typeof raw.version === "string" ? raw.version.trim() : void 0;
|
|
1384
1426
|
const qualityRaw = raw.quality;
|
|
1385
1427
|
if (qualityRaw !== void 0 && (typeof qualityRaw !== "object" || Array.isArray(qualityRaw))) {
|
|
@@ -1608,8 +1650,8 @@ function parseTier(name, raw, filename) {
|
|
|
1608
1650
|
}
|
|
1609
1651
|
|
|
1610
1652
|
// src/io/node-parser.ts
|
|
1611
|
-
import { readFile as
|
|
1612
|
-
import { parse as
|
|
1653
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
1654
|
+
import { parse as parseYaml4 } from "yaml";
|
|
1613
1655
|
|
|
1614
1656
|
// src/model/graph.ts
|
|
1615
1657
|
var STATUS_ORDER = {
|
|
@@ -1867,8 +1909,8 @@ function isValidRelationType(t) {
|
|
|
1867
1909
|
return typeof t === "string" && RELATION_TYPES2.includes(t);
|
|
1868
1910
|
}
|
|
1869
1911
|
async function parseNodeYaml(filePath) {
|
|
1870
|
-
const content14 = await
|
|
1871
|
-
const raw =
|
|
1912
|
+
const content14 = await readFile5(filePath, "utf-8");
|
|
1913
|
+
const raw = parseYaml4(content14);
|
|
1872
1914
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1873
1915
|
throw new Error(`yg-node.yaml at ${filePath}: file is empty or not a valid YAML mapping`);
|
|
1874
1916
|
}
|
|
@@ -2058,12 +2100,12 @@ function parsePorts(rawPorts, filePath) {
|
|
|
2058
2100
|
}
|
|
2059
2101
|
|
|
2060
2102
|
// src/io/aspect-parser.ts
|
|
2061
|
-
import { readFile as
|
|
2103
|
+
import { readFile as readFile7 } from "fs/promises";
|
|
2062
2104
|
import path6 from "path";
|
|
2063
|
-
import { parse as
|
|
2105
|
+
import { parse as parseYaml5 } from "yaml";
|
|
2064
2106
|
|
|
2065
2107
|
// src/io/artifact-reader.ts
|
|
2066
|
-
import { readFile as
|
|
2108
|
+
import { readFile as readFile6, readdir as readdir2 } from "fs/promises";
|
|
2067
2109
|
import path5 from "path";
|
|
2068
2110
|
async function readArtifacts(dirPath, excludeFiles = ["yg-node.yaml"], includeFiles) {
|
|
2069
2111
|
let entries;
|
|
@@ -2080,7 +2122,7 @@ async function readArtifacts(dirPath, excludeFiles = ["yg-node.yaml"], includeFi
|
|
|
2080
2122
|
if (excludeFiles.includes(entry.name)) continue;
|
|
2081
2123
|
if (includeSet && !includeSet.has(entry.name)) continue;
|
|
2082
2124
|
const filePath = path5.join(dirPath, entry.name);
|
|
2083
|
-
const content14 = await
|
|
2125
|
+
const content14 = await readFile6(filePath, "utf-8");
|
|
2084
2126
|
artifacts.push({ filename: entry.name, content: content14 });
|
|
2085
2127
|
}
|
|
2086
2128
|
artifacts.sort((a, b) => a.filename.localeCompare(b.filename));
|
|
@@ -2227,8 +2269,8 @@ async function parseAspect(aspectDir, aspectYamlPath, id) {
|
|
|
2227
2269
|
}]
|
|
2228
2270
|
};
|
|
2229
2271
|
}
|
|
2230
|
-
const content14 = await
|
|
2231
|
-
const raw =
|
|
2272
|
+
const content14 = await readFile7(aspectYamlPath, "utf-8");
|
|
2273
|
+
const raw = parseYaml5(content14);
|
|
2232
2274
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
2233
2275
|
return {
|
|
2234
2276
|
ok: false,
|
|
@@ -2719,12 +2761,12 @@ function parseScope(rawScope, aspectId, aspectYamlPath, reviewerType) {
|
|
|
2719
2761
|
}
|
|
2720
2762
|
|
|
2721
2763
|
// src/io/flow-parser.ts
|
|
2722
|
-
import { readFile as
|
|
2764
|
+
import { readFile as readFile8 } from "fs/promises";
|
|
2723
2765
|
import path7 from "path";
|
|
2724
|
-
import { parse as
|
|
2766
|
+
import { parse as parseYaml6 } from "yaml";
|
|
2725
2767
|
async function parseFlow(flowDir, flowYamlPath) {
|
|
2726
|
-
const content14 = await
|
|
2727
|
-
const raw =
|
|
2768
|
+
const content14 = await readFile8(flowYamlPath, "utf-8");
|
|
2769
|
+
const raw = parseYaml6(content14);
|
|
2728
2770
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
2729
2771
|
throw new Error(`yg-flow.yaml at ${flowYamlPath}: file is empty or not a valid YAML mapping`);
|
|
2730
2772
|
}
|
|
@@ -2780,13 +2822,13 @@ async function parseFlow(flowDir, flowYamlPath) {
|
|
|
2780
2822
|
}
|
|
2781
2823
|
|
|
2782
2824
|
// src/io/schema-parser.ts
|
|
2783
|
-
import { readFile as
|
|
2825
|
+
import { readFile as readFile9 } from "fs/promises";
|
|
2784
2826
|
import path8 from "path";
|
|
2785
|
-
import { parse as
|
|
2827
|
+
import { parse as parseYaml7 } from "yaml";
|
|
2786
2828
|
async function parseSchema(filePath) {
|
|
2787
2829
|
const filename = path8.basename(filePath);
|
|
2788
|
-
const content14 = await
|
|
2789
|
-
const raw =
|
|
2830
|
+
const content14 = await readFile9(filePath, "utf-8");
|
|
2831
|
+
const raw = parseYaml7(content14);
|
|
2790
2832
|
if (raw != null && (typeof raw !== "object" || Array.isArray(raw))) {
|
|
2791
2833
|
throw new Error(`${filename} at ${filePath}: expected YAML mapping or empty document`);
|
|
2792
2834
|
}
|
|
@@ -2796,12 +2838,12 @@ async function parseSchema(filePath) {
|
|
|
2796
2838
|
}
|
|
2797
2839
|
|
|
2798
2840
|
// src/io/architecture-parser.ts
|
|
2799
|
-
import { readFile as
|
|
2800
|
-
import { parse as
|
|
2841
|
+
import { readFile as readFile10 } from "fs/promises";
|
|
2842
|
+
import { parse as parseYaml8 } from "yaml";
|
|
2801
2843
|
var VALID_RELATION_TYPES = /* @__PURE__ */ new Set(["uses", "calls", "extends", "implements", "emits", "listens"]);
|
|
2802
2844
|
async function parseArchitecture(filePath) {
|
|
2803
|
-
const content14 = await
|
|
2804
|
-
const raw =
|
|
2845
|
+
const content14 = await readFile10(filePath, "utf-8");
|
|
2846
|
+
const raw = parseYaml8(content14);
|
|
2805
2847
|
if (raw && (typeof raw !== "object" || Array.isArray(raw))) {
|
|
2806
2848
|
throw new Error(`yg-architecture.yaml: file must be a YAML mapping (or empty/omitted)`);
|
|
2807
2849
|
}
|
|
@@ -3350,7 +3392,7 @@ function readLock(yggRoot) {
|
|
|
3350
3392
|
nodes: obj.nodes
|
|
3351
3393
|
};
|
|
3352
3394
|
}
|
|
3353
|
-
function
|
|
3395
|
+
function isPlainObject2(value) {
|
|
3354
3396
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3355
3397
|
}
|
|
3356
3398
|
function throwMalformed(detail) {
|
|
@@ -3365,19 +3407,19 @@ function validateLockShape(obj) {
|
|
|
3365
3407
|
for (const key of Object.keys(obj)) {
|
|
3366
3408
|
if (!TOP_KEYS.has(key)) throwMalformed(`unexpected top-level key "${key}" (allowed: version, verdicts, nodes)`);
|
|
3367
3409
|
}
|
|
3368
|
-
if (!
|
|
3410
|
+
if (!isPlainObject2(obj.verdicts)) {
|
|
3369
3411
|
throwMalformed('"verdicts" must be a JSON object (found ' + describe(obj.verdicts) + ")");
|
|
3370
3412
|
}
|
|
3371
3413
|
for (const aspectId of Object.keys(obj.verdicts)) {
|
|
3372
3414
|
const unitMap = obj.verdicts[aspectId];
|
|
3373
|
-
if (!
|
|
3415
|
+
if (!isPlainObject2(unitMap)) {
|
|
3374
3416
|
throwMalformed(`"verdicts.${aspectId}" must be a JSON object (found ${describe(unitMap)})`);
|
|
3375
3417
|
}
|
|
3376
3418
|
for (const unitKey of Object.keys(unitMap)) {
|
|
3377
3419
|
validateVerdictEntry(unitMap[unitKey], `verdicts.${aspectId}.${unitKey}`);
|
|
3378
3420
|
}
|
|
3379
3421
|
}
|
|
3380
|
-
if (!
|
|
3422
|
+
if (!isPlainObject2(obj.nodes)) {
|
|
3381
3423
|
throwMalformed('"nodes" must be a JSON object (found ' + describe(obj.nodes) + ")");
|
|
3382
3424
|
}
|
|
3383
3425
|
for (const nodePath of Object.keys(obj.nodes)) {
|
|
@@ -3385,7 +3427,7 @@ function validateLockShape(obj) {
|
|
|
3385
3427
|
}
|
|
3386
3428
|
}
|
|
3387
3429
|
function validateVerdictEntry(entry, at) {
|
|
3388
|
-
if (!
|
|
3430
|
+
if (!isPlainObject2(entry)) throwMalformed(`"${at}" must be a JSON object (found ${describe(entry)})`);
|
|
3389
3431
|
const ENTRY_KEYS = /* @__PURE__ */ new Set(["verdict", "hash", "reason", "touched"]);
|
|
3390
3432
|
for (const key of Object.keys(entry)) {
|
|
3391
3433
|
if (!ENTRY_KEYS.has(key)) throwMalformed(`"${at}" has unexpected key "${key}" (allowed: verdict, hash, reason, touched)`);
|
|
@@ -3412,7 +3454,7 @@ function validateVerdictEntry(entry, at) {
|
|
|
3412
3454
|
}
|
|
3413
3455
|
}
|
|
3414
3456
|
function validateNodeEntry(entry, at) {
|
|
3415
|
-
if (!
|
|
3457
|
+
if (!isPlainObject2(entry)) throwMalformed(`"${at}" must be a JSON object (found ${describe(entry)})`);
|
|
3416
3458
|
const NODE_KEYS = /* @__PURE__ */ new Set(["source", "log"]);
|
|
3417
3459
|
for (const key of Object.keys(entry)) {
|
|
3418
3460
|
if (!NODE_KEYS.has(key)) throwMalformed(`"${at}" has unexpected key "${key}" (allowed: source, log)`);
|
|
@@ -3421,7 +3463,7 @@ function validateNodeEntry(entry, at) {
|
|
|
3421
3463
|
throwMalformed(`"${at}.source" must be a string when present (found ${describe(entry.source)})`);
|
|
3422
3464
|
}
|
|
3423
3465
|
if (entry.log !== void 0) {
|
|
3424
|
-
if (!
|
|
3466
|
+
if (!isPlainObject2(entry.log)) {
|
|
3425
3467
|
throwMalformed(`"${at}.log" must be a JSON object when present (found ${describe(entry.log)})`);
|
|
3426
3468
|
}
|
|
3427
3469
|
const LOG_KEYS = /* @__PURE__ */ new Set(["last_entry_datetime", "prefix_hash"]);
|
|
@@ -3575,11 +3617,6 @@ function getPackageRoot() {
|
|
|
3575
3617
|
function getGraphSchemasDir() {
|
|
3576
3618
|
return path13.join(getPackageRoot(), "graph-schemas");
|
|
3577
3619
|
}
|
|
3578
|
-
async function getCliVersion() {
|
|
3579
|
-
const pkgPath = path13.join(getPackageRoot(), "package.json");
|
|
3580
|
-
const pkg2 = JSON.parse(await readFile10(pkgPath, "utf-8"));
|
|
3581
|
-
return pkg2.version;
|
|
3582
|
-
}
|
|
3583
3620
|
async function refreshSchemas(yggRoot) {
|
|
3584
3621
|
const schemasDir = path13.join(yggRoot, "schemas");
|
|
3585
3622
|
await mkdir3(schemasDir, { recursive: true });
|
|
@@ -3589,7 +3626,7 @@ async function refreshSchemas(yggRoot) {
|
|
|
3589
3626
|
const schemaFiles = entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
3590
3627
|
for (const file of schemaFiles) {
|
|
3591
3628
|
const srcPath = path13.join(graphSchemasDir, file);
|
|
3592
|
-
const content14 = await
|
|
3629
|
+
const content14 = await readFile11(srcPath, "utf-8");
|
|
3593
3630
|
await writeFile4(path13.join(schemasDir, file), content14, "utf-8");
|
|
3594
3631
|
}
|
|
3595
3632
|
} catch (e) {
|
|
@@ -3610,7 +3647,7 @@ async function ensureGitattributes(repoRoot) {
|
|
|
3610
3647
|
const gaPath = path13.join(repoRoot, ".gitattributes");
|
|
3611
3648
|
let existing;
|
|
3612
3649
|
try {
|
|
3613
|
-
existing = await
|
|
3650
|
+
existing = await readFile11(gaPath, "utf-8");
|
|
3614
3651
|
} catch (e) {
|
|
3615
3652
|
if (e.code !== "ENOENT") throw e;
|
|
3616
3653
|
debugWrite(`[init] ensureGitattributes: ${gaPath} not found (ENOENT), will create`);
|
|
@@ -3636,7 +3673,7 @@ async function ensureYggdrasilGitignore(yggRoot) {
|
|
|
3636
3673
|
const giPath = path13.join(yggRoot, ".gitignore");
|
|
3637
3674
|
let existing;
|
|
3638
3675
|
try {
|
|
3639
|
-
existing = await
|
|
3676
|
+
existing = await readFile11(giPath, "utf-8");
|
|
3640
3677
|
} catch (e) {
|
|
3641
3678
|
if (e.code !== "ENOENT") throw e;
|
|
3642
3679
|
debugWrite(`[init] ensureYggdrasilGitignore: ${giPath} not found (ENOENT), will create`);
|
|
@@ -3825,7 +3862,7 @@ async function writeReviewerConfig(yggRoot, config) {
|
|
|
3825
3862
|
const configPath = path13.join(yggRoot, "yg-config.yaml");
|
|
3826
3863
|
let raw = {};
|
|
3827
3864
|
try {
|
|
3828
|
-
const content14 = await
|
|
3865
|
+
const content14 = await readFile11(configPath, "utf-8");
|
|
3829
3866
|
raw = yamlParse(content14) ?? {};
|
|
3830
3867
|
} catch (err) {
|
|
3831
3868
|
const e = err;
|
|
@@ -3857,7 +3894,7 @@ async function writeSecretsFile(yggRoot, provider, apiKey) {
|
|
|
3857
3894
|
const secretsPath = path13.join(yggRoot, "yg-secrets.yaml");
|
|
3858
3895
|
let raw = {};
|
|
3859
3896
|
try {
|
|
3860
|
-
const content14 = await
|
|
3897
|
+
const content14 = await readFile11(secretsPath, "utf-8");
|
|
3861
3898
|
raw = yamlParse(content14) ?? {};
|
|
3862
3899
|
} catch (err) {
|
|
3863
3900
|
const e = err;
|
|
@@ -3919,7 +3956,7 @@ async function createYggdrasilStructure(projectRoot, yggRoot, platform) {
|
|
|
3919
3956
|
const schemaFiles = entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
3920
3957
|
for (const file of schemaFiles) {
|
|
3921
3958
|
const srcPath = path13.join(graphSchemasDir, file);
|
|
3922
|
-
const content14 = await
|
|
3959
|
+
const content14 = await readFile11(srcPath, "utf-8");
|
|
3923
3960
|
await writeFile4(path13.join(schemasDir, file), content14, "utf-8");
|
|
3924
3961
|
}
|
|
3925
3962
|
} catch (err) {
|
|
@@ -3966,9 +4003,8 @@ async function existingInit(projectRoot) {
|
|
|
3966
4003
|
}
|
|
3967
4004
|
p.intro(chalk2.bold("Yggdrasil Configuration"));
|
|
3968
4005
|
const currentVersion = await detectVersion(yggRoot);
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
p.log.step(`Graph version ${currentVersion} detected \u2014 CLI is ${cliVersion}. Upgrade required.`);
|
|
4006
|
+
if (currentVersion && currentVersion !== CLI_SUPPORTED_SCHEMA) {
|
|
4007
|
+
p.log.step(`Graph schema ${currentVersion} detected \u2014 this CLI uses schema ${CLI_SUPPORTED_SCHEMA}. Upgrade required.`);
|
|
3972
4008
|
p.log.info("Select the agent platform so rules and schemas advance together.");
|
|
3973
4009
|
const platform = await promptPlatform();
|
|
3974
4010
|
const s = p.spinner();
|
|
@@ -4994,7 +5030,7 @@ function formatFileContext(data) {
|
|
|
4994
5030
|
}
|
|
4995
5031
|
|
|
4996
5032
|
// src/io/file-content-cache.ts
|
|
4997
|
-
import { readFile as
|
|
5033
|
+
import { readFile as readFile12, stat as stat4 } from "fs/promises";
|
|
4998
5034
|
var SIZE_LIMIT_BYTES = 5 * 1024 * 1024;
|
|
4999
5035
|
var BINARY_PROBE_BYTES = 8 * 1024;
|
|
5000
5036
|
var FileContentCache = class {
|
|
@@ -5024,7 +5060,7 @@ var FileContentCache = class {
|
|
|
5024
5060
|
}
|
|
5025
5061
|
let buf;
|
|
5026
5062
|
try {
|
|
5027
|
-
buf = await
|
|
5063
|
+
buf = await readFile12(absPath);
|
|
5028
5064
|
} catch (e) {
|
|
5029
5065
|
return {
|
|
5030
5066
|
isBinary: false,
|
|
@@ -5255,21 +5291,21 @@ function renderNode(node, indent, lines) {
|
|
|
5255
5291
|
}
|
|
5256
5292
|
|
|
5257
5293
|
// src/io/hash.ts
|
|
5258
|
-
import { readFile as
|
|
5294
|
+
import { readFile as readFile14, readdir as readdir5, stat as stat5 } from "fs/promises";
|
|
5259
5295
|
import path15 from "path";
|
|
5260
5296
|
import { createHash } from "crypto";
|
|
5261
5297
|
import { createRequire as createRequire2 } from "module";
|
|
5262
5298
|
|
|
5263
5299
|
// src/io/repo-scanner.ts
|
|
5264
|
-
import { readFile as
|
|
5265
|
-
import { join, relative, sep } from "path";
|
|
5300
|
+
import { readFile as readFile13, readdir as readdir4 } from "fs/promises";
|
|
5301
|
+
import { join as join2, relative, sep } from "path";
|
|
5266
5302
|
import { createRequire } from "module";
|
|
5267
5303
|
var require2 = createRequire(import.meta.url);
|
|
5268
5304
|
var ignoreFactory = require2("ignore");
|
|
5269
5305
|
var YGGDRASIL_DIRNAME = ".yggdrasil";
|
|
5270
5306
|
async function loadRootGitignoreStack(projectRoot) {
|
|
5271
5307
|
try {
|
|
5272
|
-
const content14 = await
|
|
5308
|
+
const content14 = await readFile13(join2(projectRoot, ".gitignore"), "utf-8");
|
|
5273
5309
|
const ig = ignoreFactory();
|
|
5274
5310
|
ig.add(content14);
|
|
5275
5311
|
return [{ dir: projectRoot, ig }];
|
|
@@ -5290,7 +5326,7 @@ function isIgnoredByStack(absPath, stack) {
|
|
|
5290
5326
|
async function collectFiles(dir, projectRoot, stack) {
|
|
5291
5327
|
let localStack = stack;
|
|
5292
5328
|
try {
|
|
5293
|
-
const content14 = await
|
|
5329
|
+
const content14 = await readFile13(join2(dir, ".gitignore"), "utf-8");
|
|
5294
5330
|
const ig = ignoreFactory();
|
|
5295
5331
|
ig.add(content14);
|
|
5296
5332
|
localStack = [...stack, { dir, ig }];
|
|
@@ -5306,7 +5342,7 @@ async function collectFiles(dir, projectRoot, stack) {
|
|
|
5306
5342
|
}
|
|
5307
5343
|
const results = [];
|
|
5308
5344
|
for (const entry of entries) {
|
|
5309
|
-
const absPath =
|
|
5345
|
+
const absPath = join2(dir, entry.name);
|
|
5310
5346
|
if (entry.isDirectory()) {
|
|
5311
5347
|
if (entry.name === ".git") continue;
|
|
5312
5348
|
if (entry.name === YGGDRASIL_DIRNAME && dir === projectRoot) continue;
|
|
@@ -5344,13 +5380,13 @@ async function walkRepoFiles(projectRoot) {
|
|
|
5344
5380
|
var require3 = createRequire2(import.meta.url);
|
|
5345
5381
|
var ignoreFactory2 = require3("ignore");
|
|
5346
5382
|
async function hashFile(filePath) {
|
|
5347
|
-
const content14 = await
|
|
5383
|
+
const content14 = await readFile14(filePath);
|
|
5348
5384
|
return createHash("sha256").update(content14).digest("hex");
|
|
5349
5385
|
}
|
|
5350
5386
|
async function loadRootGitignoreStack2(projectRoot) {
|
|
5351
5387
|
if (!projectRoot) return [];
|
|
5352
5388
|
try {
|
|
5353
|
-
const content14 = await
|
|
5389
|
+
const content14 = await readFile14(path15.join(projectRoot, ".gitignore"), "utf-8");
|
|
5354
5390
|
const matcher = ignoreFactory2();
|
|
5355
5391
|
matcher.add(content14);
|
|
5356
5392
|
return [{ basePath: projectRoot, matcher }];
|
|
@@ -5375,7 +5411,7 @@ function hashBytes(bytes) {
|
|
|
5375
5411
|
async function collectDirectoryFilePaths(directoryPath, rootDirectoryPath, options) {
|
|
5376
5412
|
let stack = options.gitignoreStack ?? [];
|
|
5377
5413
|
try {
|
|
5378
|
-
const localContent = await
|
|
5414
|
+
const localContent = await readFile14(path15.join(directoryPath, ".gitignore"), "utf-8");
|
|
5379
5415
|
const localMatcher = ignoreFactory2();
|
|
5380
5416
|
localMatcher.add(localContent);
|
|
5381
5417
|
stack = [...stack, { basePath: directoryPath, matcher: localMatcher }];
|
|
@@ -6867,81 +6903,6 @@ async function checkDirectoriesHaveNodeYaml(graph) {
|
|
|
6867
6903
|
return issues;
|
|
6868
6904
|
}
|
|
6869
6905
|
|
|
6870
|
-
// src/io/secrets-parser.ts
|
|
6871
|
-
import { readFile as readFile14 } from "fs/promises";
|
|
6872
|
-
import { join as join2 } from "path";
|
|
6873
|
-
import { parse as parseYaml8 } from "yaml";
|
|
6874
|
-
async function loadSecrets(rootPath, providerName) {
|
|
6875
|
-
const secretsPath = join2(rootPath, "yg-secrets.yaml");
|
|
6876
|
-
let content14;
|
|
6877
|
-
try {
|
|
6878
|
-
content14 = await readFile14(secretsPath, "utf-8");
|
|
6879
|
-
} catch (err) {
|
|
6880
|
-
debugWrite(`[secrets-parser] readFile: ${err.message}`);
|
|
6881
|
-
return void 0;
|
|
6882
|
-
}
|
|
6883
|
-
const raw = parseYaml8(content14);
|
|
6884
|
-
if (raw === null || raw === void 0) return void 0;
|
|
6885
|
-
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
6886
|
-
throw new Error(`yg-secrets.yaml: top level must be a YAML mapping`);
|
|
6887
|
-
}
|
|
6888
|
-
const rawObj = raw;
|
|
6889
|
-
if (rawObj.reviewer === void 0) return void 0;
|
|
6890
|
-
if (typeof rawObj.reviewer !== "object" || rawObj.reviewer === null || Array.isArray(rawObj.reviewer)) {
|
|
6891
|
-
throw new Error(`yg-secrets.yaml: 'reviewer' must be a YAML mapping`);
|
|
6892
|
-
}
|
|
6893
|
-
if (!providerName) return void 0;
|
|
6894
|
-
const reviewerRaw = rawObj.reviewer;
|
|
6895
|
-
const providerSection = reviewerRaw[providerName];
|
|
6896
|
-
if (providerSection === void 0) return void 0;
|
|
6897
|
-
if (typeof providerSection !== "object" || providerSection === null || Array.isArray(providerSection)) {
|
|
6898
|
-
throw new Error(`yg-secrets.yaml: 'reviewer.${providerName}' must be a YAML mapping`);
|
|
6899
|
-
}
|
|
6900
|
-
return extractSecretFields(providerSection, providerName);
|
|
6901
|
-
}
|
|
6902
|
-
function extractSecretFields(raw, providerName) {
|
|
6903
|
-
const ctx = (field) => `yg-secrets.yaml at reviewer.${providerName}.${field}`;
|
|
6904
|
-
if (raw.api_key !== void 0) {
|
|
6905
|
-
if (typeof raw.api_key !== "string") throw new Error(`${ctx("api_key")}: must be a string`);
|
|
6906
|
-
if (raw.api_key.trim() !== "") {
|
|
6907
|
-
return { api_key: raw.api_key };
|
|
6908
|
-
}
|
|
6909
|
-
}
|
|
6910
|
-
return void 0;
|
|
6911
|
-
}
|
|
6912
|
-
function mergeLlmConfig(base, secrets) {
|
|
6913
|
-
return { ...base, ...secrets };
|
|
6914
|
-
}
|
|
6915
|
-
async function inspectSecretsForValidation(rootPath) {
|
|
6916
|
-
const secretsPath = join2(rootPath, "yg-secrets.yaml");
|
|
6917
|
-
let content14;
|
|
6918
|
-
try {
|
|
6919
|
-
content14 = await readFile14(secretsPath, "utf-8");
|
|
6920
|
-
} catch {
|
|
6921
|
-
return [];
|
|
6922
|
-
}
|
|
6923
|
-
const raw = parseYaml8(content14);
|
|
6924
|
-
if (raw === null || raw === void 0) return [];
|
|
6925
|
-
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
6926
|
-
throw new Error(`yg-secrets.yaml: top level must be a YAML mapping`);
|
|
6927
|
-
}
|
|
6928
|
-
if (raw.reviewer === void 0) return [];
|
|
6929
|
-
if (typeof raw.reviewer !== "object" || raw.reviewer === null || Array.isArray(raw.reviewer)) {
|
|
6930
|
-
throw new Error(`yg-secrets.yaml: 'reviewer' must be a YAML mapping`);
|
|
6931
|
-
}
|
|
6932
|
-
const reviewerRaw = raw.reviewer;
|
|
6933
|
-
const results = [];
|
|
6934
|
-
for (const [provider, section] of Object.entries(reviewerRaw)) {
|
|
6935
|
-
if (!section || typeof section !== "object" || Array.isArray(section)) continue;
|
|
6936
|
-
const sectionObj = section;
|
|
6937
|
-
const foreignKeys = Object.keys(sectionObj).filter((k) => k !== "api_key");
|
|
6938
|
-
if (foreignKeys.length > 0) {
|
|
6939
|
-
results.push({ provider, foreignKeys });
|
|
6940
|
-
}
|
|
6941
|
-
}
|
|
6942
|
-
return results;
|
|
6943
|
-
}
|
|
6944
|
-
|
|
6945
6906
|
// src/core/checks/relations.ts
|
|
6946
6907
|
function findSimilar(target, candidates2) {
|
|
6947
6908
|
if (candidates2.length === 0) return null;
|
|
@@ -7205,21 +7166,6 @@ function checkMissingDescriptions(graph) {
|
|
|
7205
7166
|
}
|
|
7206
7167
|
return issues;
|
|
7207
7168
|
}
|
|
7208
|
-
async function checkSecretsCredentialsOnly(graph) {
|
|
7209
|
-
const foreign = await inspectSecretsForValidation(graph.rootPath);
|
|
7210
|
-
const issues = [];
|
|
7211
|
-
for (const { provider, foreignKeys } of foreign) {
|
|
7212
|
-
for (const key of foreignKeys) {
|
|
7213
|
-
const msgData = {
|
|
7214
|
-
what: `yg-secrets.yaml has '${key}' under reviewer.${provider}.`,
|
|
7215
|
-
why: "The secrets file accepts only api_key; non-credential fields belong in yg-config.yaml tiers.",
|
|
7216
|
-
next: `Move '${key}' into reviewer.tiers.<name> in .yggdrasil/yg-config.yaml and remove it from yg-secrets.yaml.`
|
|
7217
|
-
};
|
|
7218
|
-
issues.push({ code: "secrets-non-credential-field", severity: "error", rule: "secrets-non-credential-field", ...issueMsg(msgData), messageData: msgData });
|
|
7219
|
-
}
|
|
7220
|
-
}
|
|
7221
|
-
return issues;
|
|
7222
|
-
}
|
|
7223
7169
|
|
|
7224
7170
|
// src/core/validator.ts
|
|
7225
7171
|
var ARCHITECTURE_FATAL_CODES = /* @__PURE__ */ new Set([
|
|
@@ -7332,7 +7278,6 @@ async function validate(graph, scope = "all") {
|
|
|
7332
7278
|
issues.push(...await checkAspectReferences(graph));
|
|
7333
7279
|
issues.push(...checkAspectStatusDowngrade(graph));
|
|
7334
7280
|
issues.push(...checkFileDuplicateMapping(graph));
|
|
7335
|
-
issues.push(...await checkSecretsCredentialsOnly(graph));
|
|
7336
7281
|
const strictOutcome = await checkStrictBackwardCoverage(graph, cache);
|
|
7337
7282
|
issues.push(...strictOutcome.issues);
|
|
7338
7283
|
allUnreadable.push(...strictOutcome.unreadable);
|
|
@@ -9285,11 +9230,7 @@ var STRUCTURAL_CODES = /* @__PURE__ */ new Set([
|
|
|
9285
9230
|
// re-validation in runCheck. Always an error (not an aspect, not suppressible);
|
|
9286
9231
|
// a node depends on another node without a declared, sanctioned relation, or its
|
|
9287
9232
|
// relation verdict could not be confirmed against the current tree.
|
|
9288
|
-
"relation-undeclared-dependency"
|
|
9289
|
-
// yg-secrets.yaml carries a non-credential field (only api_key is allowed).
|
|
9290
|
-
// Emitted as a blocking error and gates --approve; structural so the summary
|
|
9291
|
-
// tally counts it and computeSuggestedNext can point at it.
|
|
9292
|
-
"secrets-non-credential-field"
|
|
9233
|
+
"relation-undeclared-dependency"
|
|
9293
9234
|
]);
|
|
9294
9235
|
var COMPLETENESS_CODES = /* @__PURE__ */ new Set(["description-missing"]);
|
|
9295
9236
|
var APPROVE_GATING_CODES = /* @__PURE__ */ new Set([
|
|
@@ -9313,8 +9254,7 @@ var APPROVE_GATING_CODES = /* @__PURE__ */ new Set([
|
|
|
9313
9254
|
"aspect-reviewer-type-invalid",
|
|
9314
9255
|
"aspect-reviewer-unknown-key",
|
|
9315
9256
|
"aspect-tier-on-deterministic",
|
|
9316
|
-
"aspect-tier-unknown"
|
|
9317
|
-
"secrets-non-credential-field"
|
|
9257
|
+
"aspect-tier-unknown"
|
|
9318
9258
|
]);
|
|
9319
9259
|
|
|
9320
9260
|
// src/core/log-format.ts
|
|
@@ -9454,10 +9394,7 @@ function computeLlmInputHash(input) {
|
|
|
9454
9394
|
kind: "llm",
|
|
9455
9395
|
references,
|
|
9456
9396
|
tier: {
|
|
9457
|
-
|
|
9458
|
-
consensus: input.tier.consensus,
|
|
9459
|
-
name: input.tier.name,
|
|
9460
|
-
provider: input.tier.provider
|
|
9397
|
+
name: input.tier.name
|
|
9461
9398
|
}
|
|
9462
9399
|
};
|
|
9463
9400
|
return hashString(codePointCanonicalJson(canonical));
|
|
@@ -9490,14 +9427,8 @@ function hashListObservation(entries) {
|
|
|
9490
9427
|
function hashExistsObservation(result) {
|
|
9491
9428
|
return hashString(result === false ? "false" : result);
|
|
9492
9429
|
}
|
|
9493
|
-
function tierHashView(tierName
|
|
9494
|
-
|
|
9495
|
-
return {
|
|
9496
|
-
name: tierName,
|
|
9497
|
-
provider: llm.provider,
|
|
9498
|
-
consensus: llm.consensus,
|
|
9499
|
-
config: rest
|
|
9500
|
-
};
|
|
9430
|
+
function tierHashView(tierName) {
|
|
9431
|
+
return { name: tierName };
|
|
9501
9432
|
}
|
|
9502
9433
|
|
|
9503
9434
|
// src/structure/ctx-graph.ts
|
|
@@ -9673,16 +9604,8 @@ function ruleHashFor(aspect, filename) {
|
|
|
9673
9604
|
function nodeDescriptionFor(graph, nodePath) {
|
|
9674
9605
|
return graph.nodes.get(nodePath)?.meta.description ?? "";
|
|
9675
9606
|
}
|
|
9676
|
-
function tierHashViewFromTier(tierName
|
|
9677
|
-
|
|
9678
|
-
provider,
|
|
9679
|
-
consensus,
|
|
9680
|
-
// Excluded gates — never fold into the verdict hash.
|
|
9681
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
9682
|
-
max_prompt_chars: _mpc,
|
|
9683
|
-
...rest
|
|
9684
|
-
} = tier;
|
|
9685
|
-
return tierHashView(tierName, { provider, consensus, config: rest });
|
|
9607
|
+
function tierHashViewFromTier(tierName) {
|
|
9608
|
+
return tierHashView(tierName);
|
|
9686
9609
|
}
|
|
9687
9610
|
|
|
9688
9611
|
// src/llm/xml-escape.ts
|
|
@@ -9868,7 +9791,7 @@ async function verifyLlmPair(pair, aspect, graph, lock, projectRoot, storedEntry
|
|
|
9868
9791
|
ruleHash,
|
|
9869
9792
|
files: subjects.map((s) => [s.path, hashCached(path24.resolve(projectRoot, s.path), s.bytes)]),
|
|
9870
9793
|
references: referencesForHash,
|
|
9871
|
-
tier: tierHashViewFromTier(tierResult.tierName
|
|
9794
|
+
tier: tierHashViewFromTier(tierResult.tierName),
|
|
9872
9795
|
verdict: storedEntry.verdict
|
|
9873
9796
|
});
|
|
9874
9797
|
valid4 = expectedHash === storedEntry.hash;
|
|
@@ -13289,14 +13212,41 @@ import { execFile as execFile2 } from "child_process";
|
|
|
13289
13212
|
import { tmpdir } from "os";
|
|
13290
13213
|
import { promisify as promisify2 } from "util";
|
|
13291
13214
|
var execFileAsync2 = promisify2(execFile2);
|
|
13215
|
+
function coerceBool(v) {
|
|
13216
|
+
if (typeof v === "boolean") return v;
|
|
13217
|
+
if (typeof v === "string") {
|
|
13218
|
+
const s = v.trim().toLowerCase();
|
|
13219
|
+
if (s === "true") return true;
|
|
13220
|
+
if (s === "false") return false;
|
|
13221
|
+
}
|
|
13222
|
+
return void 0;
|
|
13223
|
+
}
|
|
13292
13224
|
function normalizeResponse(raw) {
|
|
13293
13225
|
const r = raw;
|
|
13294
13226
|
return {
|
|
13295
|
-
satisfied:
|
|
13227
|
+
satisfied: coerceBool(r.satisfied) ?? false,
|
|
13296
13228
|
reason: typeof r.reason === "string" ? r.reason : "",
|
|
13297
13229
|
errorSource: "codeViolation"
|
|
13298
13230
|
};
|
|
13299
13231
|
}
|
|
13232
|
+
function salvageVerdict(text2) {
|
|
13233
|
+
if (!text2.includes("{")) return void 0;
|
|
13234
|
+
const verdicts = [...text2.matchAll(/"satisfied"\s*:\s*"?(true|false)"?/gi)];
|
|
13235
|
+
if (verdicts.length === 0) return void 0;
|
|
13236
|
+
const satisfied = verdicts[verdicts.length - 1][1].toLowerCase() === "true";
|
|
13237
|
+
let reason = "";
|
|
13238
|
+
const rms = [...text2.matchAll(/"reason"\s*:\s*"/g)];
|
|
13239
|
+
if (rms.length > 0) {
|
|
13240
|
+
const m = rms[rms.length - 1];
|
|
13241
|
+
reason = text2.slice((m.index ?? 0) + m[0].length).split(/"\s*,\s*"satisfied"\s*:/i)[0].replace(/\s*}\s*$/, "").replace(/"\s*$/, "").replace(/\\"/g, '"').replace(/\\n/g, "\n").replace(/\\t/g, " ").replace(/\\\\/g, "\\").trim();
|
|
13242
|
+
}
|
|
13243
|
+
debugWrite("[parseAspectResponse] salvaged verdict from invalid-JSON reply");
|
|
13244
|
+
return {
|
|
13245
|
+
satisfied,
|
|
13246
|
+
reason: reason || "(verdict salvaged; reviewer reason was not valid JSON)",
|
|
13247
|
+
errorSource: "codeViolation"
|
|
13248
|
+
};
|
|
13249
|
+
}
|
|
13300
13250
|
function extractLastVerdict(text2) {
|
|
13301
13251
|
let last;
|
|
13302
13252
|
for (let i = 0; i < text2.length; i++) {
|
|
@@ -13319,7 +13269,7 @@ function extractLastVerdict(text2) {
|
|
|
13319
13269
|
if (depth === 0) {
|
|
13320
13270
|
try {
|
|
13321
13271
|
const obj = JSON.parse(text2.slice(i, j + 1));
|
|
13322
|
-
if (obj &&
|
|
13272
|
+
if (obj && coerceBool(obj.satisfied) !== void 0) last = obj;
|
|
13323
13273
|
} catch {
|
|
13324
13274
|
}
|
|
13325
13275
|
break;
|
|
@@ -13347,6 +13297,8 @@ function parseAspectResponse(output) {
|
|
|
13347
13297
|
}
|
|
13348
13298
|
const verdict = extractLastVerdict(trimmed);
|
|
13349
13299
|
if (verdict) return normalizeResponse(verdict);
|
|
13300
|
+
const salvaged = salvageVerdict(trimmed);
|
|
13301
|
+
if (salvaged) return salvaged;
|
|
13350
13302
|
debugWrite("[parseAspectResponse] no parseable JSON verdict \u2014 classifying as provider error");
|
|
13351
13303
|
return { satisfied: false, reason: `Unparseable reviewer response: ${trimmed.slice(0, 160)}`, errorSource: "provider" };
|
|
13352
13304
|
}
|
|
@@ -13432,10 +13384,12 @@ var OllamaProvider = class {
|
|
|
13432
13384
|
endpoint;
|
|
13433
13385
|
model;
|
|
13434
13386
|
temperature;
|
|
13387
|
+
timeout;
|
|
13435
13388
|
constructor(config) {
|
|
13436
13389
|
this.endpoint = config.endpoint ?? "http://localhost:11434";
|
|
13437
13390
|
this.model = config.model;
|
|
13438
13391
|
this.temperature = config.temperature;
|
|
13392
|
+
this.timeout = config.timeout ?? 3e5;
|
|
13439
13393
|
}
|
|
13440
13394
|
async isAvailable() {
|
|
13441
13395
|
try {
|
|
@@ -13452,8 +13406,15 @@ var OllamaProvider = class {
|
|
|
13452
13406
|
model: this.model,
|
|
13453
13407
|
messages: [{ role: "user", content: prompt }],
|
|
13454
13408
|
stream: false,
|
|
13455
|
-
|
|
13456
|
-
|
|
13409
|
+
// Native thinking ON: the model reasons in its own `thinking` channel and
|
|
13410
|
+
// emits only the final JSON verdict in `content`. The verdict therefore
|
|
13411
|
+
// follows the reasoning (no snap-judgment before the rules are weighed) and
|
|
13412
|
+
// chain-of-thought never leaks into the parsed `reason`.
|
|
13413
|
+
think: true,
|
|
13414
|
+
// num_predict: -1 → generate until the model stops; no cap, so the verdict
|
|
13415
|
+
// is never truncated (a cut-off JSON would otherwise fail to parse and waste
|
|
13416
|
+
// a re-verification).
|
|
13417
|
+
options: { temperature: this.temperature, num_predict: -1 },
|
|
13457
13418
|
format: "json"
|
|
13458
13419
|
};
|
|
13459
13420
|
try {
|
|
@@ -13461,7 +13422,7 @@ var OllamaProvider = class {
|
|
|
13461
13422
|
method: "POST",
|
|
13462
13423
|
headers: { "Content-Type": "application/json" },
|
|
13463
13424
|
body: JSON.stringify(body)
|
|
13464
|
-
}, "ollama");
|
|
13425
|
+
}, "ollama", this.timeout);
|
|
13465
13426
|
if (!res.ok) {
|
|
13466
13427
|
debugWrite(`[ollama] http_error: ${res.status} ${res.statusText}`);
|
|
13467
13428
|
return fallback;
|
|
@@ -13571,7 +13532,11 @@ var AnthropicProvider = class {
|
|
|
13571
13532
|
body: JSON.stringify({
|
|
13572
13533
|
model: this.model,
|
|
13573
13534
|
messages: [{ role: "user", content: prompt }],
|
|
13574
|
-
max_tokens
|
|
13535
|
+
// Never cap the verdict. The Anthropic API requires a max_tokens ceiling
|
|
13536
|
+
// (there is no "unlimited"), so set it far above any reviewer reply — a
|
|
13537
|
+
// verdict is hundreds of tokens; this is many times that — so it is never
|
|
13538
|
+
// truncated. A truncated reply still fails closed when parsed.
|
|
13539
|
+
max_tokens: 8192,
|
|
13575
13540
|
temperature: this.temperature
|
|
13576
13541
|
})
|
|
13577
13542
|
}, "anthropic");
|
|
@@ -14698,7 +14663,7 @@ async function fillLlmPair(graph, projectRoot, pair, aspect, tier, tierName, mer
|
|
|
14698
14663
|
ruleHash: ruleHashFor(aspect, "content.md"),
|
|
14699
14664
|
files: subjects.map((s) => [s.path, hashBytes(s.bytes)]),
|
|
14700
14665
|
references: referencesForHash,
|
|
14701
|
-
tier: tierHashViewFromTier(tierName
|
|
14666
|
+
tier: tierHashViewFromTier(tierName),
|
|
14702
14667
|
verdict
|
|
14703
14668
|
});
|
|
14704
14669
|
const entry = { verdict, hash };
|
|
@@ -14765,7 +14730,7 @@ async function logGateBlocks(graph, projectRoot, node, lock, emitIssue) {
|
|
|
14765
14730
|
if (!blocked) return false;
|
|
14766
14731
|
emitIssue({
|
|
14767
14732
|
what: `No fresh log entry for node '${toPosixPath(node.path)}' \u2014 mandatory before --approve when source changed.`,
|
|
14768
|
-
why: `Node type '${node.meta.type}' has log_required: true \u2014 every source change needs a justification entry capturing WHY.
|
|
14733
|
+
why: `Node type '${node.meta.type}' has log_required: true \u2014 every source change needs a justification entry capturing WHY. --approve stops here and approves nothing this run until a fresh entry exists.`,
|
|
14769
14734
|
next: `yg log add --node ${toPosixPath(node.path)} --reason '<justification>', then re-run: yg check --approve`
|
|
14770
14735
|
});
|
|
14771
14736
|
return true;
|
|
@@ -14929,6 +14894,14 @@ async function runFill(graph, opts) {
|
|
|
14929
14894
|
const blocked = await logGateBlocks(graph, projectRoot, node, lock, emitIssue);
|
|
14930
14895
|
if (blocked) blockedNodes.add(nodePath);
|
|
14931
14896
|
}
|
|
14897
|
+
if (blockedNodes.size > 0) {
|
|
14898
|
+
throw new FillGatingError([{
|
|
14899
|
+
code: "log-entry-required",
|
|
14900
|
+
what: `${blockedNodes.size} node(s) need a fresh log entry before --approve.`,
|
|
14901
|
+
why: "Source changed on log_required nodes without a justification entry; nothing was approved this run.",
|
|
14902
|
+
next: "Add the log entries listed above (yg log add), then re-run: yg check --approve"
|
|
14903
|
+
}]);
|
|
14904
|
+
}
|
|
14932
14905
|
let reviewerCallsMade = 0;
|
|
14933
14906
|
let infraFailures = 0;
|
|
14934
14907
|
let runtimeErrors = 0;
|
|
@@ -14968,7 +14941,6 @@ async function runFill(graph, opts) {
|
|
|
14968
14941
|
next: `Fix the deterministic violations on '${toPosixPath(nodePath)}', then re-run: yg check --approve`
|
|
14969
14942
|
});
|
|
14970
14943
|
}
|
|
14971
|
-
const secretsByProvider = /* @__PURE__ */ new Map();
|
|
14972
14944
|
const referencesCache = /* @__PURE__ */ new Map();
|
|
14973
14945
|
const byTier = /* @__PURE__ */ new Map();
|
|
14974
14946
|
const infraReport = [];
|
|
@@ -14995,12 +14967,7 @@ async function runFill(graph, opts) {
|
|
|
14995
14967
|
const parallel = Math.max(1, graph.config.parallel ?? 1);
|
|
14996
14968
|
for (const [tierName, group] of byTier) {
|
|
14997
14969
|
const baseTier = group[0].tier;
|
|
14998
|
-
|
|
14999
|
-
const secrets = await loadSecrets(graph.rootPath, baseTier.provider);
|
|
15000
|
-
secretsByProvider.set(baseTier.provider, secrets ?? null);
|
|
15001
|
-
}
|
|
15002
|
-
const merged = applySecrets(baseTier, secretsByProvider.get(baseTier.provider));
|
|
15003
|
-
const provider = createLlmProvider(merged);
|
|
14970
|
+
const provider = createLlmProvider(baseTier);
|
|
15004
14971
|
let available;
|
|
15005
14972
|
try {
|
|
15006
14973
|
available = await provider.isAvailable();
|
|
@@ -15010,16 +14977,22 @@ async function runFill(graph, opts) {
|
|
|
15010
14977
|
}
|
|
15011
14978
|
if (!available) {
|
|
15012
14979
|
infraFailures += group.length;
|
|
15013
|
-
infraReport.push({ provider:
|
|
14980
|
+
infraReport.push({ provider: baseTier.provider, tier: tierName });
|
|
15014
14981
|
emitIssue({
|
|
15015
|
-
what: `Reviewer provider '${
|
|
14982
|
+
what: `Reviewer provider '${baseTier.provider}' (tier '${tierName}') is unreachable \u2014 ${group.length} pair(s) left unverified.`,
|
|
15016
14983
|
why: "The configured reviewer endpoint did not respond (availability check failed) \u2014 an infrastructure problem, not a code violation. No verdict was written.",
|
|
15017
14984
|
next: `Check the provider endpoint, network, and credentials, then re-run: yg check --approve`
|
|
15018
14985
|
});
|
|
15019
14986
|
continue;
|
|
15020
14987
|
}
|
|
15021
14988
|
const outcomes = await runPairPool(group, parallel, async (item) => {
|
|
15022
|
-
|
|
14989
|
+
const outcome = await fillLlmPair(graph, projectRoot, item.pair, item.aspect, item.tier, item.tierName, baseTier, provider, referencesCache);
|
|
14990
|
+
if (outcome.kind !== "infra") {
|
|
14991
|
+
await setEntry(item.pair.aspectId, item.pair.unitKey, outcome.entry);
|
|
14992
|
+
write(` [llm] ${item.pair.aspectId} on ${toPosixPath(item.pair.unitKey)} \u2014 ${outcome.entry.verdict}
|
|
14993
|
+
`);
|
|
14994
|
+
}
|
|
14995
|
+
return outcome;
|
|
15023
14996
|
});
|
|
15024
14997
|
for (let i = 0; i < group.length; i++) {
|
|
15025
14998
|
const item = group[i];
|
|
@@ -15027,17 +15000,13 @@ async function runFill(graph, opts) {
|
|
|
15027
15000
|
reviewerCallsMade += outcome.callsMade;
|
|
15028
15001
|
if (outcome.kind === "infra") {
|
|
15029
15002
|
infraFailures += 1;
|
|
15030
|
-
infraReport.push({ provider:
|
|
15003
|
+
infraReport.push({ provider: baseTier.provider, tier: tierName });
|
|
15031
15004
|
emitIssue({
|
|
15032
15005
|
what: `Reviewer could not verify aspect '${item.pair.aspectId}' on ${toPosixPath(item.pair.unitKey)} \u2014 left unverified.`,
|
|
15033
15006
|
why: outcome.why,
|
|
15034
15007
|
next: `Resolve the provider/config problem, then re-run: yg check --approve`
|
|
15035
15008
|
});
|
|
15036
|
-
continue;
|
|
15037
15009
|
}
|
|
15038
|
-
await setEntry(item.pair.aspectId, item.pair.unitKey, outcome.entry);
|
|
15039
|
-
write(` [llm] ${item.pair.aspectId} on ${toPosixPath(item.pair.unitKey)} \u2014 ${outcome.entry.verdict}
|
|
15040
|
-
`);
|
|
15041
15010
|
}
|
|
15042
15011
|
}
|
|
15043
15012
|
await applyPositiveClosure(graph, projectRoot, lock, blockedNodes, persistLock);
|
|
@@ -15066,9 +15035,6 @@ async function runFill(graph, opts) {
|
|
|
15066
15035
|
const checkResult = await runCheck(graph, opts.gitTrackedFiles);
|
|
15067
15036
|
return { checkResult, reviewerCallsMade, infraFailures, runtimeErrors };
|
|
15068
15037
|
}
|
|
15069
|
-
function applySecrets(tier, secrets) {
|
|
15070
|
-
return secrets ? mergeLlmConfig(tier, secrets) : tier;
|
|
15071
|
-
}
|
|
15072
15038
|
|
|
15073
15039
|
// src/cli/check.ts
|
|
15074
15040
|
import { execFileSync } from "child_process";
|
|
@@ -15636,8 +15602,7 @@ async function runLlmAspectTest(graph, projectRoot, aspect, nodePath, dryRun) {
|
|
|
15636
15602
|
const nodeDescription = nodeDescriptionFor(graph, nodePath);
|
|
15637
15603
|
const aspectContent = contentFor(aspect, "content.md");
|
|
15638
15604
|
if (!dryRun) {
|
|
15639
|
-
const
|
|
15640
|
-
const mergedTier = secrets ? mergeLlmConfig(tier, secrets) : tier;
|
|
15605
|
+
const mergedTier = tier;
|
|
15641
15606
|
const provider = createLlmProvider(mergedTier);
|
|
15642
15607
|
let available;
|
|
15643
15608
|
try {
|
|
@@ -18344,8 +18309,10 @@ verdict: "approved" | "refused" // the discrete token \u2014 tamper
|
|
|
18344
18309
|
\`\`\`
|
|
18345
18310
|
|
|
18346
18311
|
LLM pairs additionally fold their prompt inputs: the aspect description, each
|
|
18347
|
-
reference \`[path, sha256(bytes), description]\`, and the resolved tier
|
|
18348
|
-
|
|
18312
|
+
reference \`[path, sha256(bytes), description]\`, and the resolved tier's NAME.
|
|
18313
|
+
The tier's config (provider, model, endpoint, temperature, consensus, api_key,
|
|
18314
|
+
timeout) is NOT a verdict input \u2014 only the name folds in, so a named tier can be
|
|
18315
|
+
re-pointed at a different reviewer without invalidating any recorded verdict.
|
|
18349
18316
|
|
|
18350
18317
|
Deterministic pairs additionally fold the **observation set** \u2014 everything the
|
|
18351
18318
|
check observed through \`ctx\` beyond its subject files, recorded by the runner:
|
|
@@ -18375,8 +18342,10 @@ costs at worst one free re-run; a missed one yields a stale-green verdict.
|
|
|
18375
18342
|
- **\`reason\`** / free-text output \u2014 only the discrete verdict token is folded.
|
|
18376
18343
|
- **Node description** \u2014 prompt garnish, not hashed (the aspect description IS
|
|
18377
18344
|
hashed for LLM pairs).
|
|
18378
|
-
-
|
|
18379
|
-
|
|
18345
|
+
- **Tier config** \u2014 provider, model, endpoint, temperature, consensus, api_key,
|
|
18346
|
+
and timeout. Only the tier NAME folds into the hash; the resolved config is the
|
|
18347
|
+
reviewer's private business, so re-pointing a named tier at a different model or
|
|
18348
|
+
provider does not invalidate a verdict.
|
|
18380
18349
|
- **\`max_prompt_chars\`** \u2014 a gate, not an input; lowering it can trip the gate on
|
|
18381
18350
|
an already-verified pair without invalidating the verdict.
|
|
18382
18351
|
- **\`when\` / \`implies\` / port declarations** \u2014 applicability is recomputed live
|
|
@@ -18630,10 +18599,10 @@ Provider-specific options passed to the LLM client:
|
|
|
18630
18599
|
| \`endpoint\` | string | Required for \`openai-compatible\` (no default host \u2014 else falls back to api.openai.com); \`ollama\` defaults to http://localhost:11434. |
|
|
18631
18600
|
| \`timeout\` | number | Timeout in seconds. Default 300. Applies to CLI providers only \u2014 non-CLI/API providers ignore it. Not folded into a verdict's hash (a transport knob). |
|
|
18632
18601
|
|
|
18633
|
-
API keys do NOT
|
|
18634
|
-
|
|
18635
|
-
the provider
|
|
18636
|
-
\`yg-secrets.yaml\`.
|
|
18602
|
+
API keys do NOT belong in the committed config \u2014 put them in \`yg-secrets.yaml\`,
|
|
18603
|
+
the local overlay (see "Secrets and local overrides" below). API providers also
|
|
18604
|
+
read the provider API key from the standard \`*_API_KEY\` environment variable as
|
|
18605
|
+
a fallback when it is not present in \`yg-secrets.yaml\`.
|
|
18637
18606
|
|
|
18638
18607
|
## Multi-tier example
|
|
18639
18608
|
|
|
@@ -18670,25 +18639,30 @@ reviewer:
|
|
|
18670
18639
|
type: llm
|
|
18671
18640
|
\`\`\`
|
|
18672
18641
|
|
|
18673
|
-
## Secrets (yg-secrets.yaml)
|
|
18642
|
+
## Secrets and local overrides (yg-secrets.yaml)
|
|
18674
18643
|
|
|
18675
|
-
|
|
18676
|
-
|
|
18644
|
+
\`yg-secrets.yaml\` is a deep-merge OVERLAY over \`yg-config.yaml\`: it mirrors the
|
|
18645
|
+
same shape, and any field present in it wins. Use it for the API key of a tier,
|
|
18646
|
+
or to point a named tier at a different provider/model/endpoint on your machine.
|
|
18647
|
+
It is gitignored by default \u2014 see "Local state (.yggdrasil/.gitignore)" below \u2014
|
|
18648
|
+
and is never committed.
|
|
18677
18649
|
|
|
18678
18650
|
\`\`\`yaml
|
|
18679
18651
|
# .yggdrasil/yg-secrets.yaml \u2014 gitignored, never commit
|
|
18680
18652
|
reviewer:
|
|
18681
|
-
|
|
18682
|
-
|
|
18653
|
+
tiers:
|
|
18654
|
+
standard:
|
|
18655
|
+
config:
|
|
18656
|
+
api_key: sk-ant-...
|
|
18683
18657
|
\`\`\`
|
|
18684
18658
|
|
|
18685
|
-
|
|
18686
|
-
|
|
18687
|
-
|
|
18659
|
+
Because only the tier NAME is folded into a verdict's hash, an overlay never
|
|
18660
|
+
invalidates recorded baselines: the committed config names a canonical reviewer
|
|
18661
|
+
while each machine points the same named tier at its own provider, model, or key.
|
|
18688
18662
|
|
|
18689
|
-
API providers also read the provider API key from
|
|
18690
|
-
|
|
18691
|
-
\`yg-secrets.yaml\`.
|
|
18663
|
+
API providers also read the provider API key from the standard \`*_API_KEY\`
|
|
18664
|
+
environment variable as a fallback. If the env var is set, the key is not needed
|
|
18665
|
+
in \`yg-secrets.yaml\`.
|
|
18692
18666
|
|
|
18693
18667
|
\`yg-config.yaml\` itself must never contain credentials. Commit it to the
|
|
18694
18668
|
repository \u2014 it is safe to share.
|
|
@@ -46,4 +46,8 @@ reviewer: # required — aspect verification during yg c
|
|
|
46
46
|
# tier: deep
|
|
47
47
|
#
|
|
48
48
|
# Tier names match ^[a-zA-Z][a-zA-Z0-9_-]{0,62}$ and `default` is reserved.
|
|
49
|
-
#
|
|
49
|
+
# yg-secrets.yaml is a deep-merge overlay over this file (gitignored): it
|
|
50
|
+
# mirrors the same shape and overrides any field locally — most often a
|
|
51
|
+
# tier's api_key, or pointing a named tier at a different provider/model.
|
|
52
|
+
# Only the tier NAME is folded into a verdict hash, so a local override never
|
|
53
|
+
# invalidates recorded baselines. Keep credentials out of this committed file.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chrisdudek/yg",
|
|
3
|
-
"version": "5.0.
|
|
4
|
-
"description": "Architecture rules your coding agent can't ignore.
|
|
3
|
+
"version": "5.0.1",
|
|
4
|
+
"description": "Architecture rules your AI coding agent can't ignore. It gets the rules for a file before it edits, and every change is checked — by a free local script or an LLM reviewer — before it moves on. Works with Claude Code, Cursor, Copilot, Codex, Cline.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"yg": "dist/bin.js"
|