@dzhechkov/harness-core 0.3.142 → 0.3.143

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/src/backlog.ts CHANGED
@@ -76,11 +76,50 @@ export interface DedupVerdict {
76
76
  /** Top-1 raw cosine (ADR-002 — never an RRF score). `-1` when there is nothing to compare against. */
77
77
  readonly cosine: number;
78
78
  readonly matchedId: string | undefined;
79
+ /**
80
+ * The id of the TOP-1 candidate whatever band it landed in — the ADR-002 CALIBRATION surface (idea
81
+ * ce914ac2). `matchedId` is only set for a DUPLICATE, so a RELATED verdict used to report a cosine with
82
+ * no way to see WHICH idea produced it; a user calibrating the 0.92 band needs the pair, not the number.
83
+ * Purely observational: it never changes the verdict.
84
+ */
85
+ readonly topMatchId: string | undefined;
79
86
  readonly relatedIds: readonly string[];
80
87
  /** True when the embedder was unavailable and dedup degraded to exact-text (ADR-002 §degrade). */
81
88
  readonly exactTextOnly: boolean;
82
89
  }
83
90
 
91
+ /* ── Effort parsing (idea 86096d6d): a clamp the user cannot see is a silent surprise. ── */
92
+
93
+ /** The result of interpreting a `--effort` argument, with the note the CLI must ECHO when it altered it. */
94
+ export interface EffortParse {
95
+ readonly effort: number;
96
+ /** True when the parsed value was altered (clamped, floored, or rejected) — the CLI prints `note`. */
97
+ readonly adjusted: boolean;
98
+ /** Human line, e.g. `effort 13 → clamped to 5 (scale 1-5)`. Present iff `adjusted`. */
99
+ readonly note?: string;
100
+ }
101
+
102
+ export const EFFORT_MIN = 1;
103
+ export const EFFORT_MAX = 5;
104
+
105
+ /**
106
+ * PURE `--effort` interpreter. `dz backlog add --effort 13` used to store 5 and say NOTHING, so the user
107
+ * kept a wrong mental model of the scale (idea 86096d6d). Every alteration now carries a printable note;
108
+ * an in-range integer is returned untouched with `adjusted:false` (no noise on the happy path).
109
+ */
110
+ export function parseEffort(raw: string | undefined, fallback: number): EffortParse {
111
+ if (raw === undefined) return { effort: fallback, adjusted: false };
112
+ const v = Number(raw);
113
+ if (!Number.isFinite(v)) {
114
+ return { effort: fallback, adjusted: true, note: `effort ${JSON.stringify(raw)} → not a number, using ${fallback} (scale ${EFFORT_MIN}-${EFFORT_MAX})` };
115
+ }
116
+ if (v < EFFORT_MIN) return { effort: EFFORT_MIN, adjusted: true, note: `effort ${raw} → clamped to ${EFFORT_MIN} (scale ${EFFORT_MIN}-${EFFORT_MAX})` };
117
+ if (v > EFFORT_MAX) return { effort: EFFORT_MAX, adjusted: true, note: `effort ${raw} → clamped to ${EFFORT_MAX} (scale ${EFFORT_MIN}-${EFFORT_MAX})` };
118
+ const floored = Math.floor(v);
119
+ if (floored !== v) return { effort: floored, adjusted: true, note: `effort ${raw} → rounded down to ${floored} (scale ${EFFORT_MIN}-${EFFORT_MAX}, whole numbers)` };
120
+ return { effort: floored, adjusted: false };
121
+ }
122
+
84
123
  /* ================================================================== */
85
124
  /* CONFIG (readBacklogConfig) — defensive, Number.isFinite clamps. */
86
125
  /* ================================================================== */
@@ -257,6 +296,117 @@ export function writeIdeas(projectRoot: string, ideas: readonly IdeaRecord[]): v
257
296
  renameSync(tmp, path);
258
297
  }
259
298
 
299
+ /* ── Store privacy (idea ec4cd60d): raw ideas are prompt-class PRIVATE content, like recall-usage.jsonl. ── */
300
+
301
+ export type GitignoreAction = 'created' | 'appended' | 'already-covered' | 'user-opted-out' | 'skipped';
302
+ export interface GitignoreScaffold {
303
+ readonly action: GitignoreAction;
304
+ readonly path: string;
305
+ /** Set for `skipped` (the I/O reason) and `user-opted-out` (the negation line we obeyed). */
306
+ readonly reason?: string;
307
+ }
308
+
309
+ /** The entry + its one-line rationale, written verbatim so the file explains itself. */
310
+ const BACKLOG_IGNORE_ENTRY = '.dz/backlog/';
311
+ const BACKLOG_IGNORE_COMMENT = '# dz backlog — captured ideas are private prompt-class content';
312
+
313
+ /**
314
+ * Normalise ONE .gitignore pattern to the bare path it targets, so the equally-valid spellings of the
315
+ * same rule compare equal: a leading `/` (repo-root anchor), a trailing `/**` or `/*` (recursive glob),
316
+ * and a trailing `/` (directory marker) are all decoration around the same path. Returns `undefined`
317
+ * for anything that is not a plain path pattern (a comment, an empty line, or a pattern carrying a
318
+ * wildcard we do NOT interpret) — an uninterpretable pattern must never be read as coverage.
319
+ */
320
+ function normaliseIgnorePattern(body: string): string | undefined {
321
+ let p = body.trim();
322
+ if (p === '' || p.startsWith('#')) return undefined;
323
+ if (p.startsWith('/')) p = p.slice(1); // repo-root anchor: `/.dz/` ≡ `.dz/`
324
+ p = p.replace(/\/\*\*$/, '').replace(/\/\*$/, ''); // `.dz/**` / `.dz/*` ≡ `.dz`
325
+ p = p.replace(/\/+$/, ''); // trailing directory marker
326
+ if (p === '' || p.includes('*') || p.includes('?') || p.includes('[')) return undefined; // not a plain path
327
+ return p;
328
+ }
329
+
330
+ /** True when the pattern targets the backlog store (directly or via its `.dz` parent). */
331
+ function targetsBacklogStore(pattern: string): boolean {
332
+ return pattern === '.dz' || pattern === '.dz/backlog';
333
+ }
334
+
335
+ export type BacklogIgnoreStatus = 'covered' | 'negated' | 'uncovered';
336
+
337
+ /**
338
+ * PURE .gitignore verdict for the backlog store. Deliberately NOT a full gitignore engine — it
339
+ * recognises the plain-path spellings of the rule (`/.dz/`, `.dz/**`, `.dz/backlog`, …) and refuses to
340
+ * interpret anything else, because a wrong-but-clever matcher either double-appends or silently
341
+ * decides a store is private when it is not.
342
+ *
343
+ * `negated` wins over `covered`: a `!.dz/backlog/` line (anchored or not) is the user saying *"track
344
+ * this on purpose"*. We obey it — appending a rule that overrides the user's explicit opt-out would be
345
+ * this tool deciding it knows better.
346
+ */
347
+ export function backlogIgnoreStatus(gitignoreText: string): BacklogIgnoreStatus {
348
+ let covered = false;
349
+ for (const raw of String(gitignoreText).split('\n')) {
350
+ const line = raw.trim();
351
+ if (line === '' || line.startsWith('#')) continue;
352
+ const negated = line.startsWith('!');
353
+ const pattern = normaliseIgnorePattern(negated ? line.slice(1) : line);
354
+ if (pattern === undefined || !targetsBacklogStore(pattern)) continue;
355
+ if (negated) return 'negated'; // explicit user intent — decided, no further scanning
356
+ covered = true;
357
+ }
358
+ return covered ? 'covered' : 'uncovered';
359
+ }
360
+
361
+ /** Back-compat shim: "is it ignored?" — a negation is NOT coverage (the store is tracked on purpose). */
362
+ export function backlogIgnoreCovered(gitignoreText: string): boolean {
363
+ return backlogIgnoreStatus(gitignoreText) === 'covered';
364
+ }
365
+
366
+ /** The file's dominant line ending, so an append does not mix CRLF and LF in one file. */
367
+ function dominantEol(text: string): '\r\n' | '\n' {
368
+ const crlf = (text.match(/\r\n/g) ?? []).length;
369
+ const lf = (text.match(/\n/g) ?? []).length - crlf;
370
+ return crlf > lf ? '\r\n' : '\n';
371
+ }
372
+
373
+ /** Atomic write (tmp + rename in the SAME dir) — the ideas.jsonl discipline: a crash never truncates. */
374
+ function writeFileAtomic(path: string, body: string): void {
375
+ const tmp = `${path}.tmp-${process.pid}`;
376
+ writeFileSync(tmp, body);
377
+ renameSync(tmp, path);
378
+ }
379
+
380
+ /**
381
+ * Ensure the backlog store is gitignored, at the moment the feature FIRST creates it (idea ec4cd60d).
382
+ * Creates a `.gitignore` when there is none; appends the entry (+ its comment) when the project has one
383
+ * that does not cover the store; touches NOTHING when it is already covered OR when the user explicitly
384
+ * negated the rule. The write is ATOMIC and preserves the file's dominant EOL.
385
+ * Never throws — an unwritable .gitignore degrades to `skipped`, and the CALLER must say so out loud
386
+ * (a silently un-ignored store is exactly the privacy leak this function exists to prevent).
387
+ */
388
+ export function ensureBacklogGitignored(projectRoot: string): GitignoreScaffold {
389
+ const path = join(projectRoot, '.gitignore');
390
+ try {
391
+ if (!existsSync(path)) {
392
+ writeFileAtomic(path, `${BACKLOG_IGNORE_COMMENT}\n${BACKLOG_IGNORE_ENTRY}\n`);
393
+ return { action: 'created', path };
394
+ }
395
+ const text = readFileSync(path, 'utf-8');
396
+ const status = backlogIgnoreStatus(text);
397
+ if (status === 'covered') return { action: 'already-covered', path };
398
+ if (status === 'negated') {
399
+ return { action: 'user-opted-out', path, reason: 'a "!" negation for the backlog store is present — respecting the explicit opt-out and adding nothing' };
400
+ }
401
+ const eol = dominantEol(text);
402
+ const sep = text === '' || text.endsWith('\n') ? '' : eol;
403
+ writeFileAtomic(path, `${text}${sep}${eol}${BACKLOG_IGNORE_COMMENT}${eol}${BACKLOG_IGNORE_ENTRY}${eol}`);
404
+ return { action: 'appended', path };
405
+ } catch (err) {
406
+ return { action: 'skipped', path, reason: err instanceof Error ? err.message : String(err) };
407
+ }
408
+ }
409
+
260
410
  /** Pre-mutation snapshot (NFR-6) — mirrors `snapshotStore`. A failed snapshot returns `{error}` so
261
411
  * the caller ABORTS the mutation (no partial merge). */
262
412
  export interface SnapshotResult {
@@ -303,15 +453,15 @@ export function classifyDedup(
303
453
  const sorted = candidates.filter((c) => Number.isFinite(c.cosine)).sort((a, b) => b.cosine - a.cosine);
304
454
  const top = sorted[0];
305
455
  const exactTextOnly = opts.exactTextOnly === true;
306
- if (top === undefined) return { action: 'new', cosine: -1, matchedId: undefined, relatedIds: [], exactTextOnly };
456
+ if (top === undefined) return { action: 'new', cosine: -1, matchedId: undefined, topMatchId: undefined, relatedIds: [], exactTextOnly };
307
457
  if (top.cosine >= cfg.duplicateThreshold) {
308
- return { action: 'duplicate', cosine: top.cosine, matchedId: top.id, relatedIds: [], exactTextOnly };
458
+ return { action: 'duplicate', cosine: top.cosine, matchedId: top.id, topMatchId: top.id, relatedIds: [], exactTextOnly };
309
459
  }
310
460
  const related = sorted.filter((c) => c.cosine >= cfg.relatednessFloor && c.cosine < cfg.duplicateThreshold);
311
461
  if (related.length > 0) {
312
- return { action: 'related', cosine: top.cosine, matchedId: undefined, relatedIds: related.map((c) => c.id), exactTextOnly };
462
+ return { action: 'related', cosine: top.cosine, matchedId: undefined, topMatchId: top.id, relatedIds: related.map((c) => c.id), exactTextOnly };
313
463
  }
314
- return { action: 'new', cosine: top.cosine, matchedId: undefined, relatedIds: [], exactTextOnly };
464
+ return { action: 'new', cosine: top.cosine, matchedId: undefined, topMatchId: top.id, relatedIds: [], exactTextOnly };
315
465
  }
316
466
 
317
467
  /** Injectable deps so the production dedup path is testable without a live agentdb. */
@@ -354,8 +504,8 @@ export async function dedupIdea(projectRoot: string, text: string, cfg: BacklogC
354
504
  // EXACT-text safety net: identical text among existing ideas is still a DUPLICATE (content-addressed
355
505
  // idempotency, ADR-002 §degrade). Otherwise NEW — the RELATED band needs cosine and is skipped.
356
506
  const match = ideas.find((i) => i.text === text);
357
- if (match !== undefined) return { action: 'duplicate', cosine: 1, matchedId: match.id, relatedIds: [], exactTextOnly: true };
358
- return { action: 'new', cosine: -1, matchedId: undefined, relatedIds: [], exactTextOnly: result.error !== undefined };
507
+ if (match !== undefined) return { action: 'duplicate', cosine: 1, matchedId: match.id, topMatchId: match.id, relatedIds: [], exactTextOnly: true };
508
+ return { action: 'new', cosine: -1, matchedId: undefined, topMatchId: undefined, relatedIds: [], exactTextOnly: result.error !== undefined };
359
509
  }
360
510
  return classifyDedup(candidates, cfg.dedup);
361
511
  }
@@ -364,30 +514,135 @@ export async function dedupIdea(projectRoot: string, text: string, cfg: BacklogC
364
514
  /* ALIGNMENT (AM-3 / ADR-003) — weighted-MAX cosine over the GoalMap. */
365
515
  /* ================================================================== */
366
516
 
367
- /** Defensive GoalMap reader never throws; a missing/corrupt file empty compass. */
368
- export function readGoalMap(projectRoot: string): GoalMap {
517
+ /** One goal entry the reader REFUSED, with the reason the anti-vacuous-valid evidence (idea 960c9f26). */
518
+ export interface DroppedGoal {
519
+ /** 0-based index of the entry in the file's `goals` array. */
520
+ readonly index: number;
521
+ /** Machine-stable reason, e.g. `missing "statement"`. */
522
+ readonly reason: string;
523
+ }
524
+
525
+ /**
526
+ * What the defensive read actually SAW. `readGoalMap` throws away the drops (correct for runtime paths —
527
+ * a corrupt compass must never break capture), but `goals --validate` needs them: a goals.json whose every
528
+ * entry was silently dropped previously reported *"valid (0 goal(s))"* — a vacuous pass that hid the user's
529
+ * typo (`text` instead of `statement`). This variant is the validate-facing reader.
530
+ */
531
+ export interface GoalMapRead {
532
+ readonly goalMap: GoalMap;
533
+ /** Entries present in the file's `goals` array (kept + dropped). */
534
+ readonly present: number;
535
+ readonly dropped: readonly DroppedGoal[];
536
+ /**
537
+ * Fields the reader REPAIRED to keep the runtime safe. The clamp happens BEFORE any validation could
538
+ * see the original, so a `weight: 7` becomes a legal 1 and the validator's out-of-range branch is
539
+ * unreachable — a silent repair reported as "valid". These carry the RAW value so `goals --validate`
540
+ * can warn about what the user actually wrote while the runtime keeps the clamped value.
541
+ */
542
+ readonly repaired: readonly RepairedGoalField[];
543
+ /** Set when the file exists but could not be parsed / has no `goals` array. */
544
+ readonly parseError?: string;
545
+ }
546
+
547
+ /** One field the defensive reader clamped/replaced, with both the raw and the used value. */
548
+ export interface RepairedGoalField {
549
+ readonly index: number;
550
+ readonly id: string;
551
+ readonly field: 'weight';
552
+ readonly raw: unknown;
553
+ readonly used: number;
554
+ /** Human reason, e.g. `weight 7 is out of (0,1]`. */
555
+ readonly reason: string;
556
+ }
557
+
558
+ /** Reason an entry cannot become a Goal — `undefined` when it is well-formed. */
559
+ function goalDropReason(o: unknown): string | undefined {
560
+ if (o === null || typeof o !== 'object' || Array.isArray(o)) return 'not an object';
561
+ const r = o as Record<string, unknown>;
562
+ if (typeof r.id !== 'string') return 'missing "id"';
563
+ if (typeof r.statement !== 'string') return 'missing "statement"';
564
+ return undefined;
565
+ }
566
+
567
+ /**
568
+ * Detailed GoalMap read — never throws, and REPORTS what it dropped (idea 960c9f26). `readGoalMap` is the
569
+ * lossy runtime view of this; both share one parser so they can never disagree about what a goal is.
570
+ */
571
+ export function readGoalMapDetailed(projectRoot: string): GoalMapRead {
369
572
  const path = goalsPath(projectRoot);
370
- if (!existsSync(path)) return { version: 1, goals: [] };
573
+ if (!existsSync(path)) return { goalMap: { version: 1, goals: [] }, present: 0, dropped: [], repaired: [] };
574
+ let parsed: { version?: unknown; goals?: unknown };
371
575
  try {
372
- const parsed = JSON.parse(readFileSync(path, 'utf-8')) as { version?: unknown; goals?: unknown };
373
- const goals: Goal[] = Array.isArray(parsed.goals)
374
- ? parsed.goals
375
- .map((g): Goal | undefined => {
376
- const o = g as Record<string, unknown>;
377
- if (typeof o.id !== 'string' || typeof o.statement !== 'string') return undefined;
378
- return {
379
- id: o.id,
380
- statement: o.statement,
381
- weight: clampNum(o.weight, Number.MIN_VALUE, 1, 1),
382
- keywords: Array.isArray(o.keywords) ? o.keywords.filter((k): k is string => typeof k === 'string') : [],
383
- };
384
- })
385
- .filter((g): g is Goal => g !== undefined)
386
- : [];
387
- return { version: typeof parsed.version === 'number' ? parsed.version : 1, goals };
388
- } catch {
389
- return { version: 1, goals: [] };
576
+ parsed = JSON.parse(readFileSync(path, 'utf-8')) as { version?: unknown; goals?: unknown };
577
+ } catch (err) {
578
+ return {
579
+ goalMap: { version: 1, goals: [] },
580
+ present: 0,
581
+ dropped: [],
582
+ repaired: [],
583
+ parseError: `goals.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
584
+ };
585
+ }
586
+ // Valid JSON that is not an object (null, a number, an array) must not crash the never-throw
587
+ // reader: `parsed.version` on null is a TypeError (Codex re-QE HIGH).
588
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
589
+ return {
590
+ goalMap: { version: 1, goals: [] },
591
+ present: 0,
592
+ dropped: [],
593
+ repaired: [],
594
+ parseError: 'goals.json is valid JSON but not an object',
595
+ };
390
596
  }
597
+ const version = typeof parsed.version === 'number' ? parsed.version : 1;
598
+ if (!Array.isArray(parsed.goals)) {
599
+ return {
600
+ goalMap: { version, goals: [] },
601
+ present: 0,
602
+ dropped: [],
603
+ repaired: [],
604
+ parseError: 'goals.json has no `goals` array',
605
+ };
606
+ }
607
+ const goals: Goal[] = [];
608
+ const dropped: DroppedGoal[] = [];
609
+ const repaired: RepairedGoalField[] = [];
610
+ parsed.goals.forEach((g, index) => {
611
+ const reason = goalDropReason(g);
612
+ if (reason !== undefined) {
613
+ dropped.push({ index, reason });
614
+ return;
615
+ }
616
+ const o = g as Record<string, unknown>;
617
+ const id = o.id as string;
618
+ // The clamp keeps the RUNTIME safe; the raw value is recorded so validation can still see what the
619
+ // user wrote (MED-7: clamping before validating made the validator's out-of-range branch dead code).
620
+ const weight = clampNum(o.weight, Number.MIN_VALUE, 1, 1);
621
+ if (o.weight !== undefined && o.weight !== weight) {
622
+ repaired.push({
623
+ index,
624
+ id,
625
+ field: 'weight',
626
+ raw: o.weight,
627
+ used: weight,
628
+ reason: typeof o.weight === 'number' && Number.isFinite(o.weight)
629
+ ? `weight ${o.weight} is out of (0,1]`
630
+ : `weight ${JSON.stringify(o.weight) ?? String(o.weight)} is not a number in (0,1]`,
631
+ });
632
+ }
633
+ goals.push({
634
+ id,
635
+ statement: o.statement as string,
636
+ weight,
637
+ keywords: Array.isArray(o.keywords) ? o.keywords.filter((k): k is string => typeof k === 'string') : [],
638
+ });
639
+ });
640
+ return { goalMap: { version, goals }, present: parsed.goals.length, dropped, repaired };
641
+ }
642
+
643
+ /** Defensive GoalMap reader — never throws; a missing/corrupt file ⇒ empty compass (the runtime path). */
644
+ export function readGoalMap(projectRoot: string): GoalMap {
645
+ return readGoalMapDetailed(projectRoot).goalMap;
391
646
  }
392
647
 
393
648
  /** The text embedded for a goal: statement + keywords (same convention across cache + score). */
package/src/guard.ts CHANGED
@@ -56,6 +56,110 @@ export interface GuardFacts {
56
56
  readonly skillPacks?: readonly { readonly name: string; readonly nonRegistrable: readonly string[] }[];
57
57
  /** for readme-first: per publishable package, is a version bump staged without a README change? */
58
58
  readonly readmeFirst?: readonly { readonly name: string; readonly versionBumped: boolean; readonly readmeChanged: boolean }[];
59
+ /**
60
+ * for lockfile-in-sync: what each workspace package DECLARES vs what pnpm-lock.yaml RECORDS for that
61
+ * importer. `parsed:false` (or the fact absent) ⇒ the rule reports nothing — fail-open by construction,
62
+ * because a lockfile we could not read is not evidence of a defect.
63
+ */
64
+ readonly lockfile?: {
65
+ readonly parsed: boolean;
66
+ readonly importers?: readonly {
67
+ /** importer path as pnpm keys it, e.g. `packages/@dzhechkov/harness-cli`. */
68
+ readonly importer: string;
69
+ /** the `@dzhechkov/*` specs the package.json declares (deps + devDeps). */
70
+ readonly declared: Readonly<Record<string, string>>;
71
+ /** the specs pnpm-lock.yaml records for this importer; `undefined` ⇒ the importer is absent. */
72
+ readonly locked?: Readonly<Record<string, string>> | undefined;
73
+ }[];
74
+ };
75
+ }
76
+
77
+ /** The lowest `lockfileVersion` whose importers carry the `specifier:`/`version:` pair this parser reads. */
78
+ export const MIN_RECOGNISED_LOCKFILE_VERSION = 9;
79
+
80
+ /**
81
+ * RECOGNISE-OR-REFUSE `pnpm-lock.yaml` importers parser — PURE, no YAML dependency. Reads exactly one
82
+ * shape: `importers:` → `<path>:` → `<section>:` → `'<dep>':` → `specifier: <value>` (lockfileVersion 9+).
83
+ *
84
+ * "Tolerant" must mean *refuses to guess*, NOT *guesses quietly*. A half-parse is the dangerous outcome:
85
+ * a lockfileVersion-6 file lists deps as `dep: version` one-liners under a separate `specifiers:` map, so
86
+ * a lenient reader finds the importer KEYS, records ZERO specifiers, and the rule then reports every real
87
+ * dependency as *"not recorded in pnpm-lock.yaml"* — a false-positive storm dressed up as fail-open. So we
88
+ * return `undefined` (⇒ the rule reports NOTHING) unless every one of these holds:
89
+ * 1. `lockfileVersion` is present and ≥ {@link MIN_RECOGNISED_LOCKFILE_VERSION};
90
+ * 2. an `importers:` section exists and yields at least one importer;
91
+ * 3. no legacy inline `dep: value` line appears at dependency depth (the v5/v6 shape);
92
+ * 4. at least one `specifier:` was read, and NO importer came out empty (a truncated file, or a shape
93
+ * we do not understand, always trips this).
94
+ *
95
+ * `dependencies` and `devDependencies` are merged: a dep appears in only one of them per importer, and
96
+ * the rule compares specifier strings only.
97
+ */
98
+ export function parsePnpmLockImporters(lockText: unknown): Record<string, Record<string, string>> | undefined {
99
+ if (typeof lockText !== 'string' || lockText === '') return undefined;
100
+
101
+ // (1) version gate — the ONLY layout this parser claims to understand.
102
+ const versionLine = lockText.match(/^lockfileVersion:\s*['"]?([0-9]+(?:\.[0-9]+)?)['"]?\s*$/m);
103
+ const version = versionLine?.[1] !== undefined ? Number.parseFloat(versionLine[1]) : Number.NaN;
104
+ if (!Number.isFinite(version) || version < MIN_RECOGNISED_LOCKFILE_VERSION) return undefined;
105
+
106
+ const importers: Record<string, Record<string, string>> = {};
107
+ let inImporters = false;
108
+ let current: string | undefined;
109
+ let currentDep: string | undefined;
110
+ let specifiersSeen = 0;
111
+ let sawImportersKey = false;
112
+ for (const line of lockText.split('\n')) {
113
+ if (/^importers:\s*$/.test(line)) {
114
+ inImporters = true;
115
+ sawImportersKey = true;
116
+ continue;
117
+ }
118
+ if (!inImporters) continue;
119
+ if (/^\S/.test(line)) break; // a new top-level key ends the importers section
120
+ if (line.trim() === '') continue;
121
+ const importer = line.match(/^ {2}(\S.*?):\s*$/);
122
+ if (importer && importer[1] !== undefined) {
123
+ current = unquoteYaml(importer[1]);
124
+ importers[current] = importers[current] ?? {};
125
+ currentDep = undefined;
126
+ continue;
127
+ }
128
+ if (current === undefined) continue;
129
+ // (3) a dependency-depth line that carries an INLINE value is the pre-v9 shape → refuse outright
130
+ // rather than silently recording nothing for this importer.
131
+ if (/^ {6}\S.*?:\s+\S/.test(line)) return undefined;
132
+ const dep = line.match(/^ {6}(\S.*?):\s*$/);
133
+ if (dep && dep[1] !== undefined) {
134
+ // A dep line while the PREVIOUS dep never got its specifier = a truncated/unrecognized shape —
135
+ // refuse the whole parse rather than warn on a half-read (Codex re-QE: pending currentDep).
136
+ if (currentDep !== undefined) return undefined;
137
+ currentDep = unquoteYaml(dep[1]);
138
+ continue;
139
+ }
140
+ const spec = line.match(/^ {8}specifier:\s*(.+?)\s*$/);
141
+ if (spec && spec[1] !== undefined && currentDep !== undefined) {
142
+ importers[current]![currentDep] = unquoteYaml(spec[1]);
143
+ specifiersSeen += 1;
144
+ currentDep = undefined;
145
+ }
146
+ }
147
+ // EOF with a dep still awaiting its specifier: truncated — refuse, never warn on a half-parse.
148
+ if (currentDep !== undefined) return undefined;
149
+
150
+ // (2) + (4) structural confidence: no importers, no specifiers, or ANY importer that came out empty
151
+ // (truncation, an unread section shape) means we did not really parse this file — report nothing.
152
+ if (!sawImportersKey || Object.keys(importers).length === 0 || specifiersSeen === 0) return undefined;
153
+ for (const deps of Object.values(importers)) if (Object.keys(deps).length === 0) return undefined;
154
+ return importers;
155
+ }
156
+
157
+ function unquoteYaml(s: string): string {
158
+ const t = s.trim();
159
+ if ((t.startsWith("'") && t.endsWith("'") && t.length >= 2) || (t.startsWith('"') && t.endsWith('"') && t.length >= 2)) {
160
+ return t.slice(1, -1);
161
+ }
162
+ return t;
59
163
  }
60
164
 
61
165
  /** The built-in rule set (works with no config). Ops are the mutating operations each rule guards. */
@@ -66,6 +170,7 @@ export const DEFAULT_RULES: readonly GuardRule[] = [
66
170
  { id: 'readme-consistency', severity: 'soft', ops: ['publish'], description: 'README counts agree (CJM header vs All Commands, etc.)' },
67
171
  { id: 'skills-registrable', severity: 'soft', ops: ['publish'], description: 'every skill directory in a skill pack has a depth-1 SKILL.md (a buried or missing one ships un-registrable — the health-advisor 1.2.0 class)' },
68
172
  { id: 'readme-first', severity: 'soft', ops: ['publish'], description: 'a package with a staged version bump must update its own README.md in the same change (README-first)' },
173
+ { id: 'lockfile-in-sync', severity: 'soft', ops: ['publish'], description: 'every workspace @dzhechkov/* dependency spec matches the specifier pnpm-lock.yaml records for that importer (a dep bump without a lockfile refresh breaks CI with ERR_PNPM_OUTDATED_LOCKFILE). SOFT-ONLY — a config cannot promote it to HARD' },
69
174
  { id: 'store-bloat-cap', severity: 'soft', ops: ['teach', 'consolidate'], description: 'the learned store is within its size cap' },
70
175
  ];
71
176
 
@@ -154,6 +259,40 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
154
259
  }
155
260
  return out;
156
261
  },
262
+ 'lockfile-in-sync': (f, _sev) => {
263
+ // The 2026-07-28 CI break, mechanized: an overnight dep bump edited package.json and left
264
+ // pnpm-lock.yaml stale, so `pnpm install --frozen-lockfile` died with ERR_PNPM_OUTDATED_LOCKFILE.
265
+ // SOFT + FAIL-OPEN: no lockfile evidence ⇒ NO violation. A guard that cannot read the lockfile must
266
+ // never invent one — a false block on publish costs more than the miss it prevents.
267
+ // The injected severity is IGNORED on purpose (MED-6): this rule emits `soft` unconditionally, so
268
+ // neither a config promotion nor a hand-built rules array can turn a tolerant parser into a blocker.
269
+ const sev: GuardSeverity = 'soft';
270
+ const lock = f.lockfile;
271
+ if (!lock || typeof lock !== 'object' || lock.parsed !== true || !Array.isArray(lock.importers)) return [];
272
+ const out: Violation[] = [];
273
+ for (const imp of lock.importers) {
274
+ if (!imp || typeof imp.importer !== 'string' || !imp.declared || typeof imp.declared !== 'object') continue;
275
+ const declared = Object.entries(imp.declared).filter(([dep, spec]) => dep.startsWith('@dzhechkov/') && typeof spec === 'string');
276
+ if (declared.length === 0) continue;
277
+ if (imp.locked === undefined || imp.locked === null) {
278
+ out.push({
279
+ rule: 'lockfile-in-sync',
280
+ severity: sev,
281
+ detail: `${imp.importer}: declares ${declared.length} @dzhechkov/* dep(s) but has no importer entry in pnpm-lock.yaml — run \`pnpm install\` (CI installs with --frozen-lockfile)`,
282
+ });
283
+ continue;
284
+ }
285
+ for (const [dep, spec] of declared) {
286
+ const locked = imp.locked[dep];
287
+ if (locked === undefined) {
288
+ out.push({ rule: 'lockfile-in-sync', severity: sev, detail: `${imp.importer}: ${dep} = "${spec}" is not recorded in pnpm-lock.yaml — run \`pnpm install\` (CI installs with --frozen-lockfile)` });
289
+ } else if (locked !== spec) {
290
+ out.push({ rule: 'lockfile-in-sync', severity: sev, detail: `${imp.importer}: ${dep} = "${spec}" in package.json but "${locked}" in pnpm-lock.yaml — run \`pnpm install\` to refresh the lockfile (CI installs with --frozen-lockfile)` });
291
+ }
292
+ }
293
+ }
294
+ return out;
295
+ },
157
296
  'store-bloat-cap': (f, sev) => {
158
297
  const s = f.store;
159
298
  if (!s || !Number.isFinite(s.count) || !Number.isFinite(s.cap) || s.cap <= 0) return [];
@@ -161,6 +300,14 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
161
300
  },
162
301
  };
163
302
 
303
+ /**
304
+ * Rules that may NEVER be promoted to HARD, whatever a config says. A rule whose evidence comes from a
305
+ * deliberately tolerant parser must not be able to BLOCK an operation: the parser's own design admits it
306
+ * may not understand a file, and "I might be wrong" plus "block the publish" is the wrong pair. Disabling
307
+ * such a rule stays allowed — only the promotion is refused.
308
+ */
309
+ export const SOFT_ONLY_RULES: readonly string[] = ['lockfile-in-sync'];
310
+
164
311
  /** Merge a user config over the defaults: override severity, disable (enabled:false), never add an un-checked rule. */
165
312
  export function resolveRules(userRules?: readonly Partial<GuardRule>[]): GuardRule[] {
166
313
  const byId = new Map<string, GuardRule>(DEFAULT_RULES.map((r) => [r.id, r]));
@@ -168,9 +315,12 @@ export function resolveRules(userRules?: readonly Partial<GuardRule>[]): GuardRu
168
315
  if (!u || typeof u.id !== 'string') continue;
169
316
  const base = byId.get(u.id);
170
317
  if (!base) continue; // a config rule with no built-in checker is ignored (fail-safe: no un-enforceable rules)
318
+ // A SOFT-ONLY rule keeps its severity even when the config asks for hard (see SOFT_ONLY_RULES).
319
+ const severity = u.severity === 'hard' || u.severity === 'soft' ? u.severity : undefined;
320
+ const allowedSeverity = severity !== undefined && !(severity === 'hard' && SOFT_ONLY_RULES.includes(u.id)) ? severity : undefined;
171
321
  byId.set(u.id, {
172
322
  ...base,
173
- ...(u.severity === 'hard' || u.severity === 'soft' ? { severity: u.severity } : {}),
323
+ ...(allowedSeverity !== undefined ? { severity: allowedSeverity } : {}),
174
324
  ...(typeof u.enabled === 'boolean' ? { enabled: u.enabled } : {}),
175
325
  });
176
326
  }