@uchibeke/gallivant 0.2.0 → 0.3.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 (133) hide show
  1. package/README.md +120 -60
  2. package/SECURITY.md +9 -0
  3. package/bin/gallivant.mjs +41 -14
  4. package/bin/promote-bitbucket.mjs +40 -0
  5. package/bin/task-pr.mjs +9 -0
  6. package/bin/task-review.mjs +15 -0
  7. package/docs/README.md +6 -2
  8. package/docs/agent-setup.md +55 -41
  9. package/docs/architecture.md +11 -3
  10. package/docs/bitbucket-promotion.md +69 -0
  11. package/docs/bitbucket-review.md +330 -0
  12. package/docs/chains.md +30 -1
  13. package/docs/codex-cloud.md +7 -0
  14. package/docs/codex-review.md +50 -0
  15. package/docs/commands.md +103 -14
  16. package/docs/config.md +45 -13
  17. package/docs/execution.md +1 -1
  18. package/docs/first-party-review.md +266 -0
  19. package/docs/git-hosts.md +294 -0
  20. package/docs/guardrails.md +56 -0
  21. package/docs/install.md +137 -67
  22. package/docs/launch.md +7 -4
  23. package/docs/network-recovery.md +9 -0
  24. package/docs/openai-agents.md +3 -2
  25. package/docs/prd.md +23 -2
  26. package/docs/preparation.md +2 -2
  27. package/docs/release-qualification.md +1 -1
  28. package/docs/releasing.md +46 -22
  29. package/docs/task-delivery.md +5 -5
  30. package/docs/task-profiles.md +537 -0
  31. package/docs/tasks.md +59 -197
  32. package/lib/attribution.mjs +7 -3
  33. package/lib/block.mjs +10 -1
  34. package/lib/chains.mjs +101 -30
  35. package/lib/codex_cloud.mjs +2 -1
  36. package/lib/codex_cloud_input.mjs +15 -6
  37. package/lib/codex_cloud_result.mjs +1 -1
  38. package/lib/commands/doctor.mjs +22 -453
  39. package/lib/commands/init.mjs +281 -28
  40. package/lib/commands/register.mjs +81 -13
  41. package/lib/commands/resume.mjs +5 -1
  42. package/lib/commands/review.mjs +66 -0
  43. package/lib/commands/serve.mjs +8 -0
  44. package/lib/commands/start.mjs +30 -11
  45. package/lib/commands/status.mjs +18 -2
  46. package/lib/commands/task.mjs +9 -1
  47. package/lib/commands/up.mjs +19 -11
  48. package/lib/commands/why.mjs +7 -3
  49. package/lib/config.mjs +42 -9
  50. package/lib/context.mjs +28 -21
  51. package/lib/control.mjs +21 -6
  52. package/lib/credential.mjs +20 -14
  53. package/lib/deliverable.mjs +2 -0
  54. package/lib/events.mjs +96 -53
  55. package/lib/execution.mjs +132 -11
  56. package/lib/execution_preparation.mjs +16 -1
  57. package/lib/execution_result.mjs +26 -5
  58. package/lib/first_party_review.mjs +618 -0
  59. package/lib/fs.mjs +10 -3
  60. package/lib/gh.mjs +144 -56
  61. package/lib/git.mjs +133 -32
  62. package/lib/git_diff.mjs +121 -0
  63. package/lib/harness.mjs +14 -10
  64. package/lib/host_contract.mjs +71 -0
  65. package/lib/host_definitions.mjs +22 -0
  66. package/lib/hosts/bitbucket.mjs +553 -0
  67. package/lib/hosts/bitbucket_contract.mjs +57 -0
  68. package/lib/hosts/bitbucket_credential.mjs +62 -0
  69. package/lib/hosts/bitbucket_review.mjs +44 -0
  70. package/lib/hosts/bitbucket_review_producer.mjs +50 -0
  71. package/lib/hosts/github.mjs +57 -0
  72. package/lib/hosts/github_contract.mjs +27 -0
  73. package/lib/hosts/github_doctor.mjs +482 -0
  74. package/lib/hosts/github_review_summary.mjs +38 -0
  75. package/lib/hosts/transport_doctor.mjs +35 -0
  76. package/lib/hosts.mjs +130 -0
  77. package/lib/learning_qualification.mjs +1 -1
  78. package/lib/notify.mjs +1 -1
  79. package/lib/openai_agents_input.mjs +7 -2
  80. package/lib/openai_agents_sources.mjs +16 -8
  81. package/lib/plugin_package.mjs +13 -1
  82. package/lib/pr_operation.mjs +41 -0
  83. package/lib/prd.mjs +18 -3
  84. package/lib/preparation_codex.mjs +6 -1
  85. package/lib/probe.mjs +6 -4
  86. package/lib/process.mjs +24 -0
  87. package/lib/promotion.mjs +132 -0
  88. package/lib/promotion_git.mjs +63 -0
  89. package/lib/prompt.mjs +16 -13
  90. package/lib/provider_health.mjs +14 -3
  91. package/lib/repository.mjs +68 -0
  92. package/lib/repository_preflight.mjs +11 -0
  93. package/lib/review_evaluation.mjs +25 -0
  94. package/lib/review_evidence.mjs +500 -0
  95. package/lib/review_history.mjs +73 -0
  96. package/lib/review_image.mjs +145 -0
  97. package/lib/review_link.mjs +12 -0
  98. package/lib/review_policy.mjs +132 -0
  99. package/lib/review_policy_contract.mjs +18 -0
  100. package/lib/review_state.mjs +13 -0
  101. package/lib/review_visual.mjs +94 -0
  102. package/lib/runtime-environment.mjs +6 -0
  103. package/lib/runtime_node.mjs +3 -2
  104. package/lib/runtime_state.mjs +39 -5
  105. package/lib/sources/prd.mjs +6 -3
  106. package/lib/state.mjs +137 -24
  107. package/lib/task_completion.mjs +7 -3
  108. package/lib/task_contract.mjs +201 -27
  109. package/lib/task_delivery.mjs +9 -2
  110. package/lib/task_delivery_contract.mjs +13 -9
  111. package/lib/task_delivery_view.mjs +18 -2
  112. package/lib/task_environment.mjs +13 -1
  113. package/lib/task_limits.mjs +13 -0
  114. package/lib/task_model_config.mjs +99 -0
  115. package/lib/task_model_route.mjs +243 -0
  116. package/lib/task_prepare.mjs +21 -8
  117. package/lib/task_profile.mjs +1875 -0
  118. package/lib/task_repository_context.mjs +65 -0
  119. package/lib/tasks.mjs +252 -51
  120. package/lib/tick.mjs +373 -98
  121. package/lib/time.mjs +1 -0
  122. package/lib/tracker/pages.mjs +19 -5
  123. package/lib/tracker/review.mjs +21 -0
  124. package/lib/tracker_process.mjs +8 -1
  125. package/lib/verification_sandbox.mjs +2 -1
  126. package/lib/work_artifacts.mjs +79 -0
  127. package/npm-shrinkwrap.json +12 -3
  128. package/package.json +9 -3
  129. package/templates/plugin/task-delivery.SKILL.md +17 -10
  130. package/templates/plugin/task-intake-contract.json +18 -0
  131. package/templates/plugin/task-profile-requests.mjs +1448 -0
  132. package/templates/tracker/tracker.css +3 -0
  133. package/templates/tracker/tracker.js +38 -0
