mandrel 1.76.0 → 1.78.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 (68) hide show
  1. package/.agents/docs/configuration.md +2 -2
  2. package/.agents/docs/workflows.md +19 -0
  3. package/.agents/schemas/agentrc.schema.json +1 -1
  4. package/.agents/schemas/dispatch-manifest.json +1 -1
  5. package/.agents/schemas/lifecycle/loop.tick.schema.json +20 -0
  6. package/.agents/schemas/loop-unit.schema.json +70 -0
  7. package/.agents/schemas/validation-evidence.schema.json +2 -1
  8. package/.agents/scripts/audit-to-stories.js +43 -1
  9. package/.agents/scripts/check-doc-links.js +24 -1
  10. package/.agents/scripts/check-loop-units.js +204 -0
  11. package/.agents/scripts/epic-deliver-prepare.js +31 -0
  12. package/.agents/scripts/evidence-gate.js +48 -12
  13. package/.agents/scripts/generate-workflows-doc.js +37 -4
  14. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +141 -34
  15. package/.agents/scripts/lib/cli-args.js +6 -0
  16. package/.agents/scripts/lib/close-validation/process.js +61 -5
  17. package/.agents/scripts/lib/close-validation/runner.js +42 -9
  18. package/.agents/scripts/lib/config/temp-paths.js +1 -1
  19. package/.agents/scripts/lib/config/worktree-isolation.js +18 -3
  20. package/.agents/scripts/lib/config-resolver.js +4 -1
  21. package/.agents/scripts/lib/config-settings-schema-delivery.js +1 -1
  22. package/.agents/scripts/lib/git-branch-lifecycle.js +90 -0
  23. package/.agents/scripts/lib/loop-units/validate-loop-unit.js +197 -0
  24. package/.agents/scripts/lib/mandrel-catalog.js +36 -0
  25. package/.agents/scripts/lib/orchestration/auto-merge-cwd.js +128 -0
  26. package/.agents/scripts/lib/orchestration/column-sync.js +88 -9
  27. package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +183 -0
  28. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +20 -2
  29. package/.agents/scripts/lib/orchestration/project-meta-cache.js +238 -0
  30. package/.agents/scripts/lib/orchestration/reassert-status-column.js +3 -1
  31. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +25 -2
  32. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +80 -14
  33. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +74 -25
  34. package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +10 -1
  35. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +48 -1
  36. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +148 -4
  37. package/.agents/scripts/lib/orchestration/ticketing/transition.js +8 -1
  38. package/.agents/scripts/lib/story-body/story-body.js +76 -7
  39. package/.agents/scripts/lib/story-init/branch-initializer.js +29 -43
  40. package/.agents/scripts/lib/story-init/hierarchy-tracer.js +25 -4
  41. package/.agents/scripts/lib/story-init/task-graph-builder.js +22 -12
  42. package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -0
  43. package/.agents/scripts/lib/validation-evidence.js +63 -25
  44. package/.agents/scripts/lib/worktree/node-modules-strategy.js +239 -31
  45. package/.agents/scripts/providers/github/branch-protection.js +1 -1
  46. package/.agents/scripts/providers/github/errors.js +53 -2
  47. package/.agents/scripts/providers/github/labels.js +1 -1
  48. package/.agents/scripts/providers/github/projects-v2-graphql.js +1 -1
  49. package/.agents/scripts/resync-status-column.js +5 -0
  50. package/.agents/scripts/run-coverage.js +85 -45
  51. package/.agents/scripts/run-lint.js +11 -0
  52. package/.agents/scripts/single-story-init.js +22 -29
  53. package/.agents/scripts/story-init.js +38 -63
  54. package/.agents/scripts/story-phase.js +46 -4
  55. package/.agents/scripts/sync-claude-commands.js +112 -29
  56. package/.agents/scripts/update-maintainability-baseline.js +19 -76
  57. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  58. package/.agents/workflows/helpers/acceptance-self-eval.md +27 -0
  59. package/.agents/workflows/helpers/deliver-epic.md +19 -2
  60. package/.agents/workflows/helpers/epic-deliver-story.md +50 -14
  61. package/.agents/workflows/helpers/single-story-deliver.md +12 -0
  62. package/.agents/workflows/loops/README.md +65 -0
  63. package/.agents/workflows/loops/fix-failing-tests.md +74 -0
  64. package/.agents/workflows/loops/nightly-audit.md +71 -0
  65. package/.agents/workflows/loops/watch-ci.md +68 -0
  66. package/docs/CHANGELOG.md +51 -0
  67. package/package.json +1 -1
  68. package/.agents/scripts/providers/github/transient-retry.js +0 -62
