@uchibeke/gallivant 0.2.1 → 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.
- package/README.md +38 -16
- package/SECURITY.md +9 -0
- package/bin/gallivant.mjs +33 -10
- package/bin/promote-bitbucket.mjs +40 -0
- package/bin/task-pr.mjs +9 -0
- package/bin/task-review.mjs +15 -0
- package/docs/README.md +2 -0
- package/docs/architecture.md +11 -3
- package/docs/bitbucket-promotion.md +69 -0
- package/docs/bitbucket-review.md +330 -0
- package/docs/chains.md +30 -1
- package/docs/codex-cloud.md +7 -0
- package/docs/codex-review.md +50 -0
- package/docs/commands.md +65 -5
- package/docs/config.md +35 -10
- package/docs/first-party-review.md +266 -0
- package/docs/git-hosts.md +294 -0
- package/docs/guardrails.md +56 -0
- package/docs/install.md +18 -8
- package/docs/network-recovery.md +9 -0
- package/docs/openai-agents.md +2 -1
- package/docs/prd.md +23 -2
- package/docs/preparation.md +1 -1
- package/docs/release-qualification.md +1 -1
- package/docs/releasing.md +17 -2
- package/docs/task-delivery.md +4 -4
- package/docs/task-profiles.md +537 -0
- package/docs/tasks.md +59 -197
- package/lib/attribution.mjs +7 -3
- package/lib/block.mjs +10 -1
- package/lib/chains.mjs +101 -30
- package/lib/codex_cloud.mjs +2 -1
- package/lib/codex_cloud_input.mjs +15 -6
- package/lib/codex_cloud_result.mjs +1 -1
- package/lib/commands/doctor.mjs +22 -462
- package/lib/commands/init.mjs +72 -30
- package/lib/commands/register.mjs +25 -11
- package/lib/commands/resume.mjs +5 -1
- package/lib/commands/review.mjs +66 -0
- package/lib/commands/serve.mjs +8 -0
- package/lib/commands/start.mjs +30 -11
- package/lib/commands/status.mjs +18 -2
- package/lib/commands/task.mjs +9 -1
- package/lib/commands/up.mjs +19 -11
- package/lib/commands/why.mjs +7 -3
- package/lib/config.mjs +42 -9
- package/lib/context.mjs +23 -18
- package/lib/control.mjs +21 -6
- package/lib/credential.mjs +20 -14
- package/lib/deliverable.mjs +2 -0
- package/lib/events.mjs +96 -53
- package/lib/execution.mjs +48 -3
- package/lib/execution_preparation.mjs +16 -1
- package/lib/execution_result.mjs +26 -5
- package/lib/first_party_review.mjs +618 -0
- package/lib/gh.mjs +144 -56
- package/lib/git.mjs +133 -32
- package/lib/git_diff.mjs +121 -0
- package/lib/harness.mjs +14 -10
- package/lib/host_contract.mjs +71 -0
- package/lib/host_definitions.mjs +22 -0
- package/lib/hosts/bitbucket.mjs +553 -0
- package/lib/hosts/bitbucket_contract.mjs +57 -0
- package/lib/hosts/bitbucket_credential.mjs +62 -0
- package/lib/hosts/bitbucket_review.mjs +44 -0
- package/lib/hosts/bitbucket_review_producer.mjs +50 -0
- package/lib/hosts/github.mjs +57 -0
- package/lib/hosts/github_contract.mjs +27 -0
- package/lib/hosts/github_doctor.mjs +482 -0
- package/lib/hosts/github_review_summary.mjs +38 -0
- package/lib/hosts/transport_doctor.mjs +35 -0
- package/lib/hosts.mjs +130 -0
- package/lib/learning_qualification.mjs +1 -1
- package/lib/notify.mjs +1 -1
- package/lib/openai_agents_input.mjs +7 -2
- package/lib/openai_agents_sources.mjs +16 -8
- package/lib/plugin_package.mjs +13 -1
- package/lib/pr_operation.mjs +41 -0
- package/lib/prd.mjs +18 -3
- package/lib/preparation_codex.mjs +6 -1
- package/lib/probe.mjs +6 -4
- package/lib/process.mjs +24 -0
- package/lib/promotion.mjs +132 -0
- package/lib/promotion_git.mjs +63 -0
- package/lib/prompt.mjs +16 -13
- package/lib/provider_health.mjs +14 -3
- package/lib/repository.mjs +68 -0
- package/lib/repository_preflight.mjs +11 -0
- package/lib/review_evaluation.mjs +25 -0
- package/lib/review_evidence.mjs +500 -0
- package/lib/review_history.mjs +73 -0
- package/lib/review_image.mjs +145 -0
- package/lib/review_link.mjs +12 -0
- package/lib/review_policy.mjs +132 -0
- package/lib/review_policy_contract.mjs +18 -0
- package/lib/review_state.mjs +13 -0
- package/lib/review_visual.mjs +94 -0
- package/lib/runtime-environment.mjs +6 -0
- package/lib/runtime_node.mjs +3 -2
- package/lib/runtime_state.mjs +39 -5
- package/lib/sources/prd.mjs +6 -3
- package/lib/state.mjs +137 -24
- package/lib/task_completion.mjs +7 -3
- package/lib/task_contract.mjs +201 -27
- package/lib/task_delivery.mjs +9 -2
- package/lib/task_delivery_contract.mjs +13 -9
- package/lib/task_delivery_view.mjs +18 -2
- package/lib/task_environment.mjs +13 -1
- package/lib/task_limits.mjs +13 -0
- package/lib/task_model_config.mjs +99 -0
- package/lib/task_model_route.mjs +243 -0
- package/lib/task_prepare.mjs +21 -8
- package/lib/task_profile.mjs +1875 -0
- package/lib/task_repository_context.mjs +65 -0
- package/lib/tasks.mjs +252 -51
- package/lib/tick.mjs +353 -97
- package/lib/time.mjs +1 -0
- package/lib/tracker/pages.mjs +19 -5
- package/lib/tracker/review.mjs +21 -0
- package/lib/tracker_process.mjs +8 -1
- package/lib/verification_sandbox.mjs +2 -1
- package/lib/work_artifacts.mjs +79 -0
- package/npm-shrinkwrap.json +12 -3
- package/package.json +9 -3
- package/templates/plugin/task-delivery.SKILL.md +17 -10
- package/templates/plugin/task-intake-contract.json +18 -0
- package/templates/plugin/task-profile-requests.mjs +1448 -0
- package/templates/tracker/tracker.css +3 -0
- 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("/")
|
|
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) =>
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
81
|
-
get: id =>
|
|
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(
|
|
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]
|
|
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(
|
|
97
|
-
getChain: name =>
|
|
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 =>
|
|
113
|
-
assertCanRegister: id => assertCanPut(
|
|
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.-]+)
|
|
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
|
|
162
|
-
if (
|
|
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.
|
|
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
|
package/lib/task_completion.mjs
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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; } }
|