@open-agent-toolkit/cli 0.3.19 → 0.3.20

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 (119) hide show
  1. package/README.md +2 -1
  2. package/assets/agents/oat-phase-implementer.md +26 -3
  3. package/assets/agents/oat-reviewer.md +8 -3
  4. package/assets/bundle-metadata.json +1 -1
  5. package/assets/docs/reference/cli-reference.md +12 -7
  6. package/assets/docs/reference/oat-directory-structure.md +1 -0
  7. package/assets/docs/reference/project-artifacts.md +56 -1
  8. package/assets/docs/reference/project-state-machine.md +41 -7
  9. package/assets/docs/skills/brainstorm.md +3 -2
  10. package/assets/docs/skills/index.md +13 -10
  11. package/assets/docs/workflows/advanced/autonomy.md +3 -1
  12. package/assets/docs/workflows/advanced/dispatch-ceiling.md +2 -2
  13. package/assets/docs/workflows/advanced/workflow-gates.md +17 -4
  14. package/assets/docs/workflows/approvals-and-automation.md +21 -10
  15. package/assets/docs/workflows/choose-workflow.md +17 -9
  16. package/assets/docs/workflows/ideas/index.md +1 -1
  17. package/assets/docs/workflows/ideas/lifecycle.md +3 -2
  18. package/assets/docs/workflows/projects/closeout/closeout-skills.md +80 -7
  19. package/assets/docs/workflows/projects/closeout/pr-flow.md +7 -1
  20. package/assets/docs/workflows/projects/execution/execution-skills.md +16 -3
  21. package/assets/docs/workflows/projects/execution/implementation-execution.md +1 -1
  22. package/assets/docs/workflows/projects/execution/picking-up-projects.md +11 -1
  23. package/assets/docs/workflows/projects/lifecycle.md +108 -9
  24. package/assets/docs/workflows/projects/planning/hill-checkpoints.md +6 -6
  25. package/assets/docs/workflows/projects/planning/planning-skills.md +9 -7
  26. package/assets/docs/workflows/projects/planning/starting-projects.md +57 -11
  27. package/assets/docs/workflows/projects/reviews/index.md +22 -1
  28. package/assets/docs/workflows/projects/reviews/review-flavors.md +5 -3
  29. package/assets/public-package-versions.json +4 -4
  30. package/assets/skills/oat-brainstorm/SKILL.md +34 -9
  31. package/assets/skills/oat-brainstorm/references/destinations.md +6 -3
  32. package/assets/skills/oat-docs/SKILL.md +7 -3
  33. package/assets/skills/oat-pjm-review-backlog/SKILL.md +2 -2
  34. package/assets/skills/oat-project-autonomous/SKILL.md +9 -3
  35. package/assets/skills/oat-project-autonomous/references/gate-inventory.md +151 -132
  36. package/assets/skills/oat-project-brief/SKILL.md +1 -1
  37. package/assets/skills/oat-project-brief/references/brief-contract.md +458 -9
  38. package/assets/skills/oat-project-brief/references/docs/autonomy-contract.md +151 -132
  39. package/assets/skills/oat-project-brief/references/docs/gate-approval-record.md +9 -0
  40. package/assets/skills/oat-project-capture/SKILL.md +2 -2
  41. package/assets/skills/oat-project-complete/SKILL.md +14 -5
  42. package/assets/skills/oat-project-discover/SKILL.md +3 -2
  43. package/assets/skills/oat-project-document/SKILL.md +12 -6
  44. package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +151 -132
  45. package/assets/skills/oat-project-goal/SKILL.md +697 -0
  46. package/assets/skills/oat-project-goal/references/docs/autonomy-contract.md +412 -0
  47. package/assets/skills/oat-project-goal/references/docs/gate-approval-record.md +93 -0
  48. package/assets/skills/oat-project-implement/SKILL.md +1 -1
  49. package/assets/skills/oat-project-implement/references/completion-and-closeout.md +67 -0
  50. package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +151 -132
  51. package/assets/skills/oat-project-implement/references/docs/gate-approval-record.md +9 -0
  52. package/assets/skills/oat-project-implement/references/phase-execution.md +10 -3
  53. package/assets/skills/oat-project-implement/references/plan-and-resume.md +28 -1
  54. package/assets/skills/oat-project-lite/SKILL.md +1 -1
  55. package/assets/skills/oat-project-lite/references/docs/autonomy-contract.md +151 -132
  56. package/assets/skills/oat-project-new/SKILL.md +3 -3
  57. package/assets/skills/oat-project-next/SKILL.md +45 -15
  58. package/assets/skills/oat-project-next/references/docs/gate-approval-record.md +9 -0
  59. package/assets/skills/oat-project-plan/SKILL.md +14 -3
  60. package/assets/skills/oat-project-plan-writing/SKILL.md +6 -4
  61. package/assets/skills/oat-project-pr-final/SKILL.md +51 -1
  62. package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +151 -132
  63. package/assets/skills/oat-project-pr-progress/SKILL.md +97 -3
  64. package/assets/skills/oat-project-progress/SKILL.md +38 -5
  65. package/assets/skills/oat-project-progress/references/docs/gate-approval-record.md +9 -0
  66. package/assets/skills/oat-project-quick-start/SKILL.md +14 -9
  67. package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +151 -132
  68. package/assets/skills/oat-project-quick-start/references/docs/gate-approval-record.md +9 -0
  69. package/assets/skills/oat-project-reconcile/SKILL.md +31 -2
  70. package/assets/skills/oat-project-review-provide/SKILL.md +79 -13
  71. package/assets/skills/oat-project-review-receive/SKILL.md +70 -1
  72. package/assets/skills/oat-project-revise/SKILL.md +25 -1
  73. package/assets/skills/oat-project-summary/SKILL.md +60 -5
  74. package/assets/skills/oat-wave-execute/SKILL.md +2 -2
  75. package/assets/templates/state.md +1 -1
  76. package/dist/commands/project/closeout-check/closeout-invariant.d.ts +119 -3
  77. package/dist/commands/project/closeout-check/closeout-invariant.d.ts.map +1 -1
  78. package/dist/commands/project/closeout-check/closeout-invariant.js +454 -2
  79. package/dist/commands/project/closeout-check/index.d.ts +21 -3
  80. package/dist/commands/project/closeout-check/index.d.ts.map +1 -1
  81. package/dist/commands/project/closeout-check/index.js +190 -4
  82. package/dist/commands/project/complete-state/index.d.ts +2 -1
  83. package/dist/commands/project/complete-state/index.d.ts.map +1 -1
  84. package/dist/commands/project/complete-state/index.js +7 -1
  85. package/dist/commands/project/delivery/check.d.ts +66 -0
  86. package/dist/commands/project/delivery/check.d.ts.map +1 -0
  87. package/dist/commands/project/delivery/check.js +232 -0
  88. package/dist/commands/project/delivery/evidence.d.ts +171 -0
  89. package/dist/commands/project/delivery/evidence.d.ts.map +1 -0
  90. package/dist/commands/project/delivery/evidence.js +902 -0
  91. package/dist/commands/project/delivery/index.d.ts +7 -0
  92. package/dist/commands/project/delivery/index.d.ts.map +1 -0
  93. package/dist/commands/project/delivery/index.js +13 -0
  94. package/dist/commands/project/delivery/refresh.d.ts +110 -0
  95. package/dist/commands/project/delivery/refresh.d.ts.map +1 -0
  96. package/dist/commands/project/delivery/refresh.js +441 -0
  97. package/dist/commands/project/index.d.ts.map +1 -1
  98. package/dist/commands/project/index.js +2 -0
  99. package/dist/commands/project/log/append.d.ts +6 -0
  100. package/dist/commands/project/log/append.d.ts.map +1 -1
  101. package/dist/commands/project/log/append.js +6 -1
  102. package/dist/commands/project/new/scaffold.d.ts.map +1 -1
  103. package/dist/commands/project/new/scaffold.js +47 -1
  104. package/dist/commands/project/promote/promote.d.ts +15 -1
  105. package/dist/commands/project/promote/promote.d.ts.map +1 -1
  106. package/dist/commands/project/promote/promote.js +99 -15
  107. package/dist/commands/project/validate-plan/index.d.ts.map +1 -1
  108. package/dist/commands/project/validate-plan/index.js +10 -0
  109. package/dist/commands/project/validate-plan/profiles.d.ts +19 -4
  110. package/dist/commands/project/validate-plan/profiles.d.ts.map +1 -1
  111. package/dist/commands/project/validate-plan/profiles.js +153 -2
  112. package/dist/commands/shared/frontmatter.d.ts +1 -1
  113. package/dist/commands/shared/frontmatter.d.ts.map +1 -1
  114. package/dist/commands/shared/frontmatter.js +1 -0
  115. package/dist/commands/state/generate.d.ts.map +1 -1
  116. package/dist/commands/state/generate.js +35 -4
  117. package/dist/commands/tools/shared/pack-manifest.d.ts.map +1 -1
  118. package/dist/commands/tools/shared/pack-manifest.js +1 -0
  119. package/package.json +2 -2
@@ -0,0 +1,902 @@
1
+ import { posix } from 'node:path';
2
+ import { SYNTHESIS_HEADING_PREFIX } from '../../project/log/append.js';
3
+ import { recordLineMask, } from '@open-agent-toolkit/control-plane';
4
+ import YAML from 'yaml';
5
+ const OK = { ok: true };
6
+ function refuse(reason) {
7
+ return { ok: false, reason };
8
+ }
9
+ // ---------------------------------------------------------------------------
10
+ // Git inspection
11
+ // ---------------------------------------------------------------------------
12
+ async function resolveCommit(git, repoRoot, revision) {
13
+ const result = await git.run(['rev-parse', '--verify', '--quiet', `${revision}^{commit}`], { cwd: repoRoot, allowFailure: true });
14
+ const sha = result.stdout.trim();
15
+ return result.code === 0 && /^[0-9a-f]{40}$/.test(sha) ? sha : null;
16
+ }
17
+ async function readBlob(git, repoRoot, commit, path) {
18
+ const result = await git.run(['show', `${commit}:${path}`], {
19
+ cwd: repoRoot,
20
+ allowFailure: true,
21
+ });
22
+ return result.code === 0 ? result.stdout : null;
23
+ }
24
+ /**
25
+ * Whether `published` covers evidence reviewed at `reviewed` under the
26
+ * bookkeeping-successor contract.
27
+ */
28
+ export async function evaluateSuccessor(git, scope, reviewed, published) {
29
+ if (!reviewed || !published) {
30
+ return {
31
+ fresh: false,
32
+ code: 'missing-revision',
33
+ reason: !reviewed
34
+ ? 'no reviewed revision is recorded'
35
+ : 'no published head is observed',
36
+ };
37
+ }
38
+ let reviewedSha;
39
+ let publishedSha;
40
+ try {
41
+ reviewedSha = await resolveCommit(git, scope.repoRoot, reviewed);
42
+ publishedSha = await resolveCommit(git, scope.repoRoot, published);
43
+ }
44
+ catch (error) {
45
+ return inspectionFailed(error);
46
+ }
47
+ if (reviewedSha === null || publishedSha === null) {
48
+ return {
49
+ fresh: false,
50
+ code: 'missing-object',
51
+ reason: `commit ${reviewedSha === null ? reviewed : published} is not available locally`,
52
+ };
53
+ }
54
+ if (reviewedSha === publishedSha) {
55
+ return {
56
+ fresh: true,
57
+ code: 'identical',
58
+ reason: 'the published head is the reviewed revision',
59
+ inspected: [],
60
+ };
61
+ }
62
+ try {
63
+ const ancestry = await git.run(['merge-base', '--is-ancestor', reviewedSha, publishedSha], { cwd: scope.repoRoot, allowFailure: true });
64
+ if (ancestry.code === 1) {
65
+ return {
66
+ fresh: false,
67
+ code: 'not-ancestor',
68
+ reason: `reviewed revision ${reviewedSha} is not an ancestor of ${publishedSha}`,
69
+ };
70
+ }
71
+ if (ancestry.code !== 0) {
72
+ return {
73
+ fresh: false,
74
+ code: 'inspection-failed',
75
+ reason: `ancestry inspection failed: ${ancestry.stderr || `git exited ${ancestry.code}`}`,
76
+ };
77
+ }
78
+ const listed = await git.run([
79
+ 'rev-list',
80
+ '--reverse',
81
+ '--topo-order',
82
+ '--parents',
83
+ `${reviewedSha}..${publishedSha}`,
84
+ ], { cwd: scope.repoRoot, allowFailure: true });
85
+ if (listed.code !== 0) {
86
+ return {
87
+ fresh: false,
88
+ code: 'inspection-failed',
89
+ reason: `commit listing failed: ${listed.stderr || `git exited ${listed.code}`}`,
90
+ };
91
+ }
92
+ const inspected = [];
93
+ for (const line of listed.stdout.split('\n')) {
94
+ const [commit, firstParent] = line.trim().split(/\s+/);
95
+ if (!commit)
96
+ continue;
97
+ if (!firstParent) {
98
+ return {
99
+ fresh: false,
100
+ code: 'inspection-failed',
101
+ reason: `commit ${commit} has no parent to compare against`,
102
+ commit,
103
+ };
104
+ }
105
+ const failure = await inspectCommit(git, scope, firstParent, commit);
106
+ if (failure !== null) {
107
+ return failure;
108
+ }
109
+ inspected.push(commit);
110
+ }
111
+ return {
112
+ fresh: true,
113
+ code: 'bookkeeping-successor',
114
+ reason: `every commit after the reviewed revision is permitted bookkeeping (${inspected.length} inspected)`,
115
+ inspected,
116
+ };
117
+ }
118
+ catch (error) {
119
+ return inspectionFailed(error);
120
+ }
121
+ }
122
+ function inspectionFailed(error) {
123
+ return {
124
+ fresh: false,
125
+ code: 'inspection-failed',
126
+ reason: `inspection failed: ${error instanceof Error ? error.message : String(error)}`,
127
+ };
128
+ }
129
+ /**
130
+ * Inspect one commit against its first parent. For a merge commit this is
131
+ * the change the merge introduces into the line being published, which
132
+ * covers both the merged side and any change made in the merge itself.
133
+ */
134
+ async function inspectCommit(git, scope, parent, commit) {
135
+ const diff = await git.run(['diff', '--no-renames', '--name-status', '-z', parent, commit], { cwd: scope.repoRoot, allowFailure: true });
136
+ if (diff.code !== 0) {
137
+ return {
138
+ fresh: false,
139
+ code: 'inspection-failed',
140
+ reason: `diff inspection of ${commit} failed: ${diff.stderr || `git exited ${diff.code}`}`,
141
+ commit,
142
+ };
143
+ }
144
+ const tokens = diff.stdout.split('\0').filter((token) => token !== '');
145
+ for (let index = 0; index + 1 < tokens.length; index += 2) {
146
+ const status = tokens[index].charAt(0);
147
+ const path = tokens[index + 1];
148
+ const kind = classifyPath(scope, path);
149
+ if (kind === null) {
150
+ return {
151
+ fresh: false,
152
+ code: 'disallowed-path',
153
+ reason: `${commit} changes ${path}, which is not permitted bookkeeping`,
154
+ commit,
155
+ path,
156
+ };
157
+ }
158
+ const before = status === 'A' ? null : await readBlob(git, scope.repoRoot, parent, path);
159
+ const after = status === 'D' ? null : await readBlob(git, scope.repoRoot, commit, path);
160
+ if ((status !== 'A' && before === null) ||
161
+ (status !== 'D' && after === null)) {
162
+ return {
163
+ fresh: false,
164
+ code: 'inspection-failed',
165
+ reason: `could not read ${path} at ${status === 'A' ? commit : parent}`,
166
+ commit,
167
+ path,
168
+ };
169
+ }
170
+ const verdict = judgeBookkeepingChange(kind, status, before, after, scope);
171
+ if (!verdict.ok) {
172
+ return {
173
+ fresh: false,
174
+ code: 'substantive-change',
175
+ reason: `${commit} makes a substantive change to ${path}: ${verdict.reason}`,
176
+ commit,
177
+ path,
178
+ };
179
+ }
180
+ }
181
+ return null;
182
+ }
183
+ function projectRelative(projectDir, artifact) {
184
+ const trimmed = artifact.replace(/^\.\//, '');
185
+ const prefix = `${projectDir.replace(/\/$/, '')}/`;
186
+ return trimmed.startsWith(prefix) ? trimmed.slice(prefix.length) : trimmed;
187
+ }
188
+ function artifactVariants(artifact) {
189
+ const name = posix.basename(artifact);
190
+ if (artifact.startsWith('reviews/archived/')) {
191
+ return [artifact, `reviews/${name}`];
192
+ }
193
+ if (artifact.startsWith('reviews/')) {
194
+ return [artifact, `reviews/archived/${name}`];
195
+ }
196
+ return [artifact];
197
+ }
198
+ /**
199
+ * The bookkeeping kind of a repository-relative path, or `null` when the
200
+ * path is outside the allowlist: this project's `state.md`, `plan.md`,
201
+ * `implementation.md`, `project-log.md`, `summary.md`, and the exact review
202
+ * artifacts of the evidence being recorded or archived (including those of
203
+ * deliveries that share its member pull request).
204
+ */
205
+ export function classifyPath(scope, repoPath) {
206
+ const projectDir = scope.projectDir.replace(/\/$/, '');
207
+ if (!repoPath.startsWith(`${projectDir}/`)) {
208
+ return null;
209
+ }
210
+ const relative = repoPath.slice(projectDir.length + 1);
211
+ switch (relative) {
212
+ case 'state.md':
213
+ return 'state';
214
+ case 'plan.md':
215
+ return 'plan';
216
+ case 'implementation.md':
217
+ return 'implementation';
218
+ case 'project-log.md':
219
+ return 'project-log';
220
+ case 'summary.md':
221
+ return 'summary';
222
+ }
223
+ const permitted = scope.evidenceArtifacts.flatMap((artifact) => artifactVariants(projectRelative(projectDir, artifact)));
224
+ return permitted.includes(relative) ? 'review-artifact' : null;
225
+ }
226
+ function judgeBookkeepingChange(kind, status, before, after, scope) {
227
+ // `summary.md` is derived institutional memory, never the authority for
228
+ // outcomes, approach, constraints, decisions or endpoint (those live in the
229
+ // brief), so any change to it is bookkeeping (brief contract 12.8).
230
+ if (kind === 'summary') {
231
+ return OK;
232
+ }
233
+ if (kind === 'review-artifact') {
234
+ return status === 'A' || status === 'M' || status === 'D'
235
+ ? OK
236
+ : refuse(`unsupported change type ${status}`);
237
+ }
238
+ if (kind === 'project-log') {
239
+ if (status === 'A')
240
+ return OK;
241
+ if (status !== 'M')
242
+ return refuse('the project log may only grow');
243
+ return isProjectLogAppend(before, after)
244
+ ? OK
245
+ : refuse('the project log was rewritten, not appended');
246
+ }
247
+ if (kind === 'implementation' && status === 'A') {
248
+ return OK;
249
+ }
250
+ if (status !== 'M') {
251
+ return refuse(status === 'D' ? 'the file was deleted' : `change type ${status}`);
252
+ }
253
+ switch (kind) {
254
+ case 'state':
255
+ return judgeStateChange(before, after, scope);
256
+ case 'plan':
257
+ return judgePlanChange(before, after);
258
+ case 'implementation':
259
+ return judgeJournalChange(before, after);
260
+ }
261
+ }
262
+ /**
263
+ * Whether `after` only adds entries to the project log `before`. `oat project
264
+ * log append` writes at the end of the log, or, once the log carries its
265
+ * end-of-run synthesis section, immediately before that section; every byte
266
+ * of the previous log is kept either way. Insertion anywhere else, a change to
267
+ * an existing entry, or a change to the synthesis section is a rewrite.
268
+ */
269
+ export function isProjectLogAppend(before, after) {
270
+ const synthesisIndex = before.indexOf(SYNTHESIS_HEADING_PREFIX);
271
+ if (synthesisIndex < 0) {
272
+ // No synthesis section yet: the writer appends at the end of the log.
273
+ return after.startsWith(before.trimEnd());
274
+ }
275
+ // Once a synthesis section exists, only the insert-before form is an
276
+ // append; text added at the tail lands inside the synthesis and rewrites it.
277
+ const entries = before.slice(0, synthesisIndex);
278
+ const synthesis = before.slice(synthesisIndex);
279
+ if (after.length < before.length ||
280
+ !after.startsWith(entries) ||
281
+ !after.endsWith(synthesis)) {
282
+ return false;
283
+ }
284
+ const inserted = after.slice(entries.length, after.length - synthesis.length);
285
+ return !inserted.includes(SYNTHESIS_HEADING_PREFIX);
286
+ }
287
+ // --- shared helpers --------------------------------------------------------
288
+ const FRONTMATTER_PATTERN = /^---\n([\s\S]*?)\n---(?:\n|$)/;
289
+ function splitFrontmatter(content) {
290
+ const match = content.match(FRONTMATTER_PATTERN);
291
+ if (!match) {
292
+ return { data: {}, body: content };
293
+ }
294
+ try {
295
+ const parsed = YAML.parse(match[1] ?? '');
296
+ const data = parsed === null || parsed === undefined
297
+ ? {}
298
+ : isRecord(parsed)
299
+ ? parsed
300
+ : null;
301
+ return data === null
302
+ ? null
303
+ : { data, body: content.slice(match[0].length) };
304
+ }
305
+ catch {
306
+ return null;
307
+ }
308
+ }
309
+ function isRecord(value) {
310
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
311
+ }
312
+ function stable(value) {
313
+ if (Array.isArray(value)) {
314
+ return `[${value.map(stable).join(',')}]`;
315
+ }
316
+ if (isRecord(value)) {
317
+ return `{${Object.keys(value)
318
+ .sort()
319
+ .map((key) => `${JSON.stringify(key)}:${stable(value[key])}`)
320
+ .join(',')}}`;
321
+ }
322
+ return JSON.stringify(value ?? null);
323
+ }
324
+ function isUnset(value) {
325
+ return (value === undefined ||
326
+ value === null ||
327
+ value === '' ||
328
+ (Array.isArray(value) && value.length === 0));
329
+ }
330
+ /** A set-once field may go from unset to a value; any later change is not bookkeeping. */
331
+ function setOnce(before, after) {
332
+ return isUnset(before) || stable(before) === stable(after);
333
+ }
334
+ // --- state.md --------------------------------------------------------------
335
+ /** State keys whose change requires reevaluation, never bookkeeping. */
336
+ const PROTECTED_STATE_KEYS = ['oat_workflow_mode', 'oat_skill_gate_overrides'];
337
+ const DELIVERY_SET_ONCE_FIELDS = [
338
+ 'milestone',
339
+ 'scope',
340
+ 'endpoint',
341
+ 'kind',
342
+ 'covers_scope_of',
343
+ 'corrects_delivery_id',
344
+ 'lookup',
345
+ 'endpoint_attainment',
346
+ ];
347
+ function judgeStateChange(before, after, scope) {
348
+ const previous = splitFrontmatter(before);
349
+ const next = splitFrontmatter(after);
350
+ if (previous === null || next === null) {
351
+ return refuse('state.md frontmatter could not be parsed');
352
+ }
353
+ for (const key of PROTECTED_STATE_KEYS) {
354
+ if (stable(previous.data[key]) !== stable(next.data[key])) {
355
+ return refuse(`${key} changed`);
356
+ }
357
+ }
358
+ const deliveries = judgeDeliveries(previous.data['oat_deliveries'], next.data['oat_deliveries'], scope);
359
+ if (!deliveries.ok)
360
+ return deliveries;
361
+ return judgeMilestoneGates(previous.data['oat_milestone_gates'], next.data['oat_milestone_gates']);
362
+ }
363
+ function deliveryMap(value) {
364
+ const entries = new Map();
365
+ for (const entry of Array.isArray(value) ? value : []) {
366
+ if (isRecord(entry) && typeof entry['delivery_id'] === 'string') {
367
+ entries.set(entry['delivery_id'], entry);
368
+ }
369
+ }
370
+ return entries;
371
+ }
372
+ function judgeDeliveries(beforeValue, afterValue, scope) {
373
+ const after = deliveryMap(afterValue);
374
+ const evidence = new Set(scope.evidenceArtifacts.flatMap((artifact) => artifactVariants(projectRelative(scope.projectDir, artifact))));
375
+ const before = deliveryMap(beforeValue);
376
+ const ownArtifactsFor = (id) => new Set((scope.ownEvidenceArtifacts?.get(id) ?? []).flatMap((artifact) => artifactVariants(projectRelative(scope.projectDir, artifact))));
377
+ for (const [id, previous] of before) {
378
+ const next = after.get(id);
379
+ if (next === undefined) {
380
+ return refuse(`delivery ${id} was removed from the registry`);
381
+ }
382
+ for (const field of DELIVERY_SET_ONCE_FIELDS) {
383
+ if (!setOnce(previous[field], next[field])) {
384
+ return refuse(`delivery ${id} changed its recorded ${field}`);
385
+ }
386
+ }
387
+ const review = previous['integrated_review'];
388
+ const nextReview = next['integrated_review'];
389
+ const nextArtifact = isRecord(nextReview) && typeof nextReview['artifact'] === 'string'
390
+ ? projectRelative(scope.projectDir, nextReview['artifact'])
391
+ : null;
392
+ const recordsEvidence = nextArtifact !== null && evidence.has(nextArtifact);
393
+ // The review record this commit writes is new evidence only when it names
394
+ // a new artifact: a re-review always records a new timestamped artifact,
395
+ // and scope or bound_revisions changes ride on that artifact change.
396
+ // Moving the same artifact to its archived counterpart is not new.
397
+ const sameArtifact = isRecord(review) &&
398
+ nextArtifact !== null &&
399
+ typeof review['artifact'] === 'string' &&
400
+ artifactVariants(projectRelative(scope.projectDir, review['artifact'])).includes(nextArtifact);
401
+ // ...and only when that artifact is this delivery's own, never a sharing
402
+ // sibling's (which the allowlist admits as bookkeeping).
403
+ const ownArtifact = nextArtifact !== null && ownArtifactsFor(id).has(nextArtifact);
404
+ const recordsNewReview = recordsEvidence && !sameArtifact && ownArtifact;
405
+ const previousMembers = Array.isArray(previous['members'])
406
+ ? previous['members']
407
+ : [];
408
+ const nextMembers = Array.isArray(next['members']) ? next['members'] : [];
409
+ for (const [index, member] of previousMembers.entries()) {
410
+ const successor = nextMembers[index];
411
+ if (!isRecord(member))
412
+ continue;
413
+ if (!isRecord(successor)) {
414
+ return refuse(`delivery ${id} lost member ${index}`);
415
+ }
416
+ for (const field of ['url', 'base']) {
417
+ if (!setOnce(member[field], successor[field])) {
418
+ return refuse(`delivery ${id} member ${index} changed its ${field}`);
419
+ }
420
+ }
421
+ // The member's reviewed revision moves only with this delivery's own new
422
+ // review artifact, and only to the head that review binds for it.
423
+ if (!setOnce(member['reviewed_head'], successor['reviewed_head']) &&
424
+ !(recordsNewReview &&
425
+ typeof successor['reviewed_head'] === 'string' &&
426
+ successor['reviewed_head'] === boundHeadFor(nextReview, index))) {
427
+ return refuse(`delivery ${id} member ${index} changed its reviewed_head without recording a new review artifact`);
428
+ }
429
+ }
430
+ if (isRecord(review)) {
431
+ if (!isRecord(nextReview)) {
432
+ return refuse(`delivery ${id} lost its integrated review evidence`);
433
+ }
434
+ // Review identity (artifact, scope, bound revisions) is set once: a first
435
+ // write from unset is registration, and only a commit recording a new
436
+ // review artifact may replace it, together with that artifact. Under the
437
+ // same artifact, scope and bound revisions never move (contract 12.8).
438
+ for (const field of REVIEW_IDENTITY_FIELDS) {
439
+ if (recordsNewReview || setOnce(review[field], nextReview[field])) {
440
+ continue;
441
+ }
442
+ if (field === 'artifact' && sameArtifact) {
443
+ continue;
444
+ }
445
+ return refuse(`delivery ${id} changed its integrated review ${field} without recording a new review artifact`);
446
+ }
447
+ }
448
+ // The delivery's exit-gate binding is persisted evidence (A2): set once,
449
+ // replaced only together with the new integrated review it accompanies.
450
+ // The `decided_at` rerun escape belongs to the milestone working carrier.
451
+ if (isRecord(previous['exit_gate']) &&
452
+ !recordsNewReview &&
453
+ !setOnce(previous['exit_gate'], next['exit_gate'])) {
454
+ return refuse(`delivery ${id} exit gate binding changed without recording new integrated review evidence`);
455
+ }
456
+ }
457
+ return OK;
458
+ }
459
+ const REVIEW_IDENTITY_FIELDS = ['artifact', 'scope', 'bound_revisions'];
460
+ /** The head a review record binds for one member, or null. */
461
+ function boundHeadFor(review, member) {
462
+ if (!isRecord(review) || !Array.isArray(review['bound_revisions'])) {
463
+ return null;
464
+ }
465
+ const bound = review['bound_revisions'].find((revision) => isRecord(revision) && revision['member'] === member);
466
+ return isRecord(bound) && typeof bound['head'] === 'string'
467
+ ? bound['head']
468
+ : null;
469
+ }
470
+ function judgeCarrierChange(before, after, label) {
471
+ if (!isRecord(before))
472
+ return OK;
473
+ if (!isRecord(after)) {
474
+ return refuse(`${label} was removed`);
475
+ }
476
+ // A rerun replaces the record with a new decision; otherwise the reviewed
477
+ // revision and the subject it binds are evidence identities.
478
+ if (stable(before['decided_at']) !== stable(after['decided_at'])) {
479
+ return OK;
480
+ }
481
+ for (const field of ['reviewed_head', 'subject']) {
482
+ if (!setOnce(before[field], after[field])) {
483
+ return refuse(`${label} changed its ${field} without a new decision`);
484
+ }
485
+ }
486
+ return OK;
487
+ }
488
+ function judgeMilestoneGates(beforeValue, afterValue) {
489
+ if (!isRecord(beforeValue))
490
+ return OK;
491
+ const after = isRecord(afterValue) ? afterValue : {};
492
+ for (const [milestone, carriers] of Object.entries(beforeValue)) {
493
+ if (!isRecord(carriers))
494
+ continue;
495
+ const next = isRecord(after[milestone]) ? after[milestone] : {};
496
+ for (const kind of ['plan', 'exit']) {
497
+ const verdict = judgeCarrierChange(carriers[kind], next[kind], `${milestone} ${kind} carrier`);
498
+ if (!verdict.ok)
499
+ return verdict;
500
+ }
501
+ }
502
+ return OK;
503
+ }
504
+ // --- plan.md ---------------------------------------------------------------
505
+ /** Plan frontmatter keys that carry routing and progress, never approved content. */
506
+ const PLAN_ROUTING_KEYS = new Set([
507
+ 'oat_status',
508
+ 'oat_ready_for',
509
+ 'oat_blockers',
510
+ 'oat_last_updated',
511
+ 'oat_phase',
512
+ 'oat_phase_status',
513
+ 'oat_generated',
514
+ ]);
515
+ /** Plan sections that record evidence; every other section is approved content. */
516
+ const PLAN_EVIDENCE_SECTIONS = new Set(['Reviews', 'Implementation Complete']);
517
+ function judgePlanChange(before, after) {
518
+ const previous = splitFrontmatter(before);
519
+ const next = splitFrontmatter(after);
520
+ if (previous === null || next === null) {
521
+ return refuse('plan.md frontmatter could not be parsed');
522
+ }
523
+ const keys = new Set([
524
+ ...Object.keys(previous.data),
525
+ ...Object.keys(next.data),
526
+ ]);
527
+ for (const key of keys) {
528
+ if (!PLAN_ROUTING_KEYS.has(key) &&
529
+ stable(previous.data[key]) !== stable(next.data[key])) {
530
+ return refuse(`frontmatter ${key} changed`);
531
+ }
532
+ }
533
+ const previousSections = planSections(previous.body);
534
+ const nextSections = planSections(next.body);
535
+ const approved = (sections) => sections
536
+ .filter((section) => !PLAN_EVIDENCE_SECTIONS.has(section.title))
537
+ .map((section) => `${section.heading}\n${normalizeBody(section.lines)}`);
538
+ const previousApproved = approved(previousSections);
539
+ const nextApproved = approved(nextSections);
540
+ if (previousApproved.length !== nextApproved.length) {
541
+ return refuse('a section was added or removed');
542
+ }
543
+ for (const [index, section] of previousApproved.entries()) {
544
+ if (section !== nextApproved[index]) {
545
+ const heading = section.split('\n')[0] || '(preamble)';
546
+ return refuse(`approved content changed under "${heading}"`);
547
+ }
548
+ }
549
+ const reviews = (sections) => sections.find((section) => section.title === 'Reviews')?.lines ?? [];
550
+ return judgeReviewRows(reviews(previousSections), reviews(nextSections));
551
+ }
552
+ function planSections(body) {
553
+ const lines = body.split('\n');
554
+ const mask = recordLineMask(lines);
555
+ const sections = [{ heading: '', title: '', lines: [] }];
556
+ for (const [index, line] of lines.entries()) {
557
+ const heading = mask[index] ? line.match(/^##[ \t]+(.+?)[ \t]*$/) : null;
558
+ if (heading) {
559
+ sections.push({ heading: line.trimEnd(), title: heading[1], lines: [] });
560
+ continue;
561
+ }
562
+ sections[sections.length - 1].lines.push(line);
563
+ }
564
+ return sections;
565
+ }
566
+ function normalizeBody(lines) {
567
+ return lines
568
+ .map((line) => line.trimEnd())
569
+ .join('\n')
570
+ .trim();
571
+ }
572
+ function readReviewRows(lines) {
573
+ const rows = lines
574
+ .map((line) => line.trim())
575
+ .filter((line) => line.startsWith('|'))
576
+ .map((line) => line
577
+ .replace(/^\|/, '')
578
+ .replace(/\|$/, '')
579
+ .split('|')
580
+ .map((cell) => cell.trim()));
581
+ const [header, , ...body] = rows;
582
+ if (!header)
583
+ return [];
584
+ const column = (name) => header.findIndex((cell) => cell.toLowerCase() === name);
585
+ const scope = column('scope');
586
+ const type = column('type');
587
+ const artifact = column('artifact');
588
+ const reviewedHead = column('reviewed head');
589
+ return body.map((cells) => ({
590
+ scope: cells[scope] ?? '',
591
+ type: cells[type] ?? '',
592
+ artifact: artifact < 0 ? '' : (cells[artifact] ?? ''),
593
+ reviewedHead: reviewedHead < 0 ? '' : (cells[reviewedHead] ?? ''),
594
+ }));
595
+ }
596
+ function placeholder(cell) {
597
+ return cell === '' || cell === '-' || cell === '—';
598
+ }
599
+ /**
600
+ * Review rows record evidence: rows may be added and their status, date and
601
+ * lineage cells updated, but no row disappears and an existing row's
602
+ * identity (scope, type, artifact) and reviewed revision never change, except
603
+ * that an artifact may be set once or moved to its archived counterpart.
604
+ */
605
+ function judgeReviewRows(beforeLines, afterLines) {
606
+ const remaining = readReviewRows(afterLines);
607
+ for (const row of readReviewRows(beforeLines)) {
608
+ const index = remaining.findIndex((candidate) => candidate.scope === row.scope &&
609
+ candidate.type === row.type &&
610
+ (placeholder(row.reviewedHead) ||
611
+ candidate.reviewedHead === row.reviewedHead) &&
612
+ (placeholder(row.artifact) ||
613
+ artifactVariants(row.artifact).includes(candidate.artifact)));
614
+ if (index < 0) {
615
+ return refuse(`the ${row.scope} ${row.type} review row lost or changed its identity or reviewed head`);
616
+ }
617
+ remaining.splice(index, 1);
618
+ }
619
+ return OK;
620
+ }
621
+ // --- implementation.md -----------------------------------------------------
622
+ const PROGRESS_FIELD = /^(?:-[ \t]+)?\*\*(Status|Head|Completed|Updated|Last Updated|Started|Total):\*\*\s*(.*)$/;
623
+ const PLACEHOLDER_FIELD = /^(?:-[ \t]+)?\*\*[^*\n]+:\*\*\s*(?:-|—|pending|tbd|null)?\s*$/i;
624
+ const EXTRACTION_MARKER = /^\*\*(?:Status:\*\*\s+extracted|Extracted to:\*\*)/;
625
+ /**
626
+ * The journal is append-mostly: progress fields and placeholders may be
627
+ * rewritten and tables re-rendered, but no recorded observation is removed
628
+ * or rewritten, and marking a phase extracted changes scope.
629
+ */
630
+ function judgeJournalChange(before, after) {
631
+ const previous = splitFrontmatter(before);
632
+ const next = splitFrontmatter(after);
633
+ if (previous === null || next === null) {
634
+ return refuse('implementation.md frontmatter could not be parsed');
635
+ }
636
+ const remaining = new Map();
637
+ for (const line of next.body.split('\n')) {
638
+ const key = line.trimEnd();
639
+ remaining.set(key, (remaining.get(key) ?? 0) + 1);
640
+ }
641
+ const previousLines = new Set();
642
+ for (const line of previous.body.split('\n')) {
643
+ const key = line.trimEnd();
644
+ previousLines.add(key.trim());
645
+ const count = remaining.get(key) ?? 0;
646
+ if (count > 0) {
647
+ remaining.set(key, count - 1);
648
+ continue;
649
+ }
650
+ const trimmed = key.trim();
651
+ const progress = trimmed.match(PROGRESS_FIELD);
652
+ const mutable = trimmed === '' ||
653
+ trimmed.startsWith('|') ||
654
+ PLACEHOLDER_FIELD.test(trimmed) ||
655
+ (progress !== null &&
656
+ !(progress[1] === 'Status' && /^extracted\b/.test(progress[2] ?? '')));
657
+ if (!mutable) {
658
+ return refuse(`recorded journal line "${trimmed}" was removed or rewritten`);
659
+ }
660
+ }
661
+ for (const line of next.body.split('\n')) {
662
+ const trimmed = line.trim();
663
+ if (EXTRACTION_MARKER.test(trimmed) && !previousLines.has(trimmed)) {
664
+ return refuse(`"${trimmed}" marks a phase extracted`);
665
+ }
666
+ }
667
+ return OK;
668
+ }
669
+ const UNREVIEWED_RESOLUTIONS = new Set(['no_gate', 'project_disabled']);
670
+ function isUnreviewedResolution(carrier) {
671
+ return (UNREVIEWED_RESOLUTIONS.has(carrier.resolution ?? '') ||
672
+ UNREVIEWED_RESOLUTIONS.has(carrier.disposition ?? ''));
673
+ }
674
+ function sameScope(left, right) {
675
+ return (left.length === right.length &&
676
+ [...left].sort().join(',') === [...right].sort().join(','));
677
+ }
678
+ /**
679
+ * Evaluate one gate carrier (A2). The outcome is the recorded status; the
680
+ * freshness asks whether that resolution still applies: a `no_gate` or
681
+ * `project_disabled` carrier has no reviewed revision and is bound by its
682
+ * subject, so a changed configuration, override or subject makes it stale and
683
+ * a fabricated `reviewed_head` makes it invalid. A configured code carrier
684
+ * keeps its actual reviewed revision and must cover every published head as
685
+ * a bookkeeping successor. A plan carrier binds the approved brief and
686
+ * membership at activation, so implementation commits never stale it.
687
+ */
688
+ export async function evaluateGateCarrier(carrier, expected, successor) {
689
+ if (carrier === null) {
690
+ return {
691
+ outcome: 'missing',
692
+ freshness: 'stale',
693
+ reason: 'no carrier is recorded',
694
+ };
695
+ }
696
+ const outcome = carrier.status === 'allowed' ? 'allowed' : 'blocked';
697
+ const stale = (reason) => ({
698
+ outcome,
699
+ freshness: 'stale',
700
+ reason,
701
+ });
702
+ const unreviewed = isUnreviewedResolution(carrier);
703
+ if (unreviewed && carrier.reviewed_head !== null) {
704
+ return {
705
+ outcome: 'invalid',
706
+ freshness: 'stale',
707
+ reason: `a ${carrier.resolution ?? carrier.disposition} carrier records reviewed_head ${carrier.reviewed_head}, but no review occurred`,
708
+ };
709
+ }
710
+ if (carrier.subject === null) {
711
+ return stale('subject-missing: the carrier binds no subject');
712
+ }
713
+ const bound = carrier.subject.scope;
714
+ const scopeMatches = expected.match === 'covers'
715
+ ? expected.scope.every((phaseId) => bound.includes(phaseId))
716
+ : sameScope(bound, expected.scope);
717
+ if (carrier.subject.kind !== expected.kind || !scopeMatches) {
718
+ return stale(`subject-changed: the carrier binds ${carrier.subject.kind} [${carrier.subject.scope.join(', ')}], not ${expected.kind} [${expected.scope.join(', ')}]`);
719
+ }
720
+ const projectDisabled = carrier.resolution === 'project_disabled' ||
721
+ carrier.disposition === 'project_disabled';
722
+ if (expected.overrideDisabled !== undefined &&
723
+ expected.overrideDisabled !== null &&
724
+ expected.overrideDisabled !== projectDisabled) {
725
+ return stale(`override-changed: the project override ${expected.overrideDisabled ? 'now disables' : 'no longer disables'} this gate`);
726
+ }
727
+ if (expected.resolution !== undefined &&
728
+ expected.resolution !== null &&
729
+ expected.resolution !== carrier.resolution) {
730
+ return stale(`configuration-changed: the gate now resolves as ${expected.resolution}, not ${carrier.resolution ?? 'unrecorded'}`);
731
+ }
732
+ if (expected.configFingerprint !== undefined &&
733
+ expected.configFingerprint !== carrier.config_fingerprint) {
734
+ return stale('configuration-changed: the gate configuration fingerprint changed');
735
+ }
736
+ if (unreviewed || expected.kind === 'plan') {
737
+ return { outcome, freshness: 'fresh', reason: null };
738
+ }
739
+ if (carrier.reviewed_head === null) {
740
+ return stale('reviewed-head-missing: a configured gate records no reviewed revision');
741
+ }
742
+ if (successor === undefined) {
743
+ return stale('unobserved: no published head was supplied');
744
+ }
745
+ for (const head of successor.publishedHeads) {
746
+ const result = await evaluateSuccessor(successor.git, successor.scope, carrier.reviewed_head, head);
747
+ if (!result.fresh) {
748
+ return stale(`${result.code}: ${result.reason}`);
749
+ }
750
+ }
751
+ return { outcome, freshness: 'fresh', reason: null };
752
+ }
753
+ /**
754
+ * Evaluate a registered delivery's integrated review against its observed
755
+ * members (A2): each member's reviewed revision must cover its observed head
756
+ * as a bookkeeping successor, and a member base change invalidates the
757
+ * evidence independently. The review must bind every phase of
758
+ * `requiredScope`. Historical deliveries are judged against their own
759
+ * observed revisions, never the branch's latest head.
760
+ */
761
+ export async function evaluateDeliveryReview(git, scope, delivery, requiredScope) {
762
+ const review = delivery.integrated_review;
763
+ if (review === null || review.artifact === null) {
764
+ return {
765
+ artifact: null,
766
+ disposition: review?.disposition ?? null,
767
+ fresh: false,
768
+ reason: 'review-missing: no integrated review is recorded',
769
+ members: [],
770
+ };
771
+ }
772
+ const result = (fresh, reason, members = []) => ({
773
+ artifact: review.artifact,
774
+ disposition: review.disposition,
775
+ fresh,
776
+ reason,
777
+ members,
778
+ });
779
+ const unbound = requiredScope.filter((phaseId) => !review.scope.includes(phaseId));
780
+ if (unbound.length > 0) {
781
+ return result(false, `review-scope-narrow: the integrated review does not bind ${unbound.join(', ')}`);
782
+ }
783
+ if (delivery.members.length === 0) {
784
+ return result(false, 'member-missing: the delivery has no members');
785
+ }
786
+ const members = [];
787
+ for (const [index, member] of delivery.members.entries()) {
788
+ const bound = review.bound_revisions.find((revision) => revision.member === index);
789
+ // The member's reviewed_head is set once at registration and is
790
+ // authoritative: a bound revision that disagrees with it never relocates
791
+ // the inspected range.
792
+ if (bound?.head !== undefined &&
793
+ bound.head !== null &&
794
+ member.reviewed_head !== null &&
795
+ bound.head !== member.reviewed_head) {
796
+ members.push({
797
+ member: index,
798
+ fresh: false,
799
+ reason: `bound-revision-mismatch: bound revision ${bound.head} differs from the member's reviewed_head ${member.reviewed_head}`,
800
+ });
801
+ continue;
802
+ }
803
+ const reviewedHead = member.reviewed_head ?? bound?.head ?? null;
804
+ const reviewedBase = bound?.base ?? member.base;
805
+ let verdict;
806
+ if (member.observed_base === null || member.observed_head === null) {
807
+ verdict = {
808
+ fresh: false,
809
+ reason: 'member-unobserved: run delivery refresh',
810
+ };
811
+ }
812
+ else if (member.base === null ||
813
+ member.observed_base !== member.base ||
814
+ (reviewedBase !== null && reviewedBase !== member.base)) {
815
+ verdict = {
816
+ fresh: false,
817
+ reason: `base-changed: member base ${member.base ?? 'unrecorded'} is now ${member.observed_base}`,
818
+ };
819
+ }
820
+ else {
821
+ const successor = await evaluateSuccessor(git, scope, reviewedHead, member.observed_head);
822
+ verdict = successor.fresh
823
+ ? { fresh: true, reason: null }
824
+ : { fresh: false, reason: `${successor.code}: ${successor.reason}` };
825
+ }
826
+ members.push({ member: index, ...verdict });
827
+ }
828
+ const failed = members.find((member) => !member.fresh);
829
+ return failed
830
+ ? result(false, `member ${failed.member}: ${failed.reason}`, members)
831
+ : result(true, null, members);
832
+ }
833
+ /**
834
+ * Review artifacts a delivery's evidence records, for the bookkeeping
835
+ * allowlist. `sharing` lists the deliveries that share one of its member pull
836
+ * requests (terminal deliveries registered from one head branch, brief
837
+ * contract 12.7 "Shared members"): their review artifacts are recorded on the
838
+ * same member head, so they are bookkeeping for this delivery as well. Each
839
+ * delivery is still judged on its own frozen scope and bound revisions.
840
+ */
841
+ export function deliveryEvidenceArtifacts(delivery, reviewRowArtifacts = [], sharing = []) {
842
+ const artifacts = new Set(reviewRowArtifacts.filter((artifact) => !placeholder(artifact)));
843
+ for (const entry of [delivery, ...sharing]) {
844
+ if (entry.integrated_review?.artifact) {
845
+ artifacts.add(entry.integrated_review.artifact);
846
+ }
847
+ }
848
+ return [...artifacts];
849
+ }
850
+ /** Code review rows whose Scope cell names a label (the bare id or `<id>: …`). */
851
+ export function reviewRowsFor(reviews, labels) {
852
+ const ids = labels.map((label) => label.toLowerCase());
853
+ return reviews.filter((review) => {
854
+ const cell = review.scope.trim().toLowerCase();
855
+ return (review.type === 'code' &&
856
+ ids.some((id) => cell === id || cell.startsWith(`${id}:`)));
857
+ });
858
+ }
859
+ /**
860
+ * The one evidence scope for a registered delivery, shared by `delivery
861
+ * check`, `delivery refresh` and the closeout boundary: the review rows
862
+ * labelled with the delivery, its milestone and every delivery sharing one of
863
+ * its member pull requests, plus their integrated-review artifacts.
864
+ */
865
+ export function deliveryEvidenceScope(project, deliveries, delivery) {
866
+ const sharing = deliveriesSharingMembers(deliveries, delivery);
867
+ const labels = [
868
+ delivery.delivery_id,
869
+ ...(delivery.milestone === null ? [] : [delivery.milestone]),
870
+ ...sharing.map((entry) => entry.delivery_id),
871
+ ];
872
+ // Own artifacts come from the delivery's own Reviews rows only, never from
873
+ // `integrated_review.artifact` (the pointer a judged commit may rewrite).
874
+ const ownEvidenceArtifacts = new Map([delivery, ...sharing].map((entry) => [
875
+ entry.delivery_id,
876
+ reviewRowsFor(project.state.reviews, [
877
+ entry.delivery_id,
878
+ ...(entry.milestone === null ? [] : [entry.milestone]),
879
+ ])
880
+ .map((row) => row.artifact)
881
+ .filter((artifact) => !placeholder(artifact)),
882
+ ]));
883
+ return {
884
+ repoRoot: project.repoRoot,
885
+ projectDir: project.projectDir,
886
+ evidenceArtifacts: deliveryEvidenceArtifacts(delivery, reviewRowsFor(project.state.reviews, labels).map((row) => row.artifact), sharing),
887
+ ownEvidenceArtifacts,
888
+ };
889
+ }
890
+ /**
891
+ * The other registered deliveries that share at least one member pull request
892
+ * URL with `delivery` (brief contract 12.7 "Shared members").
893
+ */
894
+ export function deliveriesSharingMembers(deliveries, delivery) {
895
+ const urls = new Set(delivery.members
896
+ .map((member) => member.url)
897
+ .filter((url) => typeof url === 'string' && url !== ''));
898
+ if (urls.size === 0)
899
+ return [];
900
+ return deliveries.filter((entry) => entry.delivery_id !== delivery.delivery_id &&
901
+ entry.members.some((member) => member.url !== null && urls.has(member.url)));
902
+ }