package/lib/state.mjs CHANGED
@@ -1,13 +1,17 @@
1
+ import { repositoryIdentity, savedRepository } from "./repository.mjs";
1
2
  // The registry: one entry per PRD or registered PR, keyed by `<owner>/<repo>/<prd id>`.
2
3
  //
3
4
  // The file is a cache with cursors. Identity (which PR, which session) can be rebuilt from GitHub; counters and
4
5
  // cursors can be rebuilt by counting the daemon's own comments. Every write is atomic and every read-modify-write
5
6
  // holds a lock file, so the CLI and the daemon cannot lose each other's updates.
6
7
  import { join, basename } from "node:path";
7
- import { openSync, closeSync, unlinkSync, statSync } from "node:fs";
8
+ import { openSync, closeSync, unlinkSync, statSync, readFileSync, writeFileSync } from "node:fs";
8
9
  import { readJson, writeJsonAtomic, gallivantHome } from "./fs.mjs";
10
+ import { reviewHistoryStore } from "./review_history.mjs";
9
11
  import { later } from "./time.mjs";
10
12
  import { randomUUID } from "node:crypto";
13
+ import { isDeepStrictEqual } from "node:util";
14
+ import { assertReviewPolicy } from "./review_policy.mjs";
11
15
 
12
16
  export const STATES = Object.freeze({
13
17
  running: "running", // start run in progress, no PR yet
@@ -18,6 +22,7 @@ export const STATES = Object.freeze({
18
22
  });
19
23
 
20
24
  export const statePath = (home = gallivantHome()) => join(home, "state.json");
25
+ export const supportedStateVersion = version => version === 1 || version === 2;
21
26
 
22
27
  /**
23
28
  * The repository name without its owner, for a handle a person reads: `acme/widget` is `widget`. Anything that is
@@ -26,34 +31,55 @@ export const statePath = (home = gallivantHome()) => join(home, "state.json");
26
31
  * `gallivant why undefined#3`, and the throw happened inside `needsHuman` after the labels were written but before
27
32
  * the notification, where the tick's per-entry catch swallowed it and a stopped PR notified nobody at all.
28
33
  */
29
- export const shortRepo = nameWithOwner => { const s = String(nameWithOwner ?? ""); return s.split("/")[1] || s; };
34
+ export const shortRepo = nameWithOwner => { const s = String(nameWithOwner ?? ""); return s.split("/").at(-1) || s; };
30
35
 
31
36
  /** `<repo>#<pr>` when there is a PR, else the registry id. */
32
- export const handleFor = (id, entry) => (entry.pr ? `${shortRepo(entry.repo)}#${entry.pr}` : id);
37
+ export const handleFor = (id, entry) => {
38
+ if (!entry.pr) return id;
39
+ let repo = shortRepo(entry.repo);
40
+ try { const identity = repositoryIdentity(entry.repo); if (identity.host !== "github.com") repo = identity.remoteIdentity; } catch { /* Display opaque historical handles safely. */ }
41
+ return `${repo}#${entry.pr}`;
42
+ };
33
43
 
34
44
  /** Registry id: a pure function of the repository and the PRD, so every path keyed by it agrees. */
35
- export const entryId = (nameWithOwner, prdId) => `${nameWithOwner.toLowerCase()}/${prdId}`;
45
+ export const entryId = (nameWithOwner, prdId) => `${repositoryIdentity(nameWithOwner).key}/${prdId}`;
46
+
47
+ /** An existing predecessor is observed, never adopted into a chain's owned steps. */
48
+ export const matchesChainAnchor = (anchor, entry) => !!entry
49
+ && ["repo", "base", "branch", "pr", "url"].every(key => entry[key] === anchor[key]);
36
50
 
37
51
  /** The PRD id is the last segment of the registry id. */
38
52
  export const prdIdOf = id => id.split("/").pop();
39
53
 
40
54
  /** A filesystem-safe form of the id for run and snapshot folders. */
41
- export const idSlug = id => id.replaceAll("/", "__");
55
+ export const idSlug = id => id.split("/").length === 4 ? `host~${id.split("/").map(encodeURIComponent).join("~")}` : id.replaceAll("/", "__");
42
56
 
43
57
  const LOCK_WAIT_MS = 5_000;
44
58
  const LOCK_STALE_MS = 30_000;
45
59
 
46
- /** Hold an exclusive lock file for the duration of `fn`. Waits up to five seconds; a lock older than thirty is stale. */
60
+ // A live owner can hold the lock longer than the stale interval during the
61
+ // one-time migration of large histories. PID reuse may delay recovery, but it
62
+ // must never permit a second writer while the recorded owner is still alive.
63
+ function lockOwnerAlive(lock) {
64
+ try {
65
+ const { pid } = JSON.parse(readFileSync(lock, "utf8"));
66
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
67
+ try { process.kill(pid, 0); return true; }
68
+ catch (error) { return error.code !== "ESRCH"; }
69
+ } catch { return false; } // historical empty lock files retain old recovery behavior
70
+ }
71
+
72
+ /** Hold an exclusive lock file for the duration of `fn`. Waits up to five seconds; recover stale dead-owner locks. */
47
73
  function withLock(file, fn) {
48
74
  const lock = `${file}.lock`;
49
75
  const deadline = Date.now() + LOCK_WAIT_MS;
50
76
  for (;;) {
51
77
  try {
52
78
  const fd = openSync(lock, "wx");
53
- try { return fn(); } finally { closeSync(fd); try { unlinkSync(lock); } catch { /* gone */ } }
79
+ try { writeFileSync(fd, JSON.stringify({ pid: process.pid })); return fn(); } finally { closeSync(fd); try { unlinkSync(lock); } catch { /* gone */ } }
54
80
  } catch (e) {
55
81
  if (e.code !== "EEXIST") throw e;
56
- try { if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) { unlinkSync(lock); continue; } } catch { continue; }
82
+ try { if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS && !lockOwnerAlive(lock)) { unlinkSync(lock); continue; } } catch { continue; }
57
83
  if (Date.now() > deadline) throw new Error(`state is locked by another gallivant process (${lock})`);
58
84
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
59
85
  }
@@ -62,13 +88,66 @@ function withLock(file, fn) {
62
88
 
63
89
  export function makeState(home = gallivantHome()) {
64
90
  const file = statePath(home);
65
- const load = () => {
91
+ const historyStore = reviewHistoryStore(home);
92
+ let cached = null;
93
+ const signature = () => {
94
+ try { const s = statSync(file, { bigint: true }); return `${s.dev}:${s.ino}:${s.size}:${s.mtimeNs}:${s.ctimeNs}`; }
95
+ catch { return null; }
96
+ };
97
+ const read = () => {
98
+ const before = signature();
99
+ if (before && cached?.signature === before) return cached.value;
66
100
  const s = readJson(file, { version: 1, entries: {} });
67
- if (s.version !== 1) throw Object.assign(new Error(`unsupported registry version ${s.version}; stop the coordinator, back up ${home}, and install a compatible package or restore its matching backup; do not redispatch`), { code: "STATE_VERSION" });
101
+ if (!supportedStateVersion(s.version)) throw Object.assign(new Error(`unsupported registry version ${s.version}; stop the coordinator, back up ${home}, and install a compatible package or restore its matching backup; do not redispatch`), { code: "STATE_VERSION" });
68
102
  for (const e of Object.values(s.entries)) if (e.execution && e.execution.version !== 1) throw Object.assign(new Error("unsupported execution state version; restore a compatible package/backup before restarting"), { code: "EXECUTION_VERSION" });
103
+ for (const e of Object.values(s.entries)) if (e.reviewPolicy) assertReviewPolicy(e.reviewPolicy);
104
+ const identities = new Set();
105
+ for (const [id, entry] of Object.entries(s.entries)) {
106
+ if (!entry.repo) continue;
107
+ const repository = savedRepository(entry);
108
+ const expected = `${repository.key}/`;
109
+ if (repository.host !== "github.com" && !id.toLowerCase().startsWith(expected)) throw new Error("Ambiguous saved entry ID; back up state and reconcile its qualified repository before restarting. Do not redispatch.");
110
+ if (entry.pr) {
111
+ const key = `${repository.remoteIdentity}#${entry.pr}`;
112
+ if (identities.has(key)) throw new Error("Duplicate saved PR identity; back up state and reconcile its entries and cursors before restarting. Do not redispatch.");
113
+ identities.add(key);
114
+ }
115
+ }
116
+ for (const chain of Object.values(s.chains || {})) if (chain.repo) savedRepository(chain);
117
+ const after = signature();
118
+ cached = before && before === after ? { signature: after, value: s } : null;
69
119
  return s;
70
120
  };
71
- const save = s => writeJsonAtomic(file, s, 0o600);
121
+ // Public reads and mutation callbacks receive detached values, as they did
122
+ // when each read parsed a fresh file. Atomic replacement by another process
123
+ // changes the signature and forces validation of the new registry.
124
+ const load = () => structuredClone(read());
125
+ const save = s => {
126
+ // Archive before publishing references. Unchanged summaries do not read or
127
+ // serialize full historical evidence, even across process restarts.
128
+ let hasArchivedHistory = false;
129
+ const entries = Object.fromEntries(Object.entries(s.entries).map(([id, entry]) => {
130
+ if (!Array.isArray(entry.reviewHistory)) return [id, entry];
131
+ const lastByKey = new Map(), reviewHistory = [];
132
+ for (const item of entry.reviewHistory) {
133
+ const archived = historyStore.archive(item), marker = archived?._gallivantReviewArchive?.digest;
134
+ if (marker) hasArchivedHistory = true;
135
+ // Repeated persistence of the same key/version is redundant, but an
136
+ // earlier identical version after an intervening disposition is not:
137
+ // e.g. pending -> intent -> pending represents a proven unsent retry.
138
+ if (marker && typeof archived.key === "string") {
139
+ if (lastByKey.get(archived.key) === marker) continue;
140
+ lastByKey.set(archived.key, marker);
141
+ }
142
+ reviewHistory.push(archived);
143
+ }
144
+ return [id, { ...entry, reviewHistory }];
145
+ }));
146
+ // Version 2 fences older processes that do not understand the compact
147
+ // references. Never downgrade, even if the last archived entry is removed.
148
+ const compact = { ...s, version: hasArchivedHistory ? 2 : s.version, entries };
149
+ cached = null; writeJsonAtomic(file, compact, 0o600); return compact;
150
+ };
72
151
  const locked = fn => withLock(file, fn);
73
152
  const owner = (s, id) => Object.values(s.chains || {}).find(c => !["done", "cancelled"].includes(c.status) && c.steps.some(step => step.id === id));
74
153
  const assertCanPut = (s, id) => {
@@ -77,27 +156,46 @@ export function makeState(home = gallivantHome()) {
77
156
  return {
78
157
  file,
79
158
  load,
80
- entries: () => Object.entries(load().entries),
81
- get: id => load().entries[id] || null,
159
+ entries: () => Object.entries(read().entries).map(([id, entry]) => [id, structuredClone(entry)]),
160
+ get: id => { const entry = read().entries[id]; return entry ? structuredClone(entry) : null; },
161
+ reviewHistory: id => (read().entries[id]?.reviewHistory || []).map(item => structuredClone(historyStore.hydrate(item))),
162
+ reviewHistoryRecord: (id, key) => { const item = read().entries[id]?.reviewHistory?.findLast(record => record.key === key); return item ? structuredClone(historyStore.hydrate(item)) : null; },
82
163
  /** The entry that watches this PR, if any. */
83
- findByPr: (repo, pr) => Object.entries(load().entries).find(([, e]) => e.repo === repo.toLowerCase() && e.pr === pr) || null,
164
+ findByPr: (repo, pr) => { const found = Object.entries(read().entries).find(([, e]) => savedRepository(e).remoteIdentity === repositoryIdentity(repo).remoteIdentity && e.pr === pr); return found ? structuredClone(found) : null; },
84
165
  put: (id, entry) => locked(() => {
85
166
  const s = load();
86
167
  assertCanPut(s, id);
87
- s.entries[id] = entry; save(s); return entry;
168
+ if (isDeepStrictEqual(s.entries[id], entry)) return structuredClone(entry);
169
+ s.entries[id] = entry; return structuredClone(save(s).entries[id]);
170
+ }),
171
+ update: (id, patch) => locked(() => {
172
+ // Only the selected entry is exposed to a mutation callback. Cloning the
173
+ // entire registry here makes every small update copy unrelated review
174
+ // histories, which can be hundreds of megabytes. Keep the cached root
175
+ // untouched and detach the selected entry before invoking user code.
176
+ const original = read(), entry = original.entries[id];
177
+ if (!entry) throw new Error(`no entry ${id}`);
178
+ const current = structuredClone(entry);
179
+ const updated = { ...current, ...(typeof patch === "function" ? patch(current) : patch) };
180
+ // Compare under the lock against freshly observed state, not a caller's
181
+ // cache. A no-op neither rewrites the registry nor migrates its format.
182
+ if (isDeepStrictEqual(entry, updated)) return updated;
183
+ const next = { ...original, entries: { ...original.entries, [id]: updated } };
184
+ return structuredClone(save(next).entries[id]);
88
185
  }),
89
- update: (id, patch) => locked(() => { const s = load(); const cur = s.entries[id]; if (!cur) throw new Error(`no entry ${id}`); s.entries[id] = { ...cur, ...(typeof patch === "function" ? patch(cur) : patch) }; save(s); return s.entries[id]; }),
90
186
  remove: id => locked(() => {
91
187
  const s = load();
92
188
  const chain = owner(s, id);
93
189
  if (chain) throw new Error(`${id} belongs to chain ${chain.name}; cancel the chain before forgetting its run`);
190
+ if (!Object.hasOwn(s.entries, id)) return;
94
191
  delete s.entries[id]; save(s);
95
192
  }),
96
- chains: () => Object.entries(load().chains || {}),
97
- getChain: name => load().chains?.[name] || null,
193
+ chains: () => Object.entries(read().chains || {}).map(([name, chain]) => [name, structuredClone(chain)]),
194
+ getChain: name => { const chain = read().chains?.[name]; return chain ? structuredClone(chain) : null; },
98
195
  createChain: chain => locked(() => {
99
196
  const s = load();
100
197
  if (s.chains?.[chain.name]) throw new Error(`chain ${chain.name} already exists`);
198
+ if (chain.after && !matchesChainAnchor(chain.after, s.entries[chain.after.id])) throw new Error("chain predecessor identity changed while queueing");
101
199
  for (const step of chain.steps) {
102
200
  if (s.entries[step.id] || s.starts?.[step.id] || owner(s, step.id)) throw new Error(`${step.id} already exists, is starting, or belongs to another chain`);
103
201
  }
@@ -106,11 +204,13 @@ export function makeState(home = gallivantHome()) {
106
204
  updateChain: (name, patch) => locked(() => {
107
205
  const s = load(), cur = s.chains?.[name];
108
206
  if (!cur) throw new Error(`no chain ${name}`);
207
+ const before = structuredClone(cur);
109
208
  s.chains[name] = { ...cur, ...(typeof patch === "function" ? patch(cur) : patch) };
209
+ if (isDeepStrictEqual(before, s.chains[name])) return s.chains[name];
110
210
  save(s); return s.chains[name];
111
211
  }),
112
- startClaim: id => load().starts?.[id] || null,
113
- assertCanRegister: id => assertCanPut(load(), id),
212
+ startClaim: id => { const claim = read().starts?.[id]; return claim ? structuredClone(claim) : null; },
213
+ assertCanRegister: id => assertCanPut(read(), id),
114
214
  // This lock covers CLI starts and chain dispatches. Never hold it across a probe, clone or spawn.
115
215
  claimStart: (id, chain = null, task = null) => locked(() => {
116
216
  const s = load(), reserved = owner(s, id);
@@ -118,6 +218,7 @@ export function makeState(home = gallivantHome()) {
118
218
  if (chain) {
119
219
  const c = s.chains?.[chain.name], step = c?.steps[chain.index];
120
220
  if (c?.status !== "active" || c.index !== chain.index || step?.id !== id || step.dispatch !== chain.dispatch) throw new Error(`chain ${chain.name} no longer authorizes this start`);
221
+ if (c.after && (!c.after.merged || !matchesChainAnchor(c.after, s.entries[c.after.id]))) throw new Error("chain predecessor merge or identity is no longer approved");
121
222
  } else if (reserved) throw new Error(`${id} is reserved by chain ${reserved.name}`);
122
223
  const claim = { token: randomUUID(), chain: chain?.name || null, ...(task ? { task } : {}), at: new Date().toISOString() };
123
224
  s.starts = { ...s.starts, [id]: claim }; save(s); return claim;
@@ -136,12 +237,23 @@ export function makeState(home = gallivantHome()) {
136
237
  const s = load(), c = chain && s.chains?.[chain.name];
137
238
  if (s.starts?.[id]?.token !== token || s.entries[id]) throw new Error(`${id}: start claim changed before spawn`);
138
239
  if (chain && (c?.status !== "active" || c.index !== chain.index || c.steps[chain.index]?.dispatch !== chain.dispatch)) throw new Error(`chain ${chain.name} no longer authorizes this start`);
240
+ if (chain && c.after && (!c.after.merged || !matchesChainAnchor(c.after, s.entries[c.after.id]))) throw new Error("chain predecessor merge or identity is no longer approved");
139
241
  }),
140
242
  putStarted: (id, entry, token) => locked(() => {
141
243
  const s = load();
142
244
  if (s.starts?.[id]?.token !== token || s.entries[id]) throw new Error(`${id}: start claim changed; inspect the spawned run`);
143
245
  s.entries[id] = { ...entry, start_token: token }; delete s.starts[id]; save(s); return s.entries[id];
144
246
  }),
247
+ // Progress polling must not clone the full captured source and review history.
248
+ reviewActivityEntry: id => {
249
+ const entry = read().entries[id]; if (!entry) return null;
250
+ const p = entry.reviewPolicy, r = entry.firstPartyReview;
251
+ return { reviewPolicy: p ? { version: p.version, mode: p.mode } : null,
252
+ firstPartyReview: r ? { status: r.status, phase: r.phase, error: r.error,
253
+ pieces: (r.pieces || []).map(piece => ({ status: piece.status })),
254
+ result: r.result ? { verdict: r.result.verdict, findingsCount: Array.isArray(r.result.findings) ? r.result.findings.length : 0 } : null,
255
+ disposition: r.disposition ? { status: r.disposition.status } : null } : null };
256
+ },
145
257
  };
146
258
  }
147
259
 
@@ -154,12 +266,13 @@ export function resolveHandle(entries, handle) {
154
266
  if (/^\d+$/.test(handle)) throw new Error(`"${handle}" is ambiguous across repos; use <repo>#${handle}`);
155
267
  const direct = entries.find(([id]) => id === handle.toLowerCase() || id === handle);
156
268
  if (direct) return direct[0];
157
- const m = handle.match(/^([\w.-]+(?:\/[\w.-]+)?)#(\d+)$/);
269
+ const m = handle.match(/^([\w.-]+(?:\/[\w.-]+){0,2})#(\d+)$/);
158
270
  if (m) {
159
271
  const [, repo, n] = m;
160
272
  const key = repo.toLowerCase();
161
- const hit = entries.find(([, e]) => e.pr === Number(n) && (e.repo === key || shortRepo(e.repo) === key));
162
- if (hit) return hit[0];
273
+ const hits = entries.filter(([, e]) => e.pr === Number(n) && (e.repo === key || savedRepository(e).remoteIdentity === key || shortRepo(e.repo) === key || savedRepository(e).path === key));
274
+ if (hits.length > 1) throw new Error(`Ambiguous handle; use a qualified host/owner/repository#${n}: ${hits.map(([, e]) => `${savedRepository(e).remoteIdentity}#${n}`).join(", ")}`);
275
+ if (hits.length === 1) return hits[0][0];
163
276
  throw new Error(`no entry for ${handle}`);
164
277
  }
165
278
  const prdId = basename(handle).replace(/\.md$/, "");
@@ -211,7 +324,7 @@ export function advanceCursor(cursor = {}, candidate, keys = []) {
211
324
  */
212
325
  export function newEntry({ prd, harness, session = null, repo, branch, worktree, pr = null, url = null, state, nowIso, checkout = null, prdFingerprint = null, prdDecision = null, base = null, sources = [] }) {
213
326
  return {
214
- prd, harness, session, repo: repo.toLowerCase(), branch, worktree, pr, url, state,
327
+ prd, harness, session, repo: repositoryIdentity(repo).key, host: repositoryIdentity(repo).host, branch, worktree, pr, url, state,
215
328
  // The branch this pull request targets, recorded because it is what the daemon merges in before every fix round
216
329
  // and what `status` and `why` print so an operator can see what a PR will target before it is opened. Null on an
217
330
  // entry that never learned one, which is every entry `register` and `reconcile` build before they read the PR's own
@@ -1,3 +1,4 @@
1
+ import { completionAdapter } from "./host_definitions.mjs";
1
2
  // Portable milestones, with one currently supported observation adapter. A
2
3
  // target selects what is fulfilled; it never grants merge/deployment authority.
3
4
  import { ownerDeliveryEvidence, matchingDeliveryEvidence } from "./owner_delivery.mjs";
@@ -9,8 +10,8 @@ export const COMPLETION_SCHEMA = Object.freeze({ type: "object", properties: {
9
10
  target: { type: "string", enum: ["delivery_ready", "integrated"] },
10
11
  }, required: ["target"], additionalProperties: false });
11
12
  export const COMPLETION_CAPABILITIES = Object.freeze({ version: 1, defaultTarget: "delivery_ready", targets: [
12
- { id: "delivery_ready", label: "Ready for handoff", description: "The current delivery passes the existing checks, reviewer and completion gate.", adapter: "github-pull-request" },
13
- { id: "integrated", label: "Merged", description: "Keep watching the qualified delivery until an authorized person or system merges it. This does not authorize a merge.", adapter: "github-pull-request" },
13
+ { id: "delivery_ready", label: "Ready for handoff", description: "The current delivery passes the existing checks, reviewer and completion gate.", adapter: completionAdapter() },
14
+ { id: "integrated", label: "Merged", description: "Keep watching the qualified delivery until an authorized person or system merges it. This does not authorize a merge.", adapter: completionAdapter() },
14
15
  ] });
15
16
  export function completionPolicy(value) {
16
17
  if (value === undefined) return { version: 1, target: "delivery_ready" };
@@ -37,7 +38,7 @@ export function completionView(policy, entry = null, ownerDelivery = null, curre
37
38
  const obligations = ownerDeliveryEvidence(ownership, entry, status);
38
39
  if (obligations.some(o => o.status !== "fulfilled") && ["fulfilled", "awaiting_integration"].includes(status)) { status = "unverified"; reason = "The owner publication obligation lacks matching coordinator delivery evidence."; }
39
40
  if (["fulfilled", "awaiting_integration"].includes(status) && completionWorkPending(entry, current)) { status = "pending"; reason = "Owned execution still requires reconciliation or collection."; }
40
- return { ...selected, label: descriptor?.label || null, description: descriptor?.description || null, status, adapter: "github-pull-request", legacy, evidence, reason,
41
+ return { ...selected, label: descriptor?.label || null, description: descriptor?.description || null, status, adapter: observedAdapter(entry), legacy, evidence, reason,
41
42
  ...(ownership ? { obligations } : {}),
42
43
  monitoring: Boolean(entry && (networkRecoveryPending(entry) || ["pr-open", "ready-for-human", "running"].includes(entry.state)) && !stopsAutomation(entry, selected, status, legacy, current)), mergeAuthorized: false };
43
44
  }
@@ -45,6 +46,7 @@ export function completionView(policy, entry = null, ownerDelivery = null, curre
45
46
  const completionWorkPending = (entry, current) => Boolean(executionLive(entry) || executionLive(current) || current?.running ||
46
47
  current && current.id === entry?.last?.run && entry.last.exit == null);
47
48
  const stopsAutomation = (entry, selected, status, legacy, current) => Boolean(entry?.state === "ready-for-human" && !legacy &&
49
+ entry.reviewPolicy?.version !== 1 &&
48
50
  selected.version === 1 && selected.target === "delivery_ready" && status === "fulfilled" && matchingDeliveryEvidence(entry) && !controlPending(entry.control) && !completionWorkPending(entry, current));
49
51
  export function completionStopsAutomation(entry, current = null) {
50
52
  // Malformed saved ownership remains unqualified and is handled per entry by
@@ -59,3 +61,5 @@ export function completionStopsAutomation(entry, current = null) {
59
61
  export function coordinatorEntryActive(entry, current = null) {
60
62
  return Boolean(entry && (learningDeliveryPending(entry) || networkRecoveryPending(entry) || !["done", "needs-human"].includes(entry.state) && !completionStopsAutomation(entry, current)));
61
63
  }
64
+
65
+ function observedAdapter(entry) { try { return completionAdapter(entry?.repo); } catch { return null; } }