@@ -35,6 +35,11 @@
35
35
  */
36
36
 
37
37
  import { AGENT_LABELS } from '../label-constants.js';
38
+ import {
39
+ invalidateProjectMetaCache,
40
+ readProjectMetaCache,
41
+ writeProjectMetaCache,
42
+ } from './project-meta-cache.js';
38
43
  import { resolveProjectMeta } from './project-meta-resolver.js';
39
44
 
40
45
  export const LABEL_TO_COLUMN = Object.freeze({
@@ -74,6 +79,7 @@ export class ColumnSync {
74
79
  * projectNumber?: number | null,
75
80
  * projectOwner?: string | null,
76
81
  * logger?: { info: Function, warn: Function },
82
+ * config?: object,
77
83
  * ctx?: { provider?: object, config?: { github?: { projectNumber?: number|null } }, logger?: object },
78
84
  * }} opts
79
85
  */
@@ -89,7 +95,16 @@ export class ColumnSync {
89
95
  null;
90
96
  this.projectOwner = opts.projectOwner ?? provider.projectOwner ?? null;
91
97
  this.logger = opts.logger ?? ctx?.logger ?? console;
98
+ // Resolved config bag used to locate the on-disk meta cache's tempRoot.
99
+ // Optional — when omitted, the cache resolves the framework-default
100
+ // `temp` root (Story #4252).
101
+ this.config = opts.config ?? ctx?.config ?? undefined;
92
102
  this._meta = null; // lazy-cached { projectId, fieldId, options: Map<name, id> }
103
+ // Records whether the in-process `_meta` was hydrated from the on-disk
104
+ // cache, so a GraphQL error against possibly-stale cached metadata can
105
+ // invalidate the disk entry and force a fresh resolve on the next flip
106
+ // (Story #4252).
107
+ this._metaFromDiskCache = false;
93
108
  }
94
109
 
95
110
  /**
@@ -117,8 +132,9 @@ export class ColumnSync {
117
132
  const itemId = await this.#getProjectItemId(issueId, meta.projectId);
118
133
  if (!itemId) return { status: 'skipped', reason: 'not-on-project' };
119
134
 
120
- await this.provider.graphql(
121
- `
135
+ try {
136
+ await this.provider.graphql(
137
+ `
122
138
  mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
123
139
  updateProjectV2ItemFieldValue(
124
140
  input: {
@@ -129,18 +145,71 @@ export class ColumnSync {
129
145
  }
130
146
  ) { projectV2Item { id } }
131
147
  }`,
132
- {
133
- projectId: meta.projectId,
134
- itemId,
135
- fieldId: meta.fieldId,
136
- optionId,
137
- },
138
- );
148
+ {
149
+ projectId: meta.projectId,
150
+ itemId,
151
+ fieldId: meta.fieldId,
152
+ optionId,
153
+ },
154
+ );
155
+ } catch (err) {
156
+ // A failed mutation against metadata that came from the disk cache
157
+ // most likely means the board was reconfigured since the entry was
158
+ // written (a stale projectId / fieldId / optionId). Invalidate the
159
+ // disk entry so the next flip re-resolves against the live board and
160
+ // self-heals (Story #4252). Re-throw so the caller's existing error
161
+ // handling (e.g. `syncProjectStatusColumn`'s warn) is preserved.
162
+ this.#invalidateMetaCache();
163
+ throw err;
164
+ }
139
165
  return { status: 'synced', column };
140
166
  }
141
167
 
168
+ /**
169
+ * The `(owner, projectNumber)` pair the disk cache is keyed by. Mirrors
170
+ * the owner that `#loadMeta` resolves the board against so a cache hit and
171
+ * a live resolve agree on the same board identity.
172
+ *
173
+ * @returns {{ owner: string|null, projectNumber: number|null }}
174
+ */
175
+ get #cacheBoard() {
176
+ return {
177
+ owner: this.projectOwner ?? this.provider.owner ?? null,
178
+ projectNumber: this.projectNumber,
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Invalidate the on-disk metadata cache entry for this board and drop the
184
+ * in-process copy, so the next `#loadMeta` re-resolves from the live
185
+ * board. Only fires when the current `_meta` came from the disk cache —
186
+ * a freshly-resolved entry that fails the mutation is a transient/live
187
+ * problem, not a stale-cache problem.
188
+ */
189
+ #invalidateMetaCache() {
190
+ if (!this._metaFromDiskCache) return;
191
+ const { owner, projectNumber } = this.#cacheBoard;
192
+ invalidateProjectMetaCache({ owner, projectNumber, config: this.config });
193
+ this._meta = null;
194
+ this._metaFromDiskCache = false;
195
+ }
196
+
142
197
  async #loadMeta() {
143
198
  if (this._meta !== null) return this._meta || null;
199
+ // Disk cache hit short-circuits the ~2 metadata GraphQL round-trips
200
+ // (resolveProjectMeta) — repo-invariant board metadata persists across
201
+ // the cold CLI processes of a single-story delivery (Story #4252).
202
+ const cachedBoard = this.#cacheBoard;
203
+ const cached = readProjectMetaCache({
204
+ owner: cachedBoard.owner,
205
+ projectNumber: cachedBoard.projectNumber,
206
+ config: this.config,
207
+ });
208
+ if (cached) {
209
+ this._meta = cached;
210
+ this._metaFromDiskCache = true;
211
+ return this._meta;
212
+ }
144
213
  try {
145
214
  // Resolve the board by walking the owner-type ladder
146
215
  // (organization → user → viewer) via the shared resolver so the
@@ -176,6 +245,16 @@ export class ColumnSync {
176
245
  fieldId: field.id,
177
246
  options,
178
247
  };
248
+ // Persist the freshly-resolved, repo-invariant metadata so the next
249
+ // cold flip reads it from disk instead of re-paying the resolve
250
+ // (Story #4252). Best-effort: a write failure never blocks the sync.
251
+ writeProjectMetaCache({
252
+ owner: cachedBoard.owner,
253
+ projectNumber: cachedBoard.projectNumber,
254
+ meta: this._meta,
255
+ config: this.config,
256
+ });
257
+ this._metaFromDiskCache = false;
179
258
  return this._meta;
180
259
  } catch (err) {
181
260
  this.logger.warn?.(
@@ -0,0 +1,183 @@
1
+ /**
2
+ * emit-loop-tick.js — Story #4287 (Epic #4284).
3
+ *
4
+ * Programmatic helper that emits a single `loop.tick` lifecycle event
5
+ * THROUGH the lifecycle bus so a host-driven loop (e.g. a `/loop`-style
6
+ * recurring command or a long-running poll) lands a per-pass record in
7
+ * the on-disk ledger the `/deliver` idle watchdog already scans. The
8
+ * record is what keeps a host loop from running silently: each round
9
+ * appends an inspectable `emitted` line a reconciler can read for
10
+ * forward-progress evidence.
11
+ *
12
+ * Distinct from `story.heartbeat` (emit-story-heartbeat.js): the
13
+ * heartbeat carries Story-phase info for a single in-flight Story and is
14
+ * always Epic-scoped (its ledger path is `epicLedgerPath(epicId)`). A
15
+ * host loop is not bound to a Story tier, so `loop.tick` carries a
16
+ * free-form `loopName`, a monotonic `round` counter, the loop's
17
+ * configured `cadence` label, and a per-round `status` instead. Keeping
18
+ * the two events separate means a loop tick never masquerades as Story
19
+ * progress (and vice versa).
20
+ *
21
+ * Bus path (Story acceptance: "Emitting a loop.tick event THROUGH the
22
+ * lifecycle bus appends a record to the per-run ledger"): this helper
23
+ * constructs a `Bus`, registers a `LedgerWriter` against it, and calls
24
+ * `bus.emit('loop.tick', payload)`. The bus validates the payload against
25
+ * `loop.tick.schema.json` before any listener runs, and the
26
+ * LedgerWriter's privileged `onEmitted` hook lands the `emitted` record
27
+ * on disk — exactly the same persistence path every other lifecycle
28
+ * event flows through. The helper does NOT bypass the bus with a direct
29
+ * `appendFileSync`; routing through the bus is what gives the record its
30
+ * schema-validated, seqId-stamped guarantee.
31
+ *
32
+ * Schema contract (loop.tick.schema.json):
33
+ * { event, loopName, round, cadence, status, timestamp }
34
+ *
35
+ * The schema declares `additionalProperties: false`, so this emitter's
36
+ * signature is deliberately narrow: only the schema-allowed fields are
37
+ * accepted. `status` is one of running|done|blocked.
38
+ *
39
+ * Ledger path resolution: a caller supplies EITHER an explicit
40
+ * `ledgerPath` (the host-loop case — the loop owns where its ledger
41
+ * lives) OR an `epicId`, in which case the canonical
42
+ * `epicLedgerPath(epicId)` is used so an Epic-scoped loop's ticks land
43
+ * in the same `temp/epic-<id>/lifecycle.ndjson` the rest of the run
44
+ * reads. Exactly one of the two MUST be supplied.
45
+ */
46
+
47
+ import path from 'node:path';
48
+ import { fileURLToPath } from 'node:url';
49
+
50
+ import { epicLedgerPath } from '../../config/temp-paths.js';
51
+ import { createBus } from './bus.js';
52
+ import { createLedgerWriter } from './ledger-writer.js';
53
+
54
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
55
+ const SCHEMA_DIR = path.resolve(
56
+ __dirname,
57
+ '..',
58
+ '..',
59
+ '..',
60
+ '..',
61
+ 'schemas',
62
+ 'lifecycle',
63
+ );
64
+
65
+ const VALID_STATUSES = new Set(['running', 'done', 'blocked']);
66
+
67
+ /**
68
+ * Parse `temp/epic-<id>/lifecycle.ndjson` (or any
69
+ * `<dir>/epic-<id>/lifecycle.ndjson`) back into `{ tempRoot, epicId }`
70
+ * so a LedgerWriter — which is constructed from `{ epicId, tempRoot }`
71
+ * rather than a raw path — can be bound to the supplied ledger path.
72
+ *
73
+ * The LedgerWriter intentionally re-derives the ledger path from its
74
+ * `tempRoot` + `epicId` (so it can recreate the directory if a listener
75
+ * moves it mid-run), so we decompose the path the caller gave us into
76
+ * those two parts here.
77
+ *
78
+ * @param {string} ledgerPath
79
+ * @returns {{ tempRoot: string, epicId: number }}
80
+ */
81
+ function decomposeLedgerPath(ledgerPath) {
82
+ const epicDir = path.dirname(ledgerPath);
83
+ const tempRoot = path.dirname(epicDir);
84
+ const epicDirName = path.basename(epicDir);
85
+ const m = /^epic-(\d+)$/.exec(epicDirName);
86
+ if (!m) {
87
+ throw new Error(
88
+ `emitLoopTick: ledgerPath does not match <tempRoot>/epic-<id>/lifecycle.ndjson layout (got ${ledgerPath})`,
89
+ );
90
+ }
91
+ const epicId = Number.parseInt(m[1], 10);
92
+ return { tempRoot, epicId };
93
+ }
94
+
95
+ /**
96
+ * Emit exactly one `loop.tick` event through the lifecycle bus, landing
97
+ * an `emitted` (and `completed`) NDJSON record in the resolved ledger.
98
+ *
99
+ * @param {object} opts
100
+ * @param {string} opts.loopName Free-form loop identifier (non-empty).
101
+ * @param {number} opts.round Monotonic pass counter (integer >= 0).
102
+ * @param {string} opts.cadence Configured interval label, e.g. '5m'.
103
+ * @param {string} [opts.status='running']
104
+ * One of running|done|blocked.
105
+ * @param {string} [opts.timestamp] ISO-8601 wall clock. Defaults to now().
106
+ * @param {number} [opts.epicId] When supplied (and no `ledgerPath`),
107
+ * the canonical `epicLedgerPath(epicId)`
108
+ * is used for the ledger.
109
+ * @param {object} [opts.config] Optional resolved config for tempRoot
110
+ * (only consulted on the `epicId` path).
111
+ * @param {string} [opts.ledgerPath] Explicit ledger path (host-loop case).
112
+ * Mutually exclusive with `epicId`.
113
+ * @returns {Promise<{ ledgerPath: string, payload: object, seqId: number }>}
114
+ */
115
+ export async function emitLoopTick(opts) {
116
+ const {
117
+ loopName,
118
+ round,
119
+ cadence,
120
+ status = 'running',
121
+ timestamp = new Date().toISOString(),
122
+ epicId,
123
+ config,
124
+ ledgerPath: ledgerPathOverride,
125
+ } = opts ?? {};
126
+
127
+ if (typeof loopName !== 'string' || loopName.length === 0) {
128
+ throw new Error('emitLoopTick: loopName must be a non-empty string');
129
+ }
130
+ if (!Number.isInteger(round) || round < 0) {
131
+ throw new Error('emitLoopTick: round must be a non-negative integer');
132
+ }
133
+ if (typeof cadence !== 'string' || cadence.length === 0) {
134
+ throw new Error('emitLoopTick: cadence must be a non-empty string');
135
+ }
136
+ if (!VALID_STATUSES.has(status)) {
137
+ throw new Error(
138
+ `emitLoopTick: status "${status}" must be one of: ${[...VALID_STATUSES].join(', ')}`,
139
+ );
140
+ }
141
+
142
+ const hasEpicId = epicId !== undefined;
143
+ const hasLedgerPath = ledgerPathOverride !== undefined;
144
+ if (hasEpicId === hasLedgerPath) {
145
+ throw new Error('emitLoopTick: supply exactly one of epicId or ledgerPath');
146
+ }
147
+
148
+ let ledgerPath;
149
+ if (hasLedgerPath) {
150
+ if (
151
+ typeof ledgerPathOverride !== 'string' ||
152
+ ledgerPathOverride.length === 0
153
+ ) {
154
+ throw new Error('emitLoopTick: ledgerPath must be a non-empty string');
155
+ }
156
+ ledgerPath = ledgerPathOverride;
157
+ } else {
158
+ if (!Number.isInteger(epicId) || epicId < 1) {
159
+ throw new Error('emitLoopTick: epicId must be a positive integer');
160
+ }
161
+ ledgerPath = epicLedgerPath(epicId, config);
162
+ }
163
+
164
+ const payload = {
165
+ event: 'loop.tick',
166
+ loopName,
167
+ round,
168
+ cadence,
169
+ status,
170
+ timestamp,
171
+ };
172
+
173
+ // Route through the bus so the payload is schema-validated and the
174
+ // LedgerWriter's privileged onEmitted hook persists the record — the
175
+ // same path every lifecycle event flows through.
176
+ const { tempRoot, epicId: ledgerEpicId } = decomposeLedgerPath(ledgerPath);
177
+ const bus = createBus({ schemaDir: SCHEMA_DIR });
178
+ const writer = createLedgerWriter({ epicId: ledgerEpicId, tempRoot });
179
+ writer.register(bus);
180
+
181
+ const { seqId } = await bus.emit('loop.tick', payload);
182
+ return { ledgerPath: writer.ledgerPath, payload, seqId };
183
+ }
@@ -54,6 +54,8 @@
54
54
 
55
55
  import { spawnSync } from 'node:child_process';
56
56
 
57
+ import { resolveAutoMergeArmCwd } from '../../auto-merge-cwd.js';
58
+
57
59
  /**
58
60
  * Default `gh pr view --json autoMergeRequest` probe. Pure-spawn helper
59
61
  * — exported so tests can stub the shell-out without touching the
@@ -77,12 +79,28 @@ export function ghPrViewAutoMerge({ prUrl, cwd, spawnFn = spawnSync }) {
77
79
  * helper. Exported so tests can stub. The arg list is captured in a
78
80
  * single helper so the merge-lockout lint allow-list narrows to one
79
81
  * literal site.
82
+ *
83
+ * Story #4282: `--delete-branch` makes `gh` shell out to local `git`
84
+ * (including a `git checkout <base>`). When this arm runs from a per-Story
85
+ * worktree cwd checked out on the head branch while the base branch is
86
+ * occupied by the primary worktree, that checkout collides
87
+ * (`fatal: '<base>' is already used by worktree`). We re-point the spawn
88
+ * cwd at the primary worktree root (which holds the base branch) via
89
+ * `resolveAutoMergeArmCwd`, so the local checkout is a no-op while
90
+ * `--delete-branch` (head-branch-removed-on-merge) is preserved. The
91
+ * resolver is non-fatal — it degrades to the original cwd.
80
92
  */
81
- export function ghPrMergeAuto({ prUrl, cwd, spawnFn = spawnSync }) {
93
+ export function ghPrMergeAuto({
94
+ prUrl,
95
+ cwd,
96
+ spawnFn = spawnSync,
97
+ resolveArmCwd = resolveAutoMergeArmCwd,
98
+ }) {
99
+ const armCwd = resolveArmCwd(cwd);
82
100
  const result = spawnFn(
83
101
  'gh',
84
102
  ['pr', 'merge', prUrl, '--auto', '--squash', '--delete-branch'],
85
- { cwd, encoding: 'utf-8', shell: false },
103
+ { cwd: armCwd, encoding: 'utf-8', shell: false },
86
104
  );
87
105
  return {
88
106
  status: result.status ?? 1,
@@ -0,0 +1,238 @@
1
+ /**
2
+ * project-meta-cache — on-disk cache for repo-invariant GitHub Projects v2
3
+ * board metadata (Story #4252).
4
+ *
5
+ * Every `agent::*` label flip routes through `transitionTicketState` →
6
+ * `ColumnSync.sync`, which must resolve the board's `{ projectId, fieldId,
7
+ * options }` before issuing the Status mutation. Single-story delivery
8
+ * performs each flip in a *separate cold CLI process* (init → executing,
9
+ * close → closing, confirm-merge → done, plus `resync-status-column`), so
10
+ * the in-process `_meta` cache is always empty and every flip re-pays
11
+ * `resolveProjectMeta` + an item-id lookup + the mutation (~3 GraphQL
12
+ * round-trips).
13
+ *
14
+ * The board metadata is **repo-invariant** — it changes only when the
15
+ * Status single-select field is reconfigured — so it is a prime candidate
16
+ * for an on-disk cache that survives across the cold processes. This module
17
+ * persists the resolved metadata to a small JSON file under the resolved
18
+ * `tempRoot`, keyed by `owner/projectNumber`, carrying a TTL. Correctness
19
+ * is bounded by "the mutation still validates against the live board": a
20
+ * stale entry at worst causes one failed mutation, which the caller treats
21
+ * as a signal to invalidate the entry and re-resolve — never a wrong write.
22
+ *
23
+ * Layout: `<tempRoot>/cache/project-meta.json`, a single JSON object keyed
24
+ * by `<owner>/<projectNumber>`:
25
+ *
26
+ * {
27
+ * "dsj1984/1": {
28
+ * "cachedAt": 1718000000000,
29
+ * "projectId": "PVT_…",
30
+ * "fieldId": "PVTSSF_…",
31
+ * "options": { "Todo": "abc", "In Progress": "def", "Done": "ghi" }
32
+ * }
33
+ * }
34
+ *
35
+ * The `options` map is serialised as a plain object and re-hydrated into a
36
+ * `Map<name, id>` on read so the ColumnSync call site is identical whether
37
+ * the metadata came from the disk cache or a live resolve.
38
+ *
39
+ * The cache file lives under `tempRoot`, which is gitignored
40
+ * (`temp/` in `.gitignore`), so it introduces no new tracked artifact.
41
+ */
42
+
43
+ import fs from 'node:fs';
44
+ import path from 'node:path';
45
+ import { anchorTempRoot, tempRootFrom } from '../config/temp-paths.js';
46
+
47
+ /**
48
+ * Default time-to-live for a cached board-metadata entry, in milliseconds.
49
+ * Board metadata is repo-invariant (it only changes on a manual Status
50
+ * field reconfiguration), so a generous TTL is safe — and a stale entry is
51
+ * self-healing via invalidate-on-error regardless. One hour comfortably
52
+ * spans a single `/single-story-deliver` run's four cold flips while still
53
+ * forcing a periodic re-resolve.
54
+ */
55
+ const DEFAULT_META_TTL_MS = 60 * 60 * 1000;
56
+
57
+ /**
58
+ * Resolve the path to the on-disk project-meta cache file under the
59
+ * (anchored) resolved `tempRoot`. A relative `tempRoot` is anchored to the
60
+ * main checkout root so a worktree child and the main-checkout host
61
+ * converge on the same file (Story #3900 semantics, reused here).
62
+ *
63
+ * @param {object} [config] Optional resolved config bag. Omitted → framework
64
+ * default (`temp`).
65
+ * @returns {string}
66
+ */
67
+ function projectMetaCachePath(config) {
68
+ return path.join(
69
+ anchorTempRoot(tempRootFrom(config)),
70
+ 'cache',
71
+ 'project-meta.json',
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Build the per-board cache key from `(owner, projectNumber)`.
77
+ *
78
+ * @param {string|null|undefined} owner
79
+ * @param {number|string|null|undefined} projectNumber
80
+ * @returns {string|null} `<owner>/<projectNumber>`, or null when either part
81
+ * is missing (an un-keyable board — caller skips the cache).
82
+ */
83
+ function projectMetaCacheKey(owner, projectNumber) {
84
+ if (!owner || projectNumber === null || projectNumber === undefined) {
85
+ return null;
86
+ }
87
+ return `${owner}/${projectNumber}`;
88
+ }
89
+
90
+ /**
91
+ * Read the entire cache file as a plain object. Returns an empty object on
92
+ * any read/parse failure (missing file, malformed JSON) so the caller treats
93
+ * a corrupt cache as a cold miss rather than throwing.
94
+ *
95
+ * @param {string} filePath
96
+ * @returns {Record<string, object>}
97
+ */
98
+ function readCacheFile(filePath) {
99
+ try {
100
+ const raw = fs.readFileSync(filePath, 'utf8');
101
+ const parsed = JSON.parse(raw);
102
+ return parsed && typeof parsed === 'object' ? parsed : {};
103
+ } catch {
104
+ return {};
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Read a cached metadata entry for `(owner, projectNumber)`.
110
+ *
111
+ * Returns the re-hydrated `{ projectId, fieldId, options: Map }` descriptor
112
+ * when a fresh (within TTL) entry exists, or `null` on a miss / expired /
113
+ * malformed entry. Never throws — a read failure is a cache miss.
114
+ *
115
+ * @param {{
116
+ * owner?: string|null,
117
+ * projectNumber?: number|string|null,
118
+ * config?: object,
119
+ * ttlMs?: number,
120
+ * now?: number,
121
+ * }} opts
122
+ * @returns {{ projectId: string, fieldId: string, options: Map<string,string> } | null}
123
+ */
124
+ export function readProjectMetaCache(opts = {}) {
125
+ const key = projectMetaCacheKey(opts.owner, opts.projectNumber);
126
+ if (!key) return null;
127
+ const ttlMs = opts.ttlMs ?? DEFAULT_META_TTL_MS;
128
+ const now = opts.now ?? Date.now();
129
+
130
+ const filePath = projectMetaCachePath(opts.config);
131
+ const store = readCacheFile(filePath);
132
+ const entry = store[key];
133
+ if (!entry || typeof entry !== 'object') return null;
134
+
135
+ const { cachedAt, projectId, fieldId, options } = entry;
136
+ if (
137
+ typeof cachedAt !== 'number' ||
138
+ typeof projectId !== 'string' ||
139
+ typeof fieldId !== 'string' ||
140
+ !options ||
141
+ typeof options !== 'object'
142
+ ) {
143
+ return null;
144
+ }
145
+ // TTL check — an expired entry is a miss so the caller re-resolves.
146
+ if (now - cachedAt > ttlMs) return null;
147
+
148
+ return {
149
+ projectId,
150
+ fieldId,
151
+ options: new Map(Object.entries(options)),
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Persist a resolved metadata descriptor for `(owner, projectNumber)`.
157
+ *
158
+ * The `options` value may be a `Map<name,id>` or a plain object; it is
159
+ * normalised to a plain object on disk. Best-effort: any write failure is
160
+ * swallowed (the cache is a pure optimisation — a failed write just means
161
+ * the next process re-resolves). Writes the rest of the store back intact so
162
+ * sibling board entries are preserved.
163
+ *
164
+ * @param {{
165
+ * owner?: string|null,
166
+ * projectNumber?: number|string|null,
167
+ * meta: { projectId: string, fieldId: string, options: Map<string,string>|Record<string,string> },
168
+ * config?: object,
169
+ * now?: number,
170
+ * }} opts
171
+ * @returns {boolean} true when the entry was written, false on a skip/failure.
172
+ */
173
+ export function writeProjectMetaCache(opts = {}) {
174
+ const key = projectMetaCacheKey(opts.owner, opts.projectNumber);
175
+ if (!key) return false;
176
+ const meta = opts.meta;
177
+ if (
178
+ !meta ||
179
+ typeof meta.projectId !== 'string' ||
180
+ typeof meta.fieldId !== 'string'
181
+ ) {
182
+ return false;
183
+ }
184
+ const now = opts.now ?? Date.now();
185
+
186
+ const options =
187
+ meta.options instanceof Map
188
+ ? Object.fromEntries(meta.options)
189
+ : { ...(meta.options ?? {}) };
190
+
191
+ const filePath = projectMetaCachePath(opts.config);
192
+ try {
193
+ const store = readCacheFile(filePath);
194
+ store[key] = {
195
+ cachedAt: now,
196
+ projectId: meta.projectId,
197
+ fieldId: meta.fieldId,
198
+ options,
199
+ };
200
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
201
+ fs.writeFileSync(filePath, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
202
+ return true;
203
+ } catch {
204
+ return false;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Invalidate (delete) the cached entry for `(owner, projectNumber)`.
210
+ *
211
+ * Called when a GraphQL error fires against a board whose metadata came from
212
+ * the cache, so a mid-run board reconfiguration self-heals on the next flip
213
+ * (which re-resolves and re-writes). Best-effort and idempotent: a missing
214
+ * entry or unreadable file is a no-op. Returns true when an entry was
215
+ * actually removed.
216
+ *
217
+ * @param {{
218
+ * owner?: string|null,
219
+ * projectNumber?: number|string|null,
220
+ * config?: object,
221
+ * }} opts
222
+ * @returns {boolean}
223
+ */
224
+ export function invalidateProjectMetaCache(opts = {}) {
225
+ const key = projectMetaCacheKey(opts.owner, opts.projectNumber);
226
+ if (!key) return false;
227
+ const filePath = projectMetaCachePath(opts.config);
228
+ try {
229
+ const store = readCacheFile(filePath);
230
+ if (!(key in store)) return false;
231
+ delete store[key];
232
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
233
+ fs.writeFileSync(filePath, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
234
+ return true;
235
+ } catch {
236
+ return false;
237
+ }
238
+ }
@@ -89,6 +89,7 @@ function defaultSleep(ms) {
89
89
  * pollAttempts?: number,
90
90
  * pollDelayMs?: number,
91
91
  * sleepFn?: (ms: number) => Promise<void>,
92
+ * config?: object,
92
93
  * }} args
93
94
  * @returns {Promise<{ status: string, column?: string, reason?: string, attempts?: number }>}
94
95
  */
@@ -100,6 +101,7 @@ export async function reassertStatusColumn(args) {
100
101
  pollAttempts = DEFAULT_POLL_ATTEMPTS,
101
102
  pollDelayMs = DEFAULT_POLL_DELAY_MS,
102
103
  sleepFn = defaultSleep,
104
+ config,
103
105
  } = args ?? {};
104
106
  if (!provider || typeof provider.getTicket !== 'function') {
105
107
  throw new TypeError(
@@ -128,7 +130,7 @@ export async function reassertStatusColumn(args) {
128
130
  if (!targetColumn) {
129
131
  return { status: 'skipped', reason: 'no-matching-label' };
130
132
  }
131
- const sync = new ColumnSync({ provider, logger: logger ?? console });
133
+ const sync = new ColumnSync({ provider, logger: logger ?? console, config });
132
134
 
133
135
  // First attempt — always fires through ColumnSync.sync so the skip
134
136
  // paths (no-project / no-meta / no-option-<col> / not-on-project)
@@ -12,9 +12,21 @@
12
12
  * inject a synchronous fake; the default runner delegates to
13
13
  * `gh.pr.merge`, which spawns through the classified, typed-error
14
14
  * surface instead of a raw `execFileSync('gh', …)` call.
15
+ *
16
+ * Story #4282 made arming robust when the base branch is checked out by a
17
+ * git worktree. The `--delete-branch` flag makes `gh` shell out to local
18
+ * `git` (including a `git checkout <base>`); from a per-Story worktree cwd
19
+ * that collides with the base branch already checked out by the primary
20
+ * worktree (`fatal: '<base>' is already used by worktree`). We now resolve
21
+ * the arm cwd to the **primary worktree root** (which holds the base
22
+ * branch) via `resolveAutoMergeArmCwd`, so `gh`'s local checkout is a
23
+ * no-op. `--delete-branch` is preserved verbatim, so the PR head branch is
24
+ * still deleted on merge without depending on the repo's auto-delete
25
+ * setting. Resolution is non-fatal — it degrades to the original cwd.
15
26
  */
16
27
 
17
28
  import { gh as defaultGh } from '../../../gh-exec.js';
29
+ import { resolveAutoMergeArmCwd } from '../../auto-merge-cwd.js';
18
30
 
19
31
  /**
20
32
  * Enable GitHub native auto-merge on the PR. Non-fatal.
@@ -24,11 +36,22 @@ import { gh as defaultGh } from '../../../gh-exec.js';
24
36
  * prNumber: number,
25
37
  * gh?: ReturnType<typeof import('../../../gh-exec.js').createGh>,
26
38
  * runner?: (args: string[], opts: object) => ({ status: number, stdout?: string, stderr?: string } | Promise<{ status: number, stdout?: string, stderr?: string }>),
39
+ * resolveArmCwd?: (cwd: string) => string,
27
40
  * }} opts
28
41
  * @returns {Promise<{ enabled: boolean, reason?: string }>}
29
42
  */
30
- export async function enableAutoMergeWith({ cwd, prNumber, gh, runner }) {
43
+ export async function enableAutoMergeWith({
44
+ cwd,
45
+ prNumber,
46
+ gh,
47
+ runner,
48
+ resolveArmCwd = resolveAutoMergeArmCwd,
49
+ }) {
31
50
  const exec = runner ?? makeDefaultGhAutoMergeRunner(gh ?? defaultGh);
51
+ // Re-point the arm at the base-branch (primary) worktree so gh's
52
+ // `--delete-branch` local `git checkout <base>` cannot collide with the
53
+ // base branch already checked out by the primary worktree (Story #4282).
54
+ const armCwd = resolveArmCwd(cwd);
32
55
  try {
33
56
  const result = await exec(
34
57
  [
@@ -39,7 +62,7 @@ export async function enableAutoMergeWith({ cwd, prNumber, gh, runner }) {
39
62
  '--squash',
40
63
  '--delete-branch',
41
64
  ],
42
- { cwd },
65
+ { cwd: armCwd },
43
66
  );
44
67
  if (result.status === 0) return { enabled: true };
45
68
  return {