@rungs/cli 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +6 -6
  2. package/dist/cli.js +2194 -488
  3. package/dist/cli.js.map +4 -4
  4. package/modules/README.md +25 -3
  5. package/modules/adr/files/{{path}}/README.md +1 -1
  6. package/modules/adr/gates/adr.toml +1 -1
  7. package/modules/adr/module.toml +1 -1
  8. package/modules/audit/fragments/AGENTS.md +2 -2
  9. package/modules/audit/module.toml +1 -1
  10. package/modules/audit/skills/assess/SKILL.md +1 -1
  11. package/modules/backlog/files/docs/{{root}}/BACKLOG.md +1 -1
  12. package/modules/backlog/files/docs/{{root}}/README.md +2 -2
  13. package/modules/backlog/files/docs/{{root}}/archive/README.md +1 -1
  14. package/modules/backlog/files/docs/{{root}}/items/README.md +1 -1
  15. package/modules/backlog/fragments/AGENTS.md +2 -2
  16. package/modules/backlog/module.toml +1 -1
  17. package/modules/backlog/skills/work-item/SKILL.md +1 -1
  18. package/modules/ci/files/{{workflow_path}} +3 -3
  19. package/modules/ci/module.toml +1 -1
  20. package/modules/concurrency/files/docs/concurrent-sessions.md +66 -18
  21. package/modules/concurrency/fragments/AGENTS.md +5 -4
  22. package/modules/concurrency/fragments/gitattributes +2 -2
  23. package/modules/concurrency/gates/concurrency.toml +3 -3
  24. package/modules/concurrency/module.toml +1 -1
  25. package/modules/doc-authority/files/{{registry_path}} +1 -1
  26. package/modules/doc-authority/module.toml +1 -1
  27. package/modules/findings/files/docs/{{backlog.root}}/FINDINGS.md +1 -1
  28. package/modules/findings/gates/findings.toml +5 -0
  29. package/modules/findings/module.toml +1 -1
  30. package/modules/findings/skills/record-finding/SKILL.md +1 -1
  31. package/modules/gates/files/.ai/gates.toml +1 -1
  32. package/modules/gates/fragments/AGENTS.md +6 -5
  33. package/modules/gates/module.toml +1 -1
  34. package/modules/instructions/files/.ai/rules/README.md +2 -2
  35. package/modules/instructions/files/.ai/rungs.mjs +52 -0
  36. package/modules/instructions/files/AGENTS.md +4 -2
  37. package/modules/instructions/files/CLAUDE.md +1 -1
  38. package/modules/instructions/fragments/AGENTS.md +2 -2
  39. package/modules/instructions/gates/core.toml +2 -2
  40. package/modules/instructions/module.toml +1 -1
  41. package/modules/release/files/{{changelog_dir}}/CONSUMED_THROUGH +1 -0
  42. package/modules/release/gates/release.toml +169 -17
  43. package/modules/release/module.toml +9 -5
  44. package/modules/release/skills/cut-release/SKILL.md +43 -15
  45. package/modules/session/files/{{archive}}/README.md +1 -1
  46. package/modules/session/files/{{path}} +2 -2
  47. package/modules/session/module.toml +1 -1
  48. package/modules/specs/files/{{path}}/README.md +2 -2
  49. package/modules/specs/module.toml +1 -1
  50. package/modules/workflows/module.toml +1 -1
  51. package/modules/workflows/rules/planning-tiers.md +1 -1
  52. package/package.json +3 -2
  53. package/src/add.ts +204 -48
  54. package/src/backlog.ts +354 -48
  55. package/src/check.ts +54 -33
  56. package/src/cli.ts +196 -69
  57. package/src/concurrency.ts +628 -42
  58. package/src/detect.ts +11 -3
  59. package/src/emitted-path.ts +274 -0
  60. package/src/engine-table.ts +66 -0
  61. package/src/engines.ts +40 -32
  62. package/src/engines2.ts +424 -29
  63. package/src/engines3.ts +115 -23
  64. package/src/explain.ts +3 -7
  65. package/src/help.ts +43 -0
  66. package/src/lifecycle.ts +95 -31
  67. package/src/manifest.ts +41 -5
  68. package/src/render.ts +106 -21
  69. package/src/selftest.ts +87 -10
  70. package/src/storage-key.ts +20 -0
  71. package/src/substitute.ts +47 -5
  72. package/src/text.ts +11 -0
  73. package/src/types.ts +16 -3
  74. package/src/version-source.ts +144 -0
package/src/engines3.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
- import { execSync } from 'node:child_process';
2
+ import { execFileSync } from 'node:child_process';
3
3
  import { join } from 'node:path';
4
4
  import { matchAny } from './glob.ts';
5
5
  import type { Engine, Finding } from './engines.ts';
6
+ import { readVersionSource } from './version-source.ts';
7
+ import { semanticText } from './text.ts';
6
8
 
7
9
  const read = (root: string, rel: string) => {
8
10
  try {
9
- return readFileSync(join(root, rel), 'utf8');
11
+ return semanticText(readFileSync(join(root, rel), 'utf8'));
10
12
  } catch {
11
13
  return '';
12
14
  }
@@ -34,10 +36,15 @@ export function versionCmp(a: number[], b: number[]): number {
34
36
  * `changelog.d/0.1.1.md` survived two releases and was still there at 0.2.0
35
37
  * preparation (F-022). So the rule becomes mechanical.
36
38
  *
37
- * A fragment is stale when its filename names a version **below** the version
38
- * being prepared. Files whose names are not versions are ignored rather than
39
- * reported: the module's own fixtures use `42.feature.md`, and a gate that
40
- * refuses a naming convention it was not asked about is a gate people disable.
39
+ * The repository also states the last version whose fragments were consumed.
40
+ * That boundary must equal the package version in a steady tree, and a fragment
41
+ * at or below it is stale. Equality is what closes F-025: after a release, the
42
+ * forgotten fragment and package version are equal, so comparing only those two
43
+ * values cannot tell preparation from already-consumed work.
44
+ *
45
+ * Files whose names are not versions are ignored rather than reported: the
46
+ * module's own fixtures use `42.feature.md`, and a gate that refuses a naming
47
+ * convention it was not asked about is a gate people disable.
41
48
  */
42
49
  export const changelogFreshness: Engine = (t, root, files) => {
43
50
  const specs = Array.isArray(t) ? t : [t];
@@ -45,34 +52,119 @@ export const changelogFreshness: Engine = (t, root, files) => {
45
52
  let examined = 0;
46
53
 
47
54
  for (const spec of specs) {
48
- const src = spec.version ?? {};
55
+ const sources = Array.isArray(spec.versions)
56
+ ? spec.versions
57
+ : [spec.version ?? { file: 'package.json', path: 'version' }];
49
58
  let current: number[] | null = null;
50
- for (const rel of matchAny(files, src.file ?? 'package.json')) {
51
- try {
52
- const raw = (src.path ?? 'version')
53
- .split('.')
54
- .reduce((o: any, k: string) => o?.[k], JSON.parse(read(root, rel)));
55
- current = versionParts(String(raw ?? ''));
56
- } catch {
57
- /* unparseable is not a stale fragment */
59
+ let currentSource = '';
60
+ const versionProblems: Finding[] = [];
61
+ for (const source of sources) {
62
+ for (const rel of matchAny(files, source.file ?? 'package.json')) {
63
+ const result = readVersionSource(root, rel, source);
64
+ if (!result.ok) {
65
+ versionProblems.push({ file: rel, message: `release version source ${result.reason}` });
66
+ continue;
67
+ }
68
+ const parsed = versionParts(result.value);
69
+ if (!parsed) {
70
+ versionProblems.push({
71
+ file: rel,
72
+ message: `release version source must contain a three-part numeric version; found ${JSON.stringify(result.value)}`,
73
+ });
74
+ continue;
75
+ }
76
+ current = parsed;
77
+ if (current) {
78
+ currentSource = rel;
79
+ break;
80
+ }
58
81
  }
59
82
  if (current) break;
60
83
  }
61
- // Without a version to compare against there is no claim to make. Saying
62
- // nothing is right; passing loudly would not be.
63
- if (!current) continue;
84
+ const marker = spec.consumed_through ?? 'changelog.d/CONSUMED_THROUGH';
85
+ const markerExists = existsSync(join(root, marker));
86
+ if (!markerExists) {
87
+ findings.push({
88
+ file: marker,
89
+ message:
90
+ `release consumption marker '${marker}' is missing — create it with 'none' if no release has consumed fragments, or the exact last consumed version`,
91
+ });
92
+ continue;
93
+ }
94
+
95
+ examined++;
96
+ const markerRaw = read(root, marker);
97
+ const markerValue = markerRaw.endsWith('\r\n')
98
+ ? markerRaw.slice(0, -2)
99
+ : markerRaw.endsWith('\n')
100
+ ? markerRaw.slice(0, -1)
101
+ : markerRaw;
102
+
103
+ if (markerValue === 'UNINITIALIZED') {
104
+ findings.push({
105
+ file: marker,
106
+ message:
107
+ `release consumption marker '${marker}' is UNINITIALIZED — replace it with 'none' if no release has consumed fragments, or the exact last consumed version`,
108
+ });
109
+ continue;
110
+ }
111
+
112
+ const firstRelease = markerValue === 'none';
113
+ // `versionParts` intentionally trims package metadata. The marker is a
114
+ // one-token schema, so validate its complete content before reusing that
115
+ // parser; whitespace, a BOM or a second newline must not become valid.
116
+ const consumed = firstRelease || !/^\d+\.\d+\.\d+$/.test(markerValue)
117
+ ? null
118
+ : versionParts(markerValue);
119
+ if (!firstRelease && !consumed) {
120
+ findings.push({
121
+ file: marker,
122
+ message:
123
+ `release consumption marker '${marker}' must contain exactly 'none' or a three-part numeric version; found ${JSON.stringify(markerValue)}`,
124
+ });
125
+ continue;
126
+ }
127
+
128
+ findings.push(...versionProblems);
129
+
130
+ // Without a package version there used to be no claim to make. A concrete
131
+ // consumption boundary changes that: its steady-state equality cannot be
132
+ // checked, so reporting green would make the new state assertion vacuous.
133
+ if (!current) {
134
+ if (consumed) {
135
+ findings.push({
136
+ file: sources[0]?.file ?? 'package.json',
137
+ message:
138
+ `cannot reconcile consumed-through ${markerValue} because none of the declared version sources contains a three-part numeric version`,
139
+ });
140
+ }
141
+ continue;
142
+ }
143
+
144
+ if (consumed && versionCmp(consumed, current) !== 0) {
145
+ const relation = versionCmp(consumed, current) < 0 ? 'below' : 'above';
146
+ findings.push({
147
+ file: marker,
148
+ message:
149
+ `release consumption marker names ${markerValue}, ${relation} version ${current.join('.')} in ${currentSource} — they must match in a steady tree; advance both during reversible release preparation`,
150
+ });
151
+ }
64
152
 
65
153
  for (const rel of expand(files, spec.fragments, [])) {
66
154
  const name = rel.split('/').pop()!.replace(/\.md$/, '');
67
155
  const v = versionParts(name);
68
156
  if (!v) continue;
69
157
  examined++;
70
- if (versionCmp(v, current) < 0) {
158
+ const belowPackage = versionCmp(v, current) < 0;
159
+ const alreadyConsumed = consumed ? versionCmp(v, consumed) <= 0 : false;
160
+ if (belowPackage || alreadyConsumed) {
71
161
  findings.push({
72
162
  file: rel,
73
163
  message:
74
164
  spec.message?.trim() ||
75
- `fragment names ${name}, below the ${current.join('.')} being prepared — it was consumed by an earlier release and should have been deleted`,
165
+ (alreadyConsumed
166
+ ? `fragment names ${name}, at or below consumed-through ${markerValue} — it was already assembled and should have been deleted`
167
+ : `fragment names ${name}, below package version ${current.join('.')} — it belongs to an earlier release and should have been deleted`),
76
168
  });
77
169
  }
78
170
  }
@@ -213,7 +305,7 @@ export const rulePropagation: Engine = (t, root, files) => {
213
305
  export const gitState: Engine = (t, root) => {
214
306
  let out: string;
215
307
  try {
216
- out = execSync('git worktree list --porcelain', { cwd: root, stdio: 'pipe' }).toString();
308
+ out = execFileSync('git', ['worktree', 'list', '--porcelain'], { cwd: root, stdio: 'pipe' }).toString();
217
309
  } catch {
218
310
  // Not a git repo, or git unavailable. An unattributable result blocks:
219
311
  // we do not land on an unknown.
@@ -248,7 +340,8 @@ export const mergeDriverCheck: Engine = (t, root) => {
248
340
  for (const driver of required) {
249
341
  let configured = '';
250
342
  try {
251
- configured = execSync(`git config --get merge.${driver}.driver`, { cwd: root, stdio: 'pipe' }).toString().trim();
343
+ // Driver names come from `.gitattributes`, so they reach this as data.
344
+ configured = execFileSync('git', ['config', '--get', `merge.${driver}.driver`], { cwd: root, stdio: 'pipe' }).toString().trim();
252
345
  } catch {
253
346
  /* absent config exits non-zero, which is the finding */
254
347
  }
@@ -338,4 +431,3 @@ export const boardReconcile: Engine = (t, root, _files) => {
338
431
 
339
432
  return { findings, examined };
340
433
  };
341
-
package/src/explain.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { ENGINES, type Finding } from './engines.ts';
2
- import { loadTable, tableKey } from './check.ts';
2
+ import { loadTable } from './check.ts';
3
3
  import type { DetectResult, Manifest } from './types.ts';
4
+ import { selectEngineTable } from './engine-table.ts';
4
5
 
5
6
  /**
6
7
  * `doctor` answers a *presence* question — which of our modules does this repo
@@ -138,12 +139,7 @@ export function explainWith(
138
139
  }
139
140
 
140
141
  try {
141
- const key = tableKey(g.engine!);
142
- let section = table[key] ?? table;
143
- if (Array.isArray(section) && section.some((s: any) => s?.id)) {
144
- const mine = section.filter((s: any) => !s.id || g.id.includes(s.id));
145
- if (mine.length) section = mine;
146
- }
142
+ const section = selectEngineTable(table, g.engine!, g.id);
147
143
  const r = engines[g.engine!](section, repoRoot, files);
148
144
  if (r.findings.length) {
149
145
  reported.push({ module: mod.name, gate: g.id, why: g.why, findings: r.findings, examined: r.examined });
package/src/help.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The command surface, defined once and rendered into `--help`.
3
+ *
4
+ * It was a template literal listing eight of the nine commands — `setup git` was missing entirely —
5
+ * beside a README table listing all nine, which is two hand-kept inventories of one fact. They had
6
+ * already drifted, in both directions: help omitted a real command, and three real flags appeared
7
+ * in neither. Keep this dependency-free: the docs claim gate imports this exact authority in a
8
+ * fresh land worktree where ignored package dependencies are deliberately absent.
9
+ *
10
+ * The README's table is still hand-kept and still a second inventory. That is a known cost, not an
11
+ * oversight — see WI-004.
12
+ */
13
+ export const COMMANDS: [usage: string, blurb: string][] = [
14
+ ['init [path] [profile]', 'scaffold a repo — minimal · tracked · disciplined · hardened · fleet'],
15
+ ['doctor [path]', 'detect what a repo already has, installed or not'],
16
+ ['add <module…> [--into p]', 'install modules, resolving dependencies and adopting what exists'],
17
+ ['check [path] [tier]', 'run the registered gates and record the ledger'],
18
+ ['render [path]', 're-emit path-scoped rules per harness'],
19
+ ['upgrade [path]', 'move to newer module versions, never touching what you edited'],
20
+ ['eject [path]', 'materialise the engines; stop depending on rungs'],
21
+ ['setup git [path]', 'install the merge drivers .gitattributes names'],
22
+ ['modules', 'list the module set and audit the manifests'],
23
+ ['backlog archive [path]', 'move finished items to archive/, repointing every link'],
24
+ ['session start <branch>', 'cut a branch and worktree from the last verified merge'],
25
+ ['preflight [path]', 'did the integration branch change files you changed?'],
26
+ ['land <branch>', 'merge → verify the merged tree → advance, or refuse and park it'],
27
+ ['worktrees [path]', 'which worktrees are merged, prunable, or merged and still dirty'],
28
+ ];
29
+
30
+ /** Every flag the parser honours. A flag absent here is a flag nobody can find. */
31
+ export const FLAGS: [flag: string, blurb: string][] = [
32
+ ['--dry-run', 'report what would happen, write nothing'],
33
+ ['--explain', "doctor: also run the detectors over what this repo already has"],
34
+ ['--confirm-paradigm', 'add: install a module this repo already solves another way'],
35
+ ['--confirm-conflict', 'add: install a module that declares a conflict with one already here'],
36
+ ['--into <path>', 'add: install into this repo instead of the working directory'],
37
+ ['--set m.param=value', 'add/init: override a module parameter. Repeatable'],
38
+ ['--confirm-threshold', 'add: install a module whose rung is above this repo'],
39
+ ['--apply', 'upgrade: write the changes, rather than preview them'],
40
+ ['--fast, --full', 'check: pick the gate tier, as the positional also does'],
41
+ ['--params', 'modules: show every module parameter, its default and its allowed values'],
42
+ ['--copilot', 'also emit Copilot instruction files'],
43
+ ];
package/src/lifecycle.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { execSync } from 'node:child_process';
4
+ import { execFileSync } from 'node:child_process';
5
5
  import { parse } from 'smol-toml';
6
6
  import type { Manifest } from './types.ts';
7
- import { contentHash, emittedFiles, registerGates } from './add.ts';
7
+ import { contentHash, emittedFiles, moduleEmissionCandidates, preflightModuleEmissions, registerGates } from './add.ts';
8
8
  import { resolveParams, substitute, type Params } from './substitute.ts';
9
9
  import { loadRegistry } from './check.ts';
10
+ import { preflightEmittedPaths, resolveEmittedPath, UnsafeEmittedPathError } from './emitted-path.ts';
11
+ import { semanticText } from './text.ts';
10
12
 
11
13
  const SRC = dirname(fileURLToPath(import.meta.url));
12
14
 
@@ -25,7 +27,15 @@ export interface InstallRecord {
25
27
  }
26
28
 
27
29
  export function readRecord(repoRoot: string): InstallRecord | null {
28
- const p = join(repoRoot, '.ai', 'rungs.toml');
30
+ const resolved = resolveEmittedPath(repoRoot, 'rungs', '.ai/rungs.toml');
31
+ if (resolved.leafAlias) {
32
+ throw new UnsafeEmittedPathError(
33
+ 'rungs',
34
+ '.ai/rungs.toml',
35
+ 'the install record is a symlink or junction leaf and will not be used as configuration',
36
+ );
37
+ }
38
+ const p = resolved.absolute;
29
39
  if (!existsSync(p)) return null;
30
40
  try {
31
41
  const raw = parse(readFileSync(p, 'utf8')) as any;
@@ -59,16 +69,26 @@ export function planUpgrade(repoRoot: string, mods: Manifest[], record: InstallR
59
69
  const params = resolveParams(mods, paramsFrom(record), repoRoot);
60
70
  const skillsDir = record.harnesses.includes('claude') ? '.claude/skills' : '.agents/skills';
61
71
  const items: UpgradeItem[] = [];
72
+ const installedMods = mods.filter((mod) => record.modules[mod.name]);
73
+
74
+ // Parameters in the install record are untrusted input to this newer CLI.
75
+ // Validate every installed module before examining any one of them, so a
76
+ // later unsafe target cannot make a partial plan look usable.
77
+ preflightModuleEmissions(installedMods, repoRoot, params, skillsDir);
62
78
 
63
- for (const mod of mods) {
79
+ for (const mod of installedMods) {
64
80
  const installed = record.modules[mod.name];
65
- if (!installed) continue;
66
81
  const emitted = emittedFiles(mod, params, skillsDir);
67
82
  const files: UpgradeItem['files'] = [];
68
83
  const kept = new Set(installed.kept?.files ?? []);
69
84
  for (const [rel, wouldEmit] of emitted) {
70
85
  if (kept.has(rel)) continue; // never ours; upgrade does not touch it
71
- const full = join(repoRoot, rel);
86
+ const resolved = resolveEmittedPath(repoRoot, mod.name, rel);
87
+ const full = resolved.absolute;
88
+ if (resolved.leafAlias) {
89
+ files.push({ rel, state: 'diverged' });
90
+ continue;
91
+ }
72
92
  if (!existsSync(full)) {
73
93
  files.push({ rel, state: 'missing' });
74
94
  continue;
@@ -88,21 +108,58 @@ export function planUpgrade(repoRoot: string, mods: Manifest[], record: InstallR
88
108
  export function applyUpgrade(repoRoot: string, mods: Manifest[], record: InstallRecord, plan: UpgradeItem[]) {
89
109
  const params = resolveParams(mods, paramsFrom(record), repoRoot);
90
110
  const skillsDir = record.harnesses.includes('claude') ? '.claude/skills' : '.agents/skills';
111
+ const prepared = plan.map((item) => {
112
+ const mod = mods.find((candidate) => candidate.name === item.module);
113
+ if (!mod) throw new Error(`upgrade plan names unknown module '${item.module}'`);
114
+ const emitted = emittedFiles(mod, params, skillsDir);
115
+ const files = item.files.map((file) => {
116
+ const resolved = resolveEmittedPath(repoRoot, mod.name, file.rel);
117
+ if ((file.state === 'stale' || file.state === 'missing') && resolved.leafAlias) {
118
+ throw new UnsafeEmittedPathError(
119
+ mod.name,
120
+ file.rel,
121
+ 'the destination is a symlink or junction leaf and upgrade will not write through it',
122
+ );
123
+ }
124
+ if ((file.state === 'stale' || file.state === 'missing') && !emitted.has(resolved.target)) {
125
+ throw new Error(`module '${mod.name}' upgrade plan names target '${file.rel}' that the module does not emit`);
126
+ }
127
+ return { ...file, target: resolved.target, absolute: resolved.absolute };
128
+ });
129
+ return { item, mod, emitted, files };
130
+ });
131
+
132
+ // Rebuild and validate the complete emission set at apply time. Callers may
133
+ // retain or manufacture a plan, so application never trusts planning to have
134
+ // happened in this process or against this filesystem.
135
+ const writable = new Set(
136
+ prepared.flatMap(({ mod, files }) =>
137
+ files
138
+ .filter((file) => file.state === 'stale' || file.state === 'missing')
139
+ .map((file) => `${mod.name}\0${file.target}`),
140
+ ),
141
+ );
142
+ preflightEmittedPaths(repoRoot, [
143
+ ...moduleEmissionCandidates(prepared.map(({ mod }) => mod), params, skillsDir).map((candidate) =>
144
+ writable.has(`${candidate.moduleName}\0${candidate.target}`)
145
+ ? { ...candidate, writeExisting: true }
146
+ : candidate,
147
+ ),
148
+ { moduleName: prepared[0]?.mod.name ?? 'upgrade', target: '.ai/rungs.toml', writeExisting: true },
149
+ ]);
150
+
91
151
  let written = 0;
92
152
  // Only files this run rewrote. A diverged file is not in here, which is what
93
153
  // keeps its recorded hash — and therefore its protection — intact (F-017).
94
154
  const rewritten = new Map<string, Map<string, string>>();
95
- for (const item of plan) {
96
- const mod = mods.find((m) => m.name === item.module)!;
97
- const emitted = emittedFiles(mod, params, skillsDir);
98
- for (const f of item.files) {
155
+ for (const { mod, emitted, files } of prepared) {
156
+ for (const f of files) {
99
157
  if (f.state !== 'stale' && f.state !== 'missing') continue;
100
- const full = join(repoRoot, f.rel);
101
- const content = emitted.get(f.rel)!;
102
- mkdirSync(dirname(full), { recursive: true });
103
- writeFileSync(full, content);
158
+ const content = emitted.get(f.target)!;
159
+ mkdirSync(dirname(f.absolute), { recursive: true });
160
+ writeFileSync(f.absolute, content);
104
161
  if (!rewritten.has(mod.name)) rewritten.set(mod.name, new Map());
105
- rewritten.get(mod.name)!.set(f.rel, contentHash(content));
162
+ rewritten.get(mod.name)!.set(f.target, contentHash(content));
106
163
  written++;
107
164
  }
108
165
  }
@@ -117,7 +174,7 @@ export function applyUpgrade(repoRoot: string, mods: Manifest[], record: Install
117
174
  // dropped from a manifest leaves the registry with the block that replaces it.
118
175
  // Idempotent, and cheap enough to run for every module in the plan rather than
119
176
  // only the ones whose files happened to be stale.
120
- const upgraded = plan.map((p) => mods.find((m) => m.name === p.module)!).filter(Boolean);
177
+ const upgraded = prepared.map(({ mod }) => mod);
121
178
  const gateActions = upgraded.length ? registerGates(upgraded, repoRoot, false) : [];
122
179
 
123
180
  const recorded = updateRecordAfterUpgrade(
@@ -149,10 +206,14 @@ export function updateRecordAfterUpgrade(
149
206
  repoRoot: string,
150
207
  updates: { module: string; version: string; hashes: Map<string, string> }[],
151
208
  ): number {
152
- const path = join(repoRoot, '.ai', 'rungs.toml');
209
+ const path = preflightEmittedPaths(repoRoot, [
210
+ { moduleName: updates[0]?.module ?? 'upgrade', target: '.ai/rungs.toml', writeExisting: true },
211
+ ])[0].absolute;
153
212
  if (!existsSync(path) || !updates.length) return 0;
154
213
 
155
- const lines = readFileSync(path, 'utf8').split('\n');
214
+ const original = readFileSync(path, 'utf8');
215
+ const newline = original.match(/\r\n|\r|\n/)?.[0] ?? '\n';
216
+ const lines = semanticText(original).split('\n');
156
217
  const byModule = new Map(updates.map((u) => [u.module, u]));
157
218
  let changed = 0;
158
219
  let current: { module: string; hashes: boolean } | null = null;
@@ -177,8 +238,9 @@ export function updateRecordAfterUpgrade(
177
238
  const entry = /^"([^"]+)"\s*=/.exec(line);
178
239
  const replacement = entry && byModule.get(current.module)!.hashes.get(entry[1]);
179
240
  if (replacement) {
180
- out.push(`"${entry[1]}" = "${replacement}"`);
181
- changed++;
241
+ const next = `"${entry[1]}" = "${replacement}"`;
242
+ if (next !== line) changed++;
243
+ out.push(next);
182
244
  continue;
183
245
  }
184
246
  }
@@ -186,7 +248,8 @@ export function updateRecordAfterUpgrade(
186
248
  out.push(line);
187
249
  }
188
250
 
189
- writeFileSync(path, out.join('\n'));
251
+ const updated = out.join(newline);
252
+ if (changed > 0 && updated !== original) writeFileSync(path, updated);
190
253
  return changed;
191
254
  }
192
255
 
@@ -212,7 +275,7 @@ export function eject(repoRoot: string, mods: Manifest[], dryRun = false) {
212
275
  // The first version copied `check.ts` and `manifest.ts` too, which pull in the
213
276
  // TOML parser — so an ejected repo crashed on a module it could not resolve.
214
277
  // An exit that does not work is not an exit.
215
- const engines = ['glob.ts', 'engines.ts', 'engines2.ts'];
278
+ const engines = ['glob.ts', 'text.ts', 'engine-table.ts', 'engines.ts', 'engines2.ts'];
216
279
  const { gates } = loadRegistry(repoRoot);
217
280
  const declared = gates.filter((g) => g.kind === 'declared' && g.table);
218
281
  const tables = [...new Set(declared.map((g) => g.table!))];
@@ -267,6 +330,7 @@ import { readFileSync } from 'node:fs';
267
330
  import { join, dirname } from 'node:path';
268
331
  import { fileURLToPath } from 'node:url';
269
332
  import { ENGINES } from './engines.ts';
333
+ import { selectEngineTable } from './engine-table.ts';
270
334
  import { walk } from './glob.ts';
271
335
 
272
336
  const here = dirname(fileURLToPath(import.meta.url));
@@ -283,12 +347,7 @@ if (!engine || !ENGINES[engine]) { console.error(\`gate \${id}: engine '\${engin
283
347
  // Tables were converted to JSON when this was ejected, so nothing here needs a
284
348
  // TOML parser — or any dependency at all beyond Node itself.
285
349
  const raw = JSON.parse(readFileSync(join(here, 'tables', table.replace('/', '-').replace(/\\.toml$/, '.json')), 'utf8'));
286
- const KEYS = { 'file-budget': 'file_budget', 'frontmatter-schema': 'frontmatter_schema', 'link-integrity': 'link_integrity', 'file-population': 'file_population', 'gate-meta': 'gate_meta', 'render-freshness': 'render_freshness', 'register-schema': 'register_schema', 'self-declared-closure': 'self_declared_closure', 'filename-schema': 'filename_schema', 'cross-reference': 'cross_reference', 'git-status-reconcile': 'merged_status', 'computed-claim': 'computed_claim' };
287
- let section = raw[KEYS[engine] ?? engine] ?? raw;
288
- if (Array.isArray(section) && section.some((s) => s?.id)) {
289
- const mine = section.filter((s) => !s.id || id.includes(s.id));
290
- if (mine.length) section = mine;
291
- }
350
+ const section = selectEngineTable(raw, engine, id);
292
351
  const r = ENGINES[engine](section, root, walk(root));
293
352
  for (const f of r.findings) console.error(\` \${f.file ? f.file + ': ' : ''}\${f.message}\`);
294
353
  process.exit(r.findings.length ? 1 : 0);
@@ -334,8 +393,13 @@ export function setupGit(repoRoot: string, dryRun = false) {
334
393
  : 'git merge-file -L ours -L base -L theirs %A %O %B';
335
394
  if (!dryRun) {
336
395
  try {
337
- execSync(`git config merge.${d}.name "rungs ${d.replace('rungs-', '')} driver"`, { cwd: repoRoot, stdio: 'pipe' });
338
- execSync(`git config merge.${d}.driver ${JSON.stringify(cmd)}`, { cwd: repoRoot, stdio: 'pipe' });
396
+ // argv, not a shell string. The `rungs-generated` driver command carries
397
+ // single quotes, a literal `\n` and `%A %O %B`, and it was being handed
398
+ // to a shell through `JSON.stringify` — quoting that happens to survive
399
+ // cmd.exe and does not survive bash the same way. The same class of bug
400
+ // as F-033, found in the same sweep.
401
+ execFileSync('git', ['config', `merge.${d}.name`, `rungs ${d.replace('rungs-', '')} driver`], { cwd: repoRoot, stdio: 'pipe' });
402
+ execFileSync('git', ['config', `merge.${d}.driver`, cmd], { cwd: repoRoot, stdio: 'pipe' });
339
403
  } catch {
340
404
  continue;
341
405
  }
@@ -345,7 +409,7 @@ export function setupGit(repoRoot: string, dryRun = false) {
345
409
  let rerere = false;
346
410
  if (!dryRun) {
347
411
  try {
348
- execSync('git config rerere.enabled true', { cwd: repoRoot, stdio: 'pipe' });
412
+ execFileSync('git', ['config', 'rerere.enabled', 'true'], { cwd: repoRoot, stdio: 'pipe' });
349
413
  rerere = true;
350
414
  } catch {
351
415
  /* not a git repo */
package/src/manifest.ts CHANGED
@@ -28,12 +28,48 @@ export function loadManifest(dir: string): Manifest {
28
28
  };
29
29
 
30
30
  // `[provenance]` is required and validated (ADR-0003). A module with no
31
- // traceable source is one somebody invented, and `doctor` cannot ask its
32
- // questions without the incident.
31
+ // traceable source is one somebody invented, and that is the thing CLAUDE.md's
32
+ // evidence rule exists to prevent.
33
+ //
34
+ // F-037: the schema had exactly one shape, and it *asserted extraction* —
35
+ // `sources`, `patterns` and `incident`, all required. A module authored
36
+ // outside this package has none of the three, and the only way past the
37
+ // validator was prose in fields whose names claim a pedigree it does not
38
+ // have. It never blocked anyone: any non-empty string loads, which is the
39
+ // worse outcome, because nothing then distinguished an honest "none" from an
40
+ // invented incident. All fifteen bundled modules were extracted, so the case
41
+ // had never been exercised and the first author outside them was quietly
42
+ // invited to make something up.
43
+ //
44
+ // So the distinction is declared rather than written around, and a designed
45
+ // module may not carry the extracted module's fields. **Half a claim is the
46
+ // failure mode**: a `sources` line naming the repo that inspired a module
47
+ // reads, to every later reader, exactly like a repo that paid for it.
33
48
  const p = manifest.provenance;
34
- if (!p?.sources?.length) throw new Error(`${name}: [provenance].sources is required`);
35
- if (!p?.patterns?.length) throw new Error(`${name}: [provenance].patterns is required`);
36
- if (!p?.incident?.trim()) throw new Error(`${name}: [provenance].incident is required`);
49
+ if (!p) throw new Error(`${name}: [provenance] is required`);
50
+ const kind = p.kind ?? 'extracted';
51
+ if (kind !== 'extracted' && kind !== 'designed') {
52
+ throw new Error(`${name}: [provenance].kind must be 'extracted' or 'designed', not '${kind}'`);
53
+ }
54
+ if (kind === 'extracted') {
55
+ if (!p.sources?.length) throw new Error(`${name}: [provenance].sources is required`);
56
+ if (!p.patterns?.length) throw new Error(`${name}: [provenance].patterns is required`);
57
+ if (!p.incident?.trim()) throw new Error(`${name}: [provenance].incident is required`);
58
+ } else {
59
+ if (!p.rationale?.trim()) {
60
+ throw new Error(`${name}: [provenance].rationale is required when kind = "designed" — say why it exists, in the first person`);
61
+ }
62
+ if (p.incident?.trim()) {
63
+ throw new Error(`${name}: [provenance].incident belongs to an extracted module; a designed one has no incident to name`);
64
+ }
65
+ if (p.sources?.length) {
66
+ throw new Error(`${name}: [provenance].sources belongs to an extracted module; cite influences in the rationale instead`);
67
+ }
68
+ // `patterns` is deliberately still allowed. A designed module may implement
69
+ // patterns from the catalogue, and the catalogue entries are themselves
70
+ // evidenced — citing one claims nothing about this module's own history.
71
+ }
72
+ manifest.provenance = { ...p, kind };
37
73
 
38
74
  return manifest;
39
75
  }