@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
@@ -13,12 +13,13 @@
13
13
  * 3. Never hold the integration branch. Everything runs from a throwaway
14
14
  * worktree, which the module already gates for.
15
15
  */
16
- import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, unlinkSync } from 'node:fs';
16
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, unlinkSync } from 'node:fs';
17
17
  import { execFileSync } from 'node:child_process';
18
18
  import { hostname } from 'node:os';
19
19
  import { join, resolve, dirname, basename } from 'node:path';
20
20
  import { tmpdir } from 'node:os';
21
21
  import { installedParams } from './check.ts';
22
+ import { canonicalCaselessSegmentEqual } from './storage-key.ts';
22
23
 
23
24
  export interface LoopParams {
24
25
  integration: string;
@@ -61,6 +62,356 @@ function revParse(root: string, ref: string): string | null {
61
62
  }
62
63
  }
63
64
 
65
+ interface GitWorktree {
66
+ path: string;
67
+ branch?: string;
68
+ }
69
+
70
+ interface GitBranchRef {
71
+ ref: string;
72
+ oid: string;
73
+ symref?: string;
74
+ }
75
+
76
+ type GitRefFormat = 'files' | 'reftable';
77
+
78
+ /** @internal Exported only so the legacy-Git compatibility boundary is directly testable. */
79
+ export function parseGitRefFormatOutput(output: string | undefined): GitRefFormat {
80
+ // Git versions predating `--show-ref-format` can treat it as a revision-like
81
+ // unknown option: they echo the literal argument and exit successfully. Those
82
+ // versions predate reftable, so that response has the same meaning as a failed
83
+ // query. Do not generalize the fallback: a real future backend must fail closed.
84
+ if (output === undefined || output === '--show-ref-format') return 'files';
85
+ if (output === 'files' || output === 'reftable') return output;
86
+ throw new Error(`unsupported Git ref format '${output}'`);
87
+ }
88
+
89
+ /**
90
+ * Read worktree paths without line parsing.
91
+ *
92
+ * A worktree path may contain whitespace or a newline. `--porcelain -z` makes
93
+ * NUL the only record delimiter, while branch refs cannot contain NUL, so the
94
+ * two fields can be associated without quoting or shell interpretation.
95
+ */
96
+ function gitWorktrees(root: string): GitWorktree[] {
97
+ const out = execFileSync('git', ['worktree', 'list', '--porcelain', '-z'], {
98
+ cwd: root,
99
+ stdio: 'pipe',
100
+ encoding: 'utf8',
101
+ });
102
+ const rows: GitWorktree[] = [];
103
+ let row: GitWorktree | undefined;
104
+
105
+ for (const field of out.split('\0')) {
106
+ if (!field) {
107
+ if (row) rows.push(row);
108
+ row = undefined;
109
+ } else if (field.startsWith('worktree ')) {
110
+ // Be defensive about malformed output missing its blank record separator.
111
+ if (row) rows.push(row);
112
+ row = { path: field.slice('worktree '.length) };
113
+ } else if (row && field.startsWith('branch ')) {
114
+ row.branch = field.slice('branch '.length);
115
+ }
116
+ }
117
+ if (row) rows.push(row);
118
+ return rows;
119
+ }
120
+
121
+ /**
122
+ * Enumerate the branch names Git actually stores instead of asking its ref
123
+ * resolver whether a constructed spelling happens to work on this filesystem.
124
+ *
125
+ * Windows can resolve `refs/heads/MAIN` through a loose `refs/heads/main`, and
126
+ * `update-ref` follows a symbolic branch ref by default. Either would make the
127
+ * worktree-holder check and the ref update talk about different branches. Ref
128
+ * names cannot contain tabs or newlines, so this argv-only format is unambiguous.
129
+ */
130
+ function gitLocalBranchRefs(root: string): GitBranchRef[] {
131
+ const out = git(root, ['for-each-ref', '--format=%(refname)%09%(objectname)%09%(symref)', 'refs/heads/']);
132
+ const refs = new Map<string, GitBranchRef>();
133
+ if (out) {
134
+ for (const line of out.split('\n')) {
135
+ const [ref, oid, symref] = line.split('\t');
136
+ refs.set(ref, { ref, oid, ...(symref ? { symref } : {}) });
137
+ }
138
+ }
139
+
140
+ // `for-each-ref` deliberately omits a symbolic ref whose target does not
141
+ // exist. It is still operator-visible state and `update-ref --no-deref
142
+ // create` would replace it, so include every valid loose branch-ref file.
143
+ // Loose refs override packed refs with the same exact spelling, just as Git
144
+ // resolves them. Directory entries are read without following OS aliases.
145
+ const common = resolve(root, git(root, ['rev-parse', '--git-common-dir']));
146
+ const heads = join(common, 'refs', 'heads');
147
+ const visit = (directory: string, prefix: string): void => {
148
+ if (!existsSync(directory)) return;
149
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
150
+ const shortName = prefix ? `${prefix}/${entry.name}` : entry.name;
151
+ const path = join(directory, entry.name);
152
+ if (entry.isDirectory()) {
153
+ visit(path, shortName);
154
+ continue;
155
+ }
156
+ const ref = `refs/heads/${shortName}`;
157
+ // A lock is Git's coordination artifact, not a stored ref. Other entries
158
+ // are inspected conservatively; no per-ref subprocess is needed even in
159
+ // repositories with hundreds of branches.
160
+ if (entry.name.endsWith('.lock')) continue;
161
+ if (!entry.isFile()) throw new Error(`branch ref '${ref}' is not a regular loose-ref file`);
162
+ const value = readFileSync(path, 'utf8').replace(/[\r\n]+$/, '');
163
+ if (value.startsWith('ref: ')) {
164
+ refs.set(ref, { ref, oid: '', symref: value.slice('ref: '.length) });
165
+ } else {
166
+ // Git already enumerated every usable direct loose ref. The raw walk
167
+ // exists only to surface omitted symrefs; synthesizing an OID from an
168
+ // unenumerated file could report a malformed/unresolvable ref as safe.
169
+ const enumerated = refs.get(ref);
170
+ if (
171
+ !enumerated ||
172
+ enumerated.symref ||
173
+ !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value) ||
174
+ enumerated.oid.toLowerCase() !== value.toLowerCase()
175
+ ) throw new Error(`branch ref '${ref}' has an unreadable or unresolved loose-ref value`);
176
+ }
177
+ }
178
+ };
179
+ let refFormatOutput: string | undefined;
180
+ try {
181
+ refFormatOutput = git(root, ['rev-parse', '--show-ref-format']);
182
+ } catch {
183
+ // `--show-ref-format` predates reftable support. A Git without the query
184
+ // only has the files backend this scanner was written for.
185
+ }
186
+ const refFormat = parseGitRefFormatOutput(refFormatOutput);
187
+ if (refFormat === 'files') visit(heads, '');
188
+ return [...refs.values()];
189
+ }
190
+
191
+ interface DirectRef {
192
+ ref: string;
193
+ oid: string | null;
194
+ }
195
+
196
+ function symbolicRefTarget(root: string, ref: string): string | null {
197
+ try {
198
+ return git(root, ['symbolic-ref', '--quiet', ref]);
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+
204
+ type DirectRefResolution =
205
+ | { value: DirectRef; error?: never }
206
+ | { value?: never; error: string };
207
+
208
+ function refStorageCollides(left: string, right: string): boolean {
209
+ const leftSegments = left.split('/');
210
+ const rightSegments = right.split('/');
211
+ const shared = Math.min(leftSegments.length, rightSegments.length);
212
+ for (let index = 0; index < shared; index++) {
213
+ if (!canonicalCaselessSegmentEqual(leftSegments[index], rightSegments[index])) return false;
214
+ // A spelling difference in a directory shared by two otherwise different
215
+ // refs is itself a Windows/APFS alias. The later leaf difference does not
216
+ // make the filesystem path unambiguous.
217
+ if (
218
+ leftSegments[index] !== rightSegments[index] &&
219
+ index < leftSegments.length - 1 &&
220
+ index < rightSegments.length - 1
221
+ ) return true;
222
+ }
223
+ // Every segment of the shorter ref matched: the names are aliases, or one is
224
+ // a directory/file prefix of the other.
225
+ return true;
226
+ }
227
+
228
+ /** Resolve one configured spelling against stored refs without filesystem aliasing or symref dereferencing. */
229
+ function exactDirectRef(
230
+ root: string,
231
+ stored: GitBranchRef[],
232
+ shortName: string,
233
+ role: 'integration' | 'green',
234
+ required: boolean,
235
+ ): DirectRefResolution {
236
+ const wanted = `refs/heads/${shortName}`;
237
+ const label = role === 'integration'
238
+ ? `configured integration branch '${shortName}'`
239
+ : `configured green ref '${shortName}'`;
240
+ if (!gitOk(root, ['check-ref-format', wanted])) {
241
+ return { error: `${label} is not a valid direct local branch ref; land is refused.` };
242
+ }
243
+
244
+ // The files and reftable backends both omit dangling symrefs from
245
+ // `for-each-ref`; query the exact logical key before calling it creatable.
246
+ const exactSymbolicTarget = symbolicRefTarget(root, wanted);
247
+ if (exactSymbolicTarget) {
248
+ return { error: `${label} is symbolic (${wanted} -> ${exactSymbolicTarget}); land requires a direct local branch ref and is refused.` };
249
+ }
250
+
251
+ const alias = stored.find((entry) => entry.ref !== wanted && refStorageCollides(entry.ref, wanted));
252
+ if (alias) {
253
+ return { error: `${label} collides with case-aliased or directory/file-conflicting stored ref '${alias.ref}'; remove the ambiguity and retry.` };
254
+ }
255
+ const exact = stored.find((entry) => entry.ref === wanted);
256
+ if (!exact) {
257
+ if (required) return { error: `${label} has no exact stored local ref '${wanted}'; land is refused.` };
258
+ return { value: { ref: wanted, oid: null } };
259
+ }
260
+ if (exact.symref) {
261
+ return {
262
+ error: `${label} is symbolic (${exact.ref} -> ${exact.symref}); land requires a direct local branch ref and is refused.`,
263
+ };
264
+ }
265
+ return { value: { ref: exact.ref, oid: exact.oid } };
266
+ }
267
+
268
+ interface ManagedRef extends DirectRef {
269
+ holders: string[];
270
+ }
271
+
272
+ interface ManagedRefs {
273
+ integration: ManagedRef;
274
+ green: ManagedRef;
275
+ }
276
+
277
+ type ManagedRefsResolution =
278
+ | { value: ManagedRefs; error?: never }
279
+ | { value?: never; error: string };
280
+
281
+ /** Prove canonical/direct identity and checkout state for both refs the final transaction mutates. */
282
+ function managedRefsState(root: string, integration: string, green: string): ManagedRefsResolution {
283
+ try {
284
+ const stored = gitLocalBranchRefs(root);
285
+ const worktrees = gitWorktrees(root);
286
+ const integrationRef = exactDirectRef(root, stored, integration, 'integration', true);
287
+ if (!integrationRef.value) return integrationRef;
288
+ const greenRef = exactDirectRef(root, stored, green, 'green', false);
289
+ if (!greenRef.value) return greenRef;
290
+ const withHolders = (ref: DirectRef): ManagedRef => ({
291
+ ...ref,
292
+ holders: worktrees
293
+ .filter((worktree) => worktree.branch !== undefined && refStorageCollides(worktree.branch, ref.ref))
294
+ .map((worktree) => worktree.path),
295
+ });
296
+ return {
297
+ value: {
298
+ integration: withHolders(integrationRef.value),
299
+ green: withHolders(greenRef.value),
300
+ },
301
+ };
302
+ } catch {
303
+ return {
304
+ error: 'cannot enumerate local branch refs and worktrees; managed-ref identity or checkout state is unknown, so land is refused.',
305
+ };
306
+ }
307
+ }
308
+
309
+ interface RecoveryRefResult {
310
+ name?: string;
311
+ error?: string;
312
+ }
313
+
314
+ /**
315
+ * Preserve a verified merge without overwriting, dereferencing or deleting operator state.
316
+ *
317
+ * The merge-derived candidate makes retries deterministic. A numeric suffix is needed only
318
+ * when that exact candidate is occupied by distinct work or held by a worktree.
319
+ */
320
+ function createDirectRef(root: string, ref: string, oid: string): void {
321
+ // `create` is the strongest public Git precondition for an absent ref and
322
+ // `no-deref` protects a symbolic target. Supported Git versions disagree on
323
+ // whether a racing dangling symref is absent (replace its name) or occupied
324
+ // (refuse). Neither behavior follows the target, but the public protocol
325
+ // cannot portably CAS the name's direct-versus-symbolic type; that raw-Git
326
+ // micro-race is documented as a residual boundary.
327
+ const input = `option no-deref\0create ${ref}\0${oid}\0`;
328
+ execFileSync('git', ['update-ref', '--stdin', '-z', '-m', 'rungs park verified merge'], {
329
+ cwd: root,
330
+ stdio: 'pipe',
331
+ input: Buffer.from(input, 'utf8'),
332
+ });
333
+ }
334
+
335
+ function parkVerifiedMerge(
336
+ root: string,
337
+ preferred: string,
338
+ merged: string,
339
+ reserved: ReadonlySet<string>,
340
+ ): RecoveryRefResult {
341
+ const derived = `${preferred}-${merged}`;
342
+ const flat = `rungs-park-${merged}`;
343
+
344
+ for (let index = 0; index < 1000; index++) {
345
+ const candidate = index === 0
346
+ ? preferred
347
+ : index === 1
348
+ ? derived
349
+ : index === 2
350
+ ? flat
351
+ : `${flat}-${index - 2}`;
352
+ const wanted = `refs/heads/${candidate}`;
353
+ const reservedCollision = [...reserved]
354
+ .some((name) => refStorageCollides(wanted, `refs/heads/${name}`));
355
+ if (reservedCollision || !gitOk(root, ['check-ref-format', wanted])) continue;
356
+
357
+ // Inspect again after a failed create-only CAS so a concurrent creator of
358
+ // this same merge is reused while distinct work sends us to the next name.
359
+ for (let inspection = 0; inspection < 2; inspection++) {
360
+ let stored: GitBranchRef[];
361
+ let worktrees: GitWorktree[];
362
+ try {
363
+ stored = gitLocalBranchRefs(root);
364
+ worktrees = gitWorktrees(root);
365
+ } catch {
366
+ return { error: 'cannot enumerate refs and worktrees, so no recovery ref can be created safely.' };
367
+ }
368
+
369
+ const exact = stored.find((entry) => entry.ref === wanted);
370
+ const alias = stored.find((entry) => entry.ref !== wanted && refStorageCollides(entry.ref, wanted));
371
+ const exactSymbolicTarget = symbolicRefTarget(root, wanted);
372
+ const held = worktrees.some(
373
+ (worktree) => worktree.branch !== undefined && refStorageCollides(worktree.branch, wanted),
374
+ );
375
+ if (alias || exact?.symref || exactSymbolicTarget || held) break;
376
+ if (exact) {
377
+ if (exact.oid === merged) return { name: candidate };
378
+ break;
379
+ }
380
+
381
+ try {
382
+ createDirectRef(root, wanted, merged);
383
+ return { name: candidate };
384
+ } catch {
385
+ // One re-inspection distinguishes a benign same-merge race from a
386
+ // collision. Never turn the retry into an overwrite.
387
+ }
388
+ }
389
+ }
390
+ return { error: `could not allocate an unheld collision-free recovery ref below '${preferred}'.` };
391
+ }
392
+
393
+ /** Advance both markers as one no-dereference, expected-old Git ref transaction. */
394
+ function advanceVerifiedRefs(
395
+ root: string,
396
+ integration: DirectRef,
397
+ green: DirectRef,
398
+ merged: string,
399
+ ): void {
400
+ const input = [
401
+ 'option no-deref\0',
402
+ `update ${integration.ref}\0${merged}\0${integration.oid}\0`,
403
+ 'option no-deref\0',
404
+ green.oid === null
405
+ ? `create ${green.ref}\0${merged}\0`
406
+ : `update ${green.ref}\0${merged}\0${green.oid}\0`,
407
+ ].join('');
408
+ execFileSync('git', ['update-ref', '--stdin', '-z', '-m', 'rungs land verified merge'], {
409
+ cwd: root,
410
+ stdio: 'pipe',
411
+ input: Buffer.from(input, 'utf8'),
412
+ });
413
+ }
414
+
64
415
  export interface Result {
65
416
  ok: boolean;
66
417
  lines: string[];
@@ -200,11 +551,81 @@ export interface GateOutcome {
200
551
  * "inherited". Measured — a branch adding `./also-missing.md` on top of an
201
552
  * already-red link gate landed clean. Attribution is per finding.
202
553
  */
203
- failing: { id: string; findings: string[] }[];
554
+ failing: { id: string; findings: (string | { identity: string; diagnostic: string })[] }[];
204
555
  }
205
556
 
206
557
  export type LandRunner = (dir: string, only?: ReadonlySet<string>) => GateOutcome;
207
558
 
559
+ type GateFailure = GateOutcome['failing'][number];
560
+ type GateFailureFinding = GateFailure['findings'][number];
561
+
562
+ const findingIdentity = (finding: GateFailureFinding) =>
563
+ typeof finding === 'string' ? finding : finding.identity;
564
+
565
+ const findingDiagnostic = (finding: GateFailureFinding) =>
566
+ typeof finding === 'string' ? finding : finding.diagnostic;
567
+
568
+ function failingIdentities(outcome: GateOutcome): Map<string, Set<string>> {
569
+ return new Map(outcome.failing.map((failure) => [
570
+ failure.id,
571
+ new Set(failure.findings.map(findingIdentity)),
572
+ ]));
573
+ }
574
+
575
+ function coversGateIds(outcome: GateOutcome, ids: ReadonlySet<string>): boolean {
576
+ const failedIds = new Set(outcome.failing.map((failure) => failure.id));
577
+ return [...failedIds].every((id) => ids.has(id)) && failedIds.size + outcome.pass >= ids.size;
578
+ }
579
+
580
+ type ControlEligibility = { ok: true } | { ok: false; reason: string };
581
+
582
+ /**
583
+ * The invoking checkout can prove an inherited failure only when its tracked
584
+ * tree is exactly the captured integration tree. Ignored dependencies are the
585
+ * reason this control exists; non-ignored work makes it untrustworthy.
586
+ */
587
+ function exactControlEligibility(root: string, integrationOid: string): ControlEligibility {
588
+ try {
589
+ let attached = '';
590
+ try {
591
+ attached = execFileSync('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], {
592
+ cwd: root,
593
+ stdio: 'pipe',
594
+ encoding: 'utf8',
595
+ }).trim();
596
+ } catch (error: any) {
597
+ // Exit 1 is Git's documented detached-HEAD answer. Anything else means
598
+ // the checkout identity itself could not be established.
599
+ if (error?.status !== 1) {
600
+ return { ok: false, reason: 'the invoking worktree HEAD attachment state could not be read' };
601
+ }
602
+ }
603
+ if (attached) {
604
+ return { ok: false, reason: `the invoking worktree is attached to '${attached}', not detached` };
605
+ }
606
+
607
+ const head = revParse(root, 'HEAD');
608
+ if (head !== integrationOid) {
609
+ return {
610
+ ok: false,
611
+ reason: `the invoking worktree is at ${head?.slice(0, 8) ?? 'an unreadable HEAD'}, not integration ${integrationOid.slice(0, 8)}`,
612
+ };
613
+ }
614
+
615
+ const status = execFileSync(
616
+ 'git',
617
+ ['--no-optional-locks', 'status', '--porcelain=v1', '-z', '--untracked-files=all'],
618
+ { cwd: root, stdio: 'pipe' },
619
+ );
620
+ if (status.length) {
621
+ return { ok: false, reason: 'the invoking worktree has tracked, staged or non-ignored untracked changes' };
622
+ }
623
+ return { ok: true };
624
+ } catch {
625
+ return { ok: false, reason: 'the invoking worktree state could not be verified' };
626
+ }
627
+ }
628
+
208
629
  export function land(root: string, branch: string, runner: LandRunner, dryRun = false): Result {
209
630
  const { integration, greenRef, integPrefix } = loopParams(root);
210
631
  const lines: string[] = [];
@@ -212,9 +633,35 @@ export function land(root: string, branch: string, runner: LandRunner, dryRun =
212
633
  if (!branch) return { ok: false, lines: ['a branch name is required: `rungs land <branch>`'] };
213
634
  const head = revParse(root, `refs/heads/${branch}`);
214
635
  if (!head) return { ok: false, lines: [`branch '${branch}' does not exist`] };
215
- const before = revParse(root, `refs/heads/${integration}`);
636
+ const preferredParked = `${integPrefix}${branch}`;
637
+ const initialManaged = managedRefsState(root, integration, greenRef);
638
+ if (!initialManaged.value) return { ok: false, lines: [initialManaged.error] };
639
+ const { integration: initialIntegration, green: initialGreen } = initialManaged.value;
640
+ if (initialIntegration.ref === initialGreen.ref) {
641
+ return {
642
+ ok: false,
643
+ lines: [`configured integration branch '${integration}' and green ref '${greenRef}' resolve to the same direct ref; land requires two distinct managed refs.`],
644
+ };
645
+ }
646
+ const before = initialIntegration.oid;
216
647
  if (!before) return { ok: false, lines: [`'${integration}' does not resolve`] };
217
648
 
649
+ // ADR-0009 rule 3 is a mutation precondition, not merely a gate installed in
650
+ // some consumers. Keep this before even inspecting the coordination lock: a
651
+ // known-invalid land must not replace a stale lock or create any artifact.
652
+ const heldManagedRef = [initialIntegration, initialGreen].find((ref) => ref.holders.length);
653
+ if (heldManagedRef) {
654
+ const name = heldManagedRef.ref.slice('refs/heads/'.length);
655
+ return {
656
+ ok: false,
657
+ lines: [
658
+ `'${name}' is checked out in ${heldManagedRef.holders.length} worktree(s), so land is refused:`,
659
+ ...heldManagedRef.holders.map((path) => ` ${path}`),
660
+ 'Switch each listed worktree to another branch or detach it (`git switch --detach`), then retry.',
661
+ ],
662
+ };
663
+ }
664
+
218
665
  // A real lock: it names its holder and start time, and is taken over if that
219
666
  // holder is gone. A lock nobody can break is a lock somebody deletes.
220
667
  const lp = lockPath(root);
@@ -234,14 +681,14 @@ export function land(root: string, branch: string, runner: LandRunner, dryRun =
234
681
  }
235
682
  }
236
683
  if (dryRun) {
237
- lines.push(`would merge ${branch} (${head.slice(0, 8)}) onto ${integration} (${before.slice(0, 8)}) via ${integPrefix}${branch}, verify, then advance.`);
684
+ lines.push(`would merge ${branch} (${head.slice(0, 8)}) onto ${integration} (${before.slice(0, 8)}) via ${preferredParked}, verify, then atomically advance ${integration} and ${greenRef}.`);
238
685
  return { ok: true, lines };
239
686
  }
240
687
 
241
688
  const lock: Lock = { pid: process.pid, host: hostname(), started: new Date().toISOString(), branch };
242
689
  writeFileSync(lp, JSON.stringify(lock));
243
690
  const scratch = mkdtempSync(join(tmpdir(), 'rungs-land-'));
244
- const parked = `${integPrefix}${branch}`;
691
+ let preserveScratch = false;
245
692
 
246
693
  try {
247
694
  // Rule 3: a throwaway worktree, detached. The integration branch is never
@@ -269,6 +716,33 @@ export function land(root: string, branch: string, runner: LandRunner, dryRun =
269
716
  const res = runner(scratch);
270
717
  lines.push(`merged tree ${merged.slice(0, 8)} — ${res.pass} pass · ${res.failing.length} fail`);
271
718
 
719
+ const refuseWithRecovery = (details: string[], guidance: string): Result => {
720
+ const recovery = parkVerifiedMerge(
721
+ root,
722
+ preferredParked,
723
+ merged,
724
+ new Set([integration, greenRef, branch]),
725
+ );
726
+ if (!recovery.name) {
727
+ // Last-resort preservation: an unenumerable or completely blocked ref
728
+ // namespace must not turn refusal into data loss. The detached scratch
729
+ // stays registered until the operator resolves the condition.
730
+ git(scratch, ['reset', '--hard', merged]);
731
+ preserveScratch = true;
732
+ return {
733
+ ok: false,
734
+ lines: [...lines, ...details,
735
+ `The verified merge ${merged} could not be parked safely: ${recovery.error}`,
736
+ `Its detached scratch worktree is retained at ${scratch}. Resolve the ref/worktree state, create a recovery branch at that exact commit, then remove the scratch explicitly.`],
737
+ };
738
+ }
739
+ return {
740
+ ok: false,
741
+ lines: [...lines, ...details,
742
+ `Your verified merge is parked on '${recovery.name}'. ${guidance}`],
743
+ };
744
+ };
745
+
272
746
  if (res.failing.length) {
273
747
  // **Attribution.** A gate that is red for reasons you did not cause and
274
748
  // cannot fix is a gate you learn to bypass, and a bypassed gate reports
@@ -290,41 +764,116 @@ export function land(root: string, branch: string, runner: LandRunner, dryRun =
290
764
 
291
765
  // A gate that did not run at the base cannot be attributed at all. We do
292
766
  // not land on an unknown, so that blocks.
293
- const attributable = base !== null && base.failing.length + base.pass >= ids.size;
294
- const baseFindings = new Map((base?.failing ?? []).map((f) => [f.id, new Set(f.findings)]));
767
+ const baseAttributable = base !== null && coversGateIds(base, ids);
768
+
769
+ // The same detached scratch supplies both sides of the branch comparison.
770
+ // That controls content, not runtime: ignored dependencies can be absent
771
+ // from both and manufacture an identical failure. A clean detached
772
+ // invoking checkout at the exact integration OID is the independent
773
+ // control that can establish whether the base is genuinely red.
774
+ let control: GateOutcome | null = null;
775
+ let controlIssue = '';
776
+ const eligibleBefore = exactControlEligibility(root, before);
777
+ if (!eligibleBefore.ok) {
778
+ controlIssue = eligibleBefore.reason;
779
+ } else {
780
+ try {
781
+ control = runner(root, ids);
782
+ const eligibleAfter = exactControlEligibility(root, before);
783
+ if (!eligibleAfter.ok) {
784
+ control = null;
785
+ controlIssue = `the invoking control changed while gates ran: ${eligibleAfter.reason}`;
786
+ }
787
+ } catch {
788
+ control = null;
789
+ controlIssue = 'the exact integration control could not be gated';
790
+ }
791
+ }
792
+ if (control && !coversGateIds(control, ids)) {
793
+ control = null;
794
+ controlIssue = 'some failing gates did not run in the exact integration control';
795
+ }
295
796
 
296
- const introduced: { id: string; findings: string[] }[] = [];
297
- const inherited: { id: string; findings: string[] }[] = [];
797
+ const controlAttributable = control !== null;
798
+ const baseFindings = base ? failingIdentities(base) : new Map<string, Set<string>>();
799
+ const controlFindings = control ? failingIdentities(control) : new Map<string, Set<string>>();
800
+
801
+ const introduced: GateFailure[] = [];
802
+ const inherited: GateFailure[] = [];
803
+ const unverified: GateFailure[] = [];
804
+ const addFinding = (target: GateFailure[], id: string, finding: GateFailureFinding) => {
805
+ let failure = target.find((entry) => entry.id === id);
806
+ if (!failure) {
807
+ failure = { id, findings: [] };
808
+ target.push(failure);
809
+ }
810
+ failure.findings.push(finding);
811
+ };
298
812
  for (const f of res.failing) {
299
- const seen = attributable ? baseFindings.get(f.id) ?? new Set<string>() : null;
300
- // Per finding: a gate already red at the base does not excuse the new
301
- // violations of it that this branch brought.
302
- const fresh = seen ? f.findings.filter((x) => !seen.has(x)) : f.findings;
303
- if (fresh.length) introduced.push({ id: f.id, findings: fresh });
304
- else inherited.push(f);
813
+ const atBase = baseAttributable ? baseFindings.get(f.id) ?? new Set<string>() : null;
814
+ const inControl = controlAttributable ? controlFindings.get(f.id) ?? new Set<string>() : null;
815
+ for (const finding of f.findings) {
816
+ const identity = findingIdentity(finding);
817
+ // Per finding: a gate already red at the base does not excuse a new
818
+ // violation. A base match becomes inherited only with independent
819
+ // confirmation from the exact integration control.
820
+ if (!atBase || !atBase.has(identity)) {
821
+ addFinding(introduced, f.id, finding);
822
+ } else if (!inControl || !inControl.has(identity)) {
823
+ addFinding(unverified, f.id, finding);
824
+ } else {
825
+ addFinding(inherited, f.id, finding);
826
+ }
827
+ }
305
828
  }
306
829
 
307
830
  for (const f of inherited) {
308
- lines.push(` inherited ${f.id}${f.findings[0] ? ` — ${f.findings[0]}` : ''}`);
831
+ lines.push(` inherited ${f.id}${f.findings[0] ? ` — ${findingDiagnostic(f.findings[0])}` : ''}`);
309
832
  }
310
833
  for (const f of introduced) {
311
- lines.push(` INTRODUCED ${f.id}${f.findings[0] ? ` — ${f.findings[0]}` : ''}`);
312
- for (const extra of f.findings.slice(1, 4)) lines.push(` ${extra}`);
834
+ lines.push(` INTRODUCED ${f.id}${f.findings[0] ? ` — ${findingDiagnostic(f.findings[0])}` : ''}`);
835
+ for (const extra of f.findings.slice(1, 4)) lines.push(` ${findingDiagnostic(extra)}`);
313
836
  }
314
837
  if (base === null) {
315
838
  lines.push(' The merge base could not be gated, so nothing here is attributable and all of it blocks.');
316
- } else if (!attributable) {
839
+ } else if (!baseAttributable) {
317
840
  lines.push(' Some gates could not be attributed against the merge base, so they block. We do not land on an unknown.');
318
841
  }
319
842
 
843
+ if (controlIssue) {
844
+ lines.push(` CONTROL UNAVAILABLE — ${controlIssue}; inherited failure cannot be established.`);
845
+ }
846
+ for (const f of unverified) {
847
+ if (controlAttributable) {
848
+ const mismatch = (controlFindings.get(f.id)?.size ?? 0) > 0
849
+ ? 'the detached base scratch and exact integration control reported different findings'
850
+ : 'the exact integration control passed while the detached base scratch failed';
851
+ lines.push(` UNVERIFIED ${f.id} — ${mismatch}; the scratch environment cannot establish inheritance.`);
852
+ if (f.findings[0]) {
853
+ lines.push(` ${findingDiagnostic(f.findings[0]).replace(/\n/g, '\n ')}`);
854
+ }
855
+ } else {
856
+ lines.push(` UNVERIFIED ${f.id}${f.findings[0] ? ` — ${findingDiagnostic(f.findings[0])}` : ''}`);
857
+ }
858
+ }
859
+
320
860
  if (introduced.length) {
321
861
  // Rule 2: park it, do not discard it. The merge is the expensive part
322
862
  // and throwing it away means doing it again to see the same failure.
323
- git(root, ['update-ref', `refs/heads/${parked}`, merged]);
324
- lines.push(
325
- `${introduced.length} introduced by this branch. ${integration} is unchanged, and the merged tree is parked on '${parked}' — fix it there and land again.`,
863
+ return refuseWithRecovery(
864
+ [`${introduced.length} introduced by this branch. ${integration} and ${greenRef} were not advanced.`],
865
+ 'Fix it there and land again; recovery-ref cleanup remains operator-owned.',
866
+ );
867
+ }
868
+
869
+ if (unverified.length || !baseAttributable || !controlAttributable) {
870
+ const unverifiedCount = unverified.reduce((total, failure) => total + failure.findings.length, 0);
871
+ return refuseWithRecovery(
872
+ [
873
+ `${unverifiedCount || res.failing.length} failure(s) could not be verified as inherited. ${integration} and ${greenRef} were not advanced.`,
874
+ ],
875
+ `Run land from a clean detached checkout at ${integration} (${before.slice(0, 8)}) with the gate runtime available, then retry.`,
326
876
  );
327
- return { ok: false, lines };
328
877
  }
329
878
 
330
879
  lines.push(
@@ -335,34 +884,71 @@ export function land(root: string, branch: string, runner: LandRunner, dryRun =
335
884
  git(scratch, ['reset', '--hard', merged]);
336
885
  }
337
886
 
338
- // Rule 1: compare-and-swap. If someone else advanced the branch while we
339
- // verified, this fails and nothing is lost their merge is not overwritten.
887
+ // The runner is arbitrary repository code. Re-establish every managed-ref
888
+ // identity and holder fact at the mutation boundary, not just integration.
889
+ const lateManaged = managedRefsState(root, integration, greenRef);
890
+ if (!lateManaged.value) {
891
+ return refuseWithRecovery(
892
+ [
893
+ 'managed-ref identity and checkout state could not be revalidated after verification, so the atomic advance is refused.',
894
+ ` ${lateManaged.error}`,
895
+ ],
896
+ `Re-run \`rungs land ${branch}\` after ref identity and checkout state can be verified.`,
897
+ );
898
+ }
899
+ const { integration: lateIntegration, green: lateGreen } = lateManaged.value;
900
+ const lateHolder = [lateIntegration, lateGreen].find((ref) => ref.holders.length);
901
+ if (lateHolder) {
902
+ const name = lateHolder.ref.slice('refs/heads/'.length);
903
+ return refuseWithRecovery(
904
+ [
905
+ `'${name}' became checked out in ${lateHolder.holders.length} worktree(s) while this land was verifying, so the atomic ref advance is refused:`,
906
+ ...lateHolder.holders.map((path) => ` ${path}`),
907
+ 'Switch each listed worktree to another branch or detach it (`git switch --detach`), then retry.',
908
+ ],
909
+ `Re-run \`rungs land ${branch}\` to rebuild the merge after releasing the branch.`,
910
+ );
911
+ }
912
+ if (lateIntegration.oid !== initialIntegration.oid || lateGreen.oid !== initialGreen.oid) {
913
+ const moved = [
914
+ ...(lateIntegration.oid !== initialIntegration.oid ? [integration] : []),
915
+ ...(lateGreen.oid !== initialGreen.oid ? [greenRef] : []),
916
+ ];
917
+ return refuseWithRecovery(
918
+ [`${moved.join(' and ')} moved while this land was verifying, so the atomic advance was refused rather than overwriting concurrent work.`],
919
+ `Re-run \`rungs land ${branch}\` to rebuild the merge on the new managed-ref state.`,
920
+ );
921
+ }
922
+
923
+ // Rule 1: one compare-and-swap transaction. If either expected-old OID
924
+ // loses, Git commits neither update. `--no-deref` prevents a last-instant
925
+ // symref swap from redirecting a write into its target; Git cannot CAS the
926
+ // direct-vs-symbolic type itself, so that raw-Git micro-race is the explicit
927
+ // residual boundary documented by the module and WI-079.
340
928
  try {
341
- git(root, ['update-ref', `refs/heads/${integration}`, merged, before]);
929
+ advanceVerifiedRefs(root, initialIntegration, initialGreen, merged);
342
930
  } catch {
343
- git(root, ['update-ref', `refs/heads/${parked}`, merged]);
344
- return {
345
- ok: false,
346
- lines: [...lines,
347
- `${integration} moved while this land was verifying, so the advance was refused rather than overwriting it.`,
348
- `Your verified merge is parked on '${parked}'. Re-run \`rungs land ${branch}\` to rebuild it on the new tip.`],
349
- };
931
+ return refuseWithRecovery(
932
+ [`${integration} or ${greenRef} moved, became symbolic, or could not be locked while this land was verifying. The atomic managed-ref transaction was refused, so Rungs did not partially update either ref.`],
933
+ `Re-run \`rungs land ${branch}\` to rebuild the merge after inspecting the competing ref state.`,
934
+ );
350
935
  }
351
- git(root, ['update-ref', `refs/heads/${greenRef}`, merged]);
352
- lines.push(`${integration} ${merged.slice(0, 8)}, and ${greenRef} now marks it verified.`);
353
- if (revParse(root, `refs/heads/${parked}`)) git(root, ['update-ref', '-d', `refs/heads/${parked}`]);
936
+ lines.push(`${integration} and ${greenRef} → ${merged.slice(0, 8)} in one atomic verified-ref transaction.`);
937
+ lines.push('Existing recovery refs are retained; cleanup remains an explicit operator decision.');
354
938
  return { ok: true, lines };
355
939
  } finally {
356
940
  // The scratch worktree is ours and only ours, so removing it is not rule 2's
357
941
  // "never destroy" — that is about the operator's branches and worktrees.
358
- try {
359
- git(root, ['worktree', 'remove', '--force', scratch]);
360
- } catch {
361
- rmSync(scratch, { recursive: true, force: true });
942
+ if (!preserveScratch) {
362
943
  try {
363
- git(root, ['worktree', 'prune']);
944
+ git(root, ['worktree', 'remove', '--force', scratch]);
364
945
  } catch {
365
- /* leaving a stale worktree record is not worth failing a successful land */
946
+ rmSync(scratch, { recursive: true, force: true });
947
+ try {
948
+ git(root, ['worktree', 'prune']);
949
+ } catch {
950
+ /* leaving a stale worktree record is not worth failing a successful land */
951
+ }
366
952
  }
367
953
  }
368
954
  try {