@chrisdudek/yg 5.0.0 → 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/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,7 +13406,11 @@ var OllamaProvider = class {
13452
13406
  model: this.model,
13453
13407
  messages: [{ role: "user", content: prompt }],
13454
13408
  stream: false,
13455
- think: false,
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,
13456
13414
  // num_predict: -1 → generate until the model stops; no cap, so the verdict
13457
13415
  // is never truncated (a cut-off JSON would otherwise fail to parse and waste
13458
13416
  // a re-verification).
@@ -13464,7 +13422,7 @@ var OllamaProvider = class {
13464
13422
  method: "POST",
13465
13423
  headers: { "Content-Type": "application/json" },
13466
13424
  body: JSON.stringify(body)
13467
- }, "ollama");
13425
+ }, "ollama", this.timeout);
13468
13426
  if (!res.ok) {
13469
13427
  debugWrite(`[ollama] http_error: ${res.status} ${res.statusText}`);
13470
13428
  return fallback;
@@ -14705,7 +14663,7 @@ async function fillLlmPair(graph, projectRoot, pair, aspect, tier, tierName, mer
14705
14663
  ruleHash: ruleHashFor(aspect, "content.md"),
14706
14664
  files: subjects.map((s) => [s.path, hashBytes(s.bytes)]),
14707
14665
  references: referencesForHash,
14708
- tier: tierHashViewFromTier(tierName, tier),
14666
+ tier: tierHashViewFromTier(tierName),
14709
14667
  verdict
14710
14668
  });
14711
14669
  const entry = { verdict, hash };
@@ -14772,7 +14730,7 @@ async function logGateBlocks(graph, projectRoot, node, lock, emitIssue) {
14772
14730
  if (!blocked) return false;
14773
14731
  emitIssue({
14774
14732
  what: `No fresh log entry for node '${toPosixPath(node.path)}' \u2014 mandatory before --approve when source changed.`,
14775
- 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.`,
14776
14734
  next: `yg log add --node ${toPosixPath(node.path)} --reason '<justification>', then re-run: yg check --approve`
14777
14735
  });
14778
14736
  return true;
@@ -14936,6 +14894,14 @@ async function runFill(graph, opts) {
14936
14894
  const blocked = await logGateBlocks(graph, projectRoot, node, lock, emitIssue);
14937
14895
  if (blocked) blockedNodes.add(nodePath);
14938
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
+ }
14939
14905
  let reviewerCallsMade = 0;
14940
14906
  let infraFailures = 0;
14941
14907
  let runtimeErrors = 0;
@@ -14975,7 +14941,6 @@ async function runFill(graph, opts) {
14975
14941
  next: `Fix the deterministic violations on '${toPosixPath(nodePath)}', then re-run: yg check --approve`
14976
14942
  });
14977
14943
  }
14978
- const secretsByProvider = /* @__PURE__ */ new Map();
14979
14944
  const referencesCache = /* @__PURE__ */ new Map();
14980
14945
  const byTier = /* @__PURE__ */ new Map();
14981
14946
  const infraReport = [];
@@ -15002,12 +14967,7 @@ async function runFill(graph, opts) {
15002
14967
  const parallel = Math.max(1, graph.config.parallel ?? 1);
15003
14968
  for (const [tierName, group] of byTier) {
15004
14969
  const baseTier = group[0].tier;
15005
- if (!secretsByProvider.has(baseTier.provider)) {
15006
- const secrets = await loadSecrets(graph.rootPath, baseTier.provider);
15007
- secretsByProvider.set(baseTier.provider, secrets ?? null);
15008
- }
15009
- const merged = applySecrets(baseTier, secretsByProvider.get(baseTier.provider));
15010
- const provider = createLlmProvider(merged);
14970
+ const provider = createLlmProvider(baseTier);
15011
14971
  let available;
15012
14972
  try {
15013
14973
  available = await provider.isAvailable();
@@ -15017,16 +14977,22 @@ async function runFill(graph, opts) {
15017
14977
  }
15018
14978
  if (!available) {
15019
14979
  infraFailures += group.length;
15020
- infraReport.push({ provider: merged.provider, tier: tierName });
14980
+ infraReport.push({ provider: baseTier.provider, tier: tierName });
15021
14981
  emitIssue({
15022
- 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.`,
15023
14983
  why: "The configured reviewer endpoint did not respond (availability check failed) \u2014 an infrastructure problem, not a code violation. No verdict was written.",
15024
14984
  next: `Check the provider endpoint, network, and credentials, then re-run: yg check --approve`
15025
14985
  });
15026
14986
  continue;
15027
14987
  }
15028
14988
  const outcomes = await runPairPool(group, parallel, async (item) => {
15029
- 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;
15030
14996
  });
15031
14997
  for (let i = 0; i < group.length; i++) {
15032
14998
  const item = group[i];
@@ -15034,17 +15000,13 @@ async function runFill(graph, opts) {
15034
15000
  reviewerCallsMade += outcome.callsMade;
15035
15001
  if (outcome.kind === "infra") {
15036
15002
  infraFailures += 1;
15037
- infraReport.push({ provider: merged.provider, tier: tierName });
15003
+ infraReport.push({ provider: baseTier.provider, tier: tierName });
15038
15004
  emitIssue({
15039
15005
  what: `Reviewer could not verify aspect '${item.pair.aspectId}' on ${toPosixPath(item.pair.unitKey)} \u2014 left unverified.`,
15040
15006
  why: outcome.why,
15041
15007
  next: `Resolve the provider/config problem, then re-run: yg check --approve`
15042
15008
  });
15043
- continue;
15044
15009
  }
15045
- await setEntry(item.pair.aspectId, item.pair.unitKey, outcome.entry);
15046
- write(` [llm] ${item.pair.aspectId} on ${toPosixPath(item.pair.unitKey)} \u2014 ${outcome.entry.verdict}
15047
- `);
15048
15010
  }
15049
15011
  }
15050
15012
  await applyPositiveClosure(graph, projectRoot, lock, blockedNodes, persistLock);
@@ -15073,9 +15035,6 @@ async function runFill(graph, opts) {
15073
15035
  const checkResult = await runCheck(graph, opts.gitTrackedFiles);
15074
15036
  return { checkResult, reviewerCallsMade, infraFailures, runtimeErrors };
15075
15037
  }
15076
- function applySecrets(tier, secrets) {
15077
- return secrets ? mergeLlmConfig(tier, secrets) : tier;
15078
- }
15079
15038
 
15080
15039
  // src/cli/check.ts
15081
15040
  import { execFileSync } from "child_process";
@@ -15643,8 +15602,7 @@ async function runLlmAspectTest(graph, projectRoot, aspect, nodePath, dryRun) {
15643
15602
  const nodeDescription = nodeDescriptionFor(graph, nodePath);
15644
15603
  const aspectContent = contentFor(aspect, "content.md");
15645
15604
  if (!dryRun) {
15646
- const secrets = await loadSecrets(graph.rootPath, tier.provider);
15647
- const mergedTier = secrets ? mergeLlmConfig(tier, secrets) : tier;
15605
+ const mergedTier = tier;
15648
15606
  const provider = createLlmProvider(mergedTier);
15649
15607
  let available;
15650
15608
  try {
@@ -18351,8 +18309,10 @@ verdict: "approved" | "refused" // the discrete token \u2014 tamper
18351
18309
  \`\`\`
18352
18310
 
18353
18311
  LLM pairs additionally fold their prompt inputs: the aspect description, each
18354
- reference \`[path, sha256(bytes), description]\`, and the resolved tier
18355
- \`{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.
18356
18316
 
18357
18317
  Deterministic pairs additionally fold the **observation set** \u2014 everything the
18358
18318
  check observed through \`ctx\` beyond its subject files, recorded by the runner:
@@ -18382,8 +18342,10 @@ costs at worst one free re-run; a missed one yields a stale-green verdict.
18382
18342
  - **\`reason\`** / free-text output \u2014 only the discrete verdict token is folded.
18383
18343
  - **Node description** \u2014 prompt garnish, not hashed (the aspect description IS
18384
18344
  hashed for LLM pairs).
18385
- - **\`timeout\`** and **api_key** in tier config \u2014 transport / secret knobs, not
18386
- 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.
18387
18349
  - **\`max_prompt_chars\`** \u2014 a gate, not an input; lowering it can trip the gate on
18388
18350
  an already-verified pair without invalidating the verdict.
18389
18351
  - **\`when\` / \`implies\` / port declarations** \u2014 applicability is recomputed live
@@ -18637,10 +18599,10 @@ Provider-specific options passed to the LLM client:
18637
18599
  | \`endpoint\` | string | Required for \`openai-compatible\` (no default host \u2014 else falls back to api.openai.com); \`ollama\` defaults to http://localhost:11434. |
18638
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). |
18639
18601
 
18640
- API keys do NOT live here \u2014 they belong in \`yg-secrets.yaml\` (api_key only).
18641
- API providers also read the provider API key from environment variables (e.g.
18642
- the provider's standard \`*_API_KEY\`) as a fallback when not present in
18643
- \`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\`.
18644
18606
 
18645
18607
  ## Multi-tier example
18646
18608
 
@@ -18677,25 +18639,30 @@ reviewer:
18677
18639
  type: llm
18678
18640
  \`\`\`
18679
18641
 
18680
- ## Secrets (yg-secrets.yaml)
18642
+ ## Secrets and local overrides (yg-secrets.yaml)
18681
18643
 
18682
- API keys go in \`yg-secrets.yaml\`, NOT in \`yg-config.yaml\`.
18683
- \`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.
18684
18649
 
18685
18650
  \`\`\`yaml
18686
18651
  # .yggdrasil/yg-secrets.yaml \u2014 gitignored, never commit
18687
18652
  reviewer:
18688
- anthropic:
18689
- api_key: sk-ant-...
18653
+ tiers:
18654
+ standard:
18655
+ config:
18656
+ api_key: sk-ant-...
18690
18657
  \`\`\`
18691
18658
 
18692
- The secrets file accepts only \`api_key\` per provider. Any other field
18693
- triggers \`secrets-non-credential-field\` from \`yg check\` \u2014 non-credential
18694
- 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.
18695
18662
 
18696
- API providers also read the provider API key from environment variables (e.g.
18697
- the provider's standard \`*_API_KEY\`) as a fallback when not present in
18698
- \`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\`.
18699
18666
 
18700
18667
  \`yg-config.yaml\` itself must never contain credentials. Commit it to the
18701
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrisdudek/yg",
3
- "version": "5.0.0",
3
+ "version": "5.0.1",
4
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": {