@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 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. You write them in plain Markdown; a reviewer verifies every change and feeds violations back into the agent's loop before it moves on. Works with Claude Code, Cursor, Copilot, Codex, Cline, and more. The reviewer runs against your code, not your diffs. The feedback is specific, and the agent has to fix before moving on.
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 readFile10, stat as stat3 } from "fs/promises";
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 change \u2014 makes the pair **unverified**, and \`yg check --approve\` re-verifies it. (A status flip is NOT an input \u2014 it never invalidates a verdict.) States are: **verified / unverified / refused**.
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 tier edit cascades re-verification across every aspect on that tier). Deterministic checks read files programmatically with no prompt and are never subject to the gate. Read \`schemas/yg-node.yaml\` before creating.
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
- signal: AbortSignal.timeout(15e3)
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 readFile3 } from "fs/promises";
1297
+ import { readFile as readFile4 } from "fs/promises";
1291
1298
  import path4 from "path";
1292
- import { parse as parseYaml2 } from "yaml";
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 readFile3(filePath, "utf-8");
1375
- const raw = parseYaml2(content14);
1376
- if (!raw || typeof raw !== "object") {
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 readFile4 } from "fs/promises";
1612
- import { parse as parseYaml3 } from "yaml";
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 readFile4(filePath, "utf-8");
1871
- const raw = parseYaml3(content14);
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 readFile6 } from "fs/promises";
2103
+ import { readFile as readFile7 } from "fs/promises";
2062
2104
  import path6 from "path";
2063
- import { parse as parseYaml4 } from "yaml";
2105
+ import { parse as parseYaml5 } from "yaml";
2064
2106
 
2065
2107
  // src/io/artifact-reader.ts
2066
- import { readFile as readFile5, readdir as readdir2 } from "fs/promises";
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 readFile5(filePath, "utf-8");
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 readFile6(aspectYamlPath, "utf-8");
2231
- const raw = parseYaml4(content14);
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 readFile7 } from "fs/promises";
2764
+ import { readFile as readFile8 } from "fs/promises";
2723
2765
  import path7 from "path";
2724
- import { parse as parseYaml5 } from "yaml";
2766
+ import { parse as parseYaml6 } from "yaml";
2725
2767
  async function parseFlow(flowDir, flowYamlPath) {
2726
- const content14 = await readFile7(flowYamlPath, "utf-8");
2727
- const raw = parseYaml5(content14);
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 readFile8 } from "fs/promises";
2825
+ import { readFile as readFile9 } from "fs/promises";
2784
2826
  import path8 from "path";
2785
- import { parse as parseYaml6 } from "yaml";
2827
+ import { parse as parseYaml7 } from "yaml";
2786
2828
  async function parseSchema(filePath) {
2787
2829
  const filename = path8.basename(filePath);
2788
- const content14 = await readFile8(filePath, "utf-8");
2789
- const raw = parseYaml6(content14);
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 readFile9 } from "fs/promises";
2800
- import { parse as parseYaml7 } from "yaml";
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 readFile9(filePath, "utf-8");
2804
- const raw = parseYaml7(content14);
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 isPlainObject(value) {
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 (!isPlainObject(obj.verdicts)) {
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 (!isPlainObject(unitMap)) {
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 (!isPlainObject(obj.nodes)) {
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 (!isPlainObject(entry)) throwMalformed(`"${at}" must be a JSON object (found ${describe(entry)})`);
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 (!isPlainObject(entry)) throwMalformed(`"${at}" must be a JSON object (found ${describe(entry)})`);
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 (!isPlainObject(entry.log)) {
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 readFile10(srcPath, "utf-8");
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 readFile10(gaPath, "utf-8");
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 readFile10(giPath, "utf-8");
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 readFile10(configPath, "utf-8");
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 readFile10(secretsPath, "utf-8");
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 readFile10(srcPath, "utf-8");
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
- const cliVersion = await getCliVersion();
3970
- if (currentVersion && currentVersion !== cliVersion) {
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 readFile11, stat as stat4 } from "fs/promises";
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 readFile11(absPath);
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 readFile13, readdir as readdir5, stat as stat5 } from "fs/promises";
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 readFile12, readdir as readdir4 } from "fs/promises";
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 readFile12(join(projectRoot, ".gitignore"), "utf-8");
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 readFile12(join(dir, ".gitignore"), "utf-8");
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 = join(dir, entry.name);
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 readFile13(filePath);
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 readFile13(path15.join(projectRoot, ".gitignore"), "utf-8");
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 readFile13(path15.join(directoryPath, ".gitignore"), "utf-8");
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
- config: input.tier.config,
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, llm) {
9494
- const { api_key: _a, timeout: _t, ...rest } = llm.config;
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, tier) {
9677
- const {
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, tierResult.tier),
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: typeof r.satisfied === "boolean" ? r.satisfied : false,
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 && typeof obj.satisfied === "boolean") last = 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
- think: false,
13456
- options: { temperature: this.temperature, num_predict: 500 },
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: 500,
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, tier),
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. This node's pairs are skipped this run; other nodes proceed.`,
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
- if (!secretsByProvider.has(baseTier.provider)) {
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: merged.provider, tier: tierName });
14980
+ infraReport.push({ provider: baseTier.provider, tier: tierName });
15014
14981
  emitIssue({
15015
- what: `Reviewer provider '${merged.provider}' (tier '${tierName}') is unreachable \u2014 ${group.length} pair(s) left unverified.`,
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
- return fillLlmPair(graph, projectRoot, item.pair, item.aspect, item.tier, item.tierName, merged, provider, referencesCache);
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: merged.provider, tier: tierName });
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 secrets = await loadSecrets(graph.rootPath, tier.provider);
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
- \`{name, provider, consensus, config}\`.
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
- - **\`timeout\`** and **api_key** in tier config \u2014 transport / secret knobs, not
18379
- judgment inputs.
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 live here \u2014 they belong in \`yg-secrets.yaml\` (api_key only).
18634
- API providers also read the provider API key from environment variables (e.g.
18635
- the provider's standard \`*_API_KEY\`) as a fallback when not present in
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
- API keys go in \`yg-secrets.yaml\`, NOT in \`yg-config.yaml\`.
18676
- \`yg-secrets.yaml\` is gitignored by default \u2014 see "Local state (.yggdrasil/.gitignore)" below.
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
- anthropic:
18682
- api_key: sk-ant-...
18653
+ tiers:
18654
+ standard:
18655
+ config:
18656
+ api_key: sk-ant-...
18683
18657
  \`\`\`
18684
18658
 
18685
- The secrets file accepts only \`api_key\` per provider. Any other field
18686
- triggers \`secrets-non-credential-field\` from \`yg check\` \u2014 non-credential
18687
- overrides belong in the tier's \`config:\` block instead.
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 environment variables (e.g.
18690
- the provider's standard \`*_API_KEY\`) as a fallback when not present in
18691
- \`yg-secrets.yaml\`. If the env var is set, \`yg-secrets.yaml\` is not required.
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
- # API keys live in yg-secrets.yaml (api_key only) never in this file.
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.0-alpha.6",
4
- "description": "Architecture rules your coding agent can't ignore. Written in Markdown, verified on every change, enforced in the agent's loopnot after on a PR. Works with Claude Code, Cursor, Copilot, Codex, Cline.",
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"