@open-agent-toolkit/cli 0.3.19 → 0.3.21
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 +2 -1
- package/assets/agents/oat-phase-implementer.md +26 -3
- package/assets/agents/oat-reviewer.md +8 -3
- package/assets/bundle-metadata.json +1 -1
- package/assets/docs/reference/cli-reference.md +12 -7
- package/assets/docs/reference/oat-directory-structure.md +1 -0
- package/assets/docs/reference/project-artifacts.md +56 -1
- package/assets/docs/reference/project-state-machine.md +41 -7
- package/assets/docs/skills/brainstorm.md +3 -2
- package/assets/docs/skills/index.md +13 -10
- package/assets/docs/workflows/advanced/autonomy.md +3 -1
- package/assets/docs/workflows/advanced/dispatch-ceiling.md +2 -2
- package/assets/docs/workflows/advanced/workflow-gates.md +17 -4
- package/assets/docs/workflows/approvals-and-automation.md +21 -10
- package/assets/docs/workflows/choose-workflow.md +17 -9
- package/assets/docs/workflows/ideas/index.md +1 -1
- package/assets/docs/workflows/ideas/lifecycle.md +3 -2
- package/assets/docs/workflows/projects/closeout/closeout-skills.md +80 -7
- package/assets/docs/workflows/projects/closeout/pr-flow.md +7 -1
- package/assets/docs/workflows/projects/execution/execution-skills.md +16 -3
- package/assets/docs/workflows/projects/execution/implementation-execution.md +1 -1
- package/assets/docs/workflows/projects/execution/picking-up-projects.md +11 -1
- package/assets/docs/workflows/projects/lifecycle.md +108 -9
- package/assets/docs/workflows/projects/planning/hill-checkpoints.md +6 -6
- package/assets/docs/workflows/projects/planning/planning-skills.md +9 -7
- package/assets/docs/workflows/projects/planning/starting-projects.md +57 -11
- package/assets/docs/workflows/projects/reviews/index.md +22 -1
- package/assets/docs/workflows/projects/reviews/review-flavors.md +5 -3
- package/assets/public-package-versions.json +4 -4
- package/assets/skills/oat-brainstorm/SKILL.md +34 -9
- package/assets/skills/oat-brainstorm/references/destinations.md +6 -3
- package/assets/skills/oat-dispatch-subagents/SKILL.md +1 -1
- package/assets/skills/oat-dispatch-subagents/references/record-schema.md +6 -0
- package/assets/skills/oat-docs/SKILL.md +7 -3
- package/assets/skills/oat-pjm-review-backlog/SKILL.md +2 -2
- package/assets/skills/oat-project-autonomous/SKILL.md +9 -3
- package/assets/skills/oat-project-autonomous/references/gate-inventory.md +151 -132
- package/assets/skills/oat-project-brief/SKILL.md +1 -1
- package/assets/skills/oat-project-brief/references/brief-contract.md +465 -9
- package/assets/skills/oat-project-brief/references/docs/autonomy-contract.md +151 -132
- package/assets/skills/oat-project-brief/references/docs/gate-approval-record.md +9 -0
- package/assets/skills/oat-project-capture/SKILL.md +2 -2
- package/assets/skills/oat-project-complete/SKILL.md +14 -5
- package/assets/skills/oat-project-discover/SKILL.md +3 -2
- package/assets/skills/oat-project-document/SKILL.md +12 -6
- package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +151 -132
- package/assets/skills/oat-project-goal/SKILL.md +697 -0
- package/assets/skills/oat-project-goal/references/docs/autonomy-contract.md +412 -0
- package/assets/skills/oat-project-goal/references/docs/gate-approval-record.md +93 -0
- package/assets/skills/oat-project-implement/SKILL.md +1 -1
- package/assets/skills/oat-project-implement/references/completion-and-closeout.md +67 -0
- package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +151 -132
- package/assets/skills/oat-project-implement/references/docs/gate-approval-record.md +9 -0
- package/assets/skills/oat-project-implement/references/phase-execution.md +10 -3
- package/assets/skills/oat-project-implement/references/plan-and-resume.md +28 -1
- package/assets/skills/oat-project-lite/SKILL.md +1 -1
- package/assets/skills/oat-project-lite/references/docs/autonomy-contract.md +151 -132
- package/assets/skills/oat-project-new/SKILL.md +3 -3
- package/assets/skills/oat-project-next/SKILL.md +45 -15
- package/assets/skills/oat-project-next/references/docs/gate-approval-record.md +9 -0
- package/assets/skills/oat-project-plan/SKILL.md +14 -3
- package/assets/skills/oat-project-plan-writing/SKILL.md +6 -4
- package/assets/skills/oat-project-pr-final/SKILL.md +51 -1
- package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +151 -132
- package/assets/skills/oat-project-pr-progress/SKILL.md +97 -3
- package/assets/skills/oat-project-progress/SKILL.md +38 -5
- package/assets/skills/oat-project-progress/references/docs/gate-approval-record.md +9 -0
- package/assets/skills/oat-project-quick-start/SKILL.md +14 -9
- package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +151 -132
- package/assets/skills/oat-project-quick-start/references/docs/gate-approval-record.md +9 -0
- package/assets/skills/oat-project-reconcile/SKILL.md +31 -2
- package/assets/skills/oat-project-review-provide/SKILL.md +79 -13
- package/assets/skills/oat-project-review-receive/SKILL.md +70 -1
- package/assets/skills/oat-project-revise/SKILL.md +25 -1
- package/assets/skills/oat-project-summary/SKILL.md +60 -5
- package/assets/skills/oat-wave-execute/SKILL.md +2 -2
- package/assets/templates/state.md +1 -1
- package/dist/commands/project/closeout-check/closeout-invariant.d.ts +119 -3
- package/dist/commands/project/closeout-check/closeout-invariant.d.ts.map +1 -1
- package/dist/commands/project/closeout-check/closeout-invariant.js +454 -2
- package/dist/commands/project/closeout-check/index.d.ts +21 -3
- package/dist/commands/project/closeout-check/index.d.ts.map +1 -1
- package/dist/commands/project/closeout-check/index.js +190 -4
- package/dist/commands/project/complete-state/index.d.ts +2 -1
- package/dist/commands/project/complete-state/index.d.ts.map +1 -1
- package/dist/commands/project/complete-state/index.js +7 -1
- package/dist/commands/project/delivery/check.d.ts +66 -0
- package/dist/commands/project/delivery/check.d.ts.map +1 -0
- package/dist/commands/project/delivery/check.js +232 -0
- package/dist/commands/project/delivery/evidence.d.ts +171 -0
- package/dist/commands/project/delivery/evidence.d.ts.map +1 -0
- package/dist/commands/project/delivery/evidence.js +902 -0
- package/dist/commands/project/delivery/index.d.ts +7 -0
- package/dist/commands/project/delivery/index.d.ts.map +1 -0
- package/dist/commands/project/delivery/index.js +13 -0
- package/dist/commands/project/delivery/refresh.d.ts +110 -0
- package/dist/commands/project/delivery/refresh.d.ts.map +1 -0
- package/dist/commands/project/delivery/refresh.js +441 -0
- package/dist/commands/project/index.d.ts.map +1 -1
- package/dist/commands/project/index.js +2 -0
- package/dist/commands/project/log/append.d.ts +6 -0
- package/dist/commands/project/log/append.d.ts.map +1 -1
- package/dist/commands/project/log/append.js +6 -1
- package/dist/commands/project/new/scaffold.d.ts.map +1 -1
- package/dist/commands/project/new/scaffold.js +47 -1
- package/dist/commands/project/promote/promote.d.ts +15 -1
- package/dist/commands/project/promote/promote.d.ts.map +1 -1
- package/dist/commands/project/promote/promote.js +99 -15
- package/dist/commands/project/validate-plan/index.d.ts.map +1 -1
- package/dist/commands/project/validate-plan/index.js +10 -0
- package/dist/commands/project/validate-plan/profiles.d.ts +19 -4
- package/dist/commands/project/validate-plan/profiles.d.ts.map +1 -1
- package/dist/commands/project/validate-plan/profiles.js +153 -2
- package/dist/commands/shared/frontmatter.d.ts +1 -1
- package/dist/commands/shared/frontmatter.d.ts.map +1 -1
- package/dist/commands/shared/frontmatter.js +1 -0
- package/dist/commands/state/generate.d.ts.map +1 -1
- package/dist/commands/state/generate.js +35 -4
- package/dist/commands/tools/shared/pack-manifest.d.ts.map +1 -1
- package/dist/commands/tools/shared/pack-manifest.js +1 -0
- 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
|
+
}
|