@use-the-fork/spindle 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/Assets/Blueprints/Catalog.js +7 -2
- package/build/Assets/Blueprints/agents/sdd-implementer.agent.md +6 -6
- package/build/Assets/Blueprints/agents/sdd-orchestrator.agent.md +4 -7
- package/build/Assets/Blueprints/skills/author-agent/SKILL.md +2 -2
- package/build/Assets/Blueprints/skills/author-instruction/SKILL.md +2 -2
- package/build/Assets/Blueprints/skills/author-skill/SKILL.md +2 -2
- package/build/Assets/Blueprints/skills/int-subagent-driven-dev/SKILL.md +13 -9
- package/build/Assets/Blueprints/skills/int-subagent-driven-dev/assets/implementer-prompt.md +2 -2
- package/build/Assets/Blueprints/skills/int-subagent-driven-dev/references/example-workflow.md +11 -9
- package/build/Assets/Blueprints/skills/int-subagent-driven-dev/references/full-spec.md +33 -13
- package/build/Assets/Blueprints/skills/int-subagent-driven-dev/references/review-inputs.md +7 -7
- package/build/Assets/Blueprints/skills/{asd-ste100 → ref-asd-ste100}/SKILL.md +1 -1
- package/build/Assets/Blueprints/skills/sdd-author-design/SKILL.md +2 -2
- package/build/Assets/Blueprints/skills/sdd-implement-spec/SKILL.md +4 -6
- package/build/Assets/Templates/project/ticket-tracker.md +10 -2
- package/build/SubagentDrivenDevelopment/Commands/ChecksRecordCommand.js +27 -5
- package/build/SubagentDrivenDevelopment/Commands/ChecksRequireCommand.js +31 -7
- package/build/SubagentDrivenDevelopment/Commands/CorrectCommand.js +0 -1
- package/build/SubagentDrivenDevelopment/Commands/FinishCommand.js +2 -1
- package/build/SubagentDrivenDevelopment/Commands/ImplementCommand.js +2 -0
- package/build/SubagentDrivenDevelopment/Commands/{ClaimCommand.js → IntegrateCommand.js} +8 -7
- package/build/SubagentDrivenDevelopment/Commands/ReviewCommand.js +9 -9
- package/build/SubagentDrivenDevelopment/Commands/StartCommand.js +23 -0
- package/build/SubagentDrivenDevelopment/Commands/StatusCommand.js +5 -0
- package/build/SubagentDrivenDevelopment/Commands/groups.js +3 -2
- package/build/SubagentDrivenDevelopment/Evidence/GitEvidence.js +188 -37
- package/build/SubagentDrivenDevelopment/Models/Workflow.js +0 -2
- package/build/SubagentDrivenDevelopment/Persistence/AssignmentSchema.js +4 -0
- package/build/SubagentDrivenDevelopment/Persistence/CheckSchema.js +4 -13
- package/build/SubagentDrivenDevelopment/Persistence/LedgerRepository.js +66 -5
- package/build/SubagentDrivenDevelopment/Persistence/ProgressSchema.js +54 -4
- package/build/SubagentDrivenDevelopment/Persistence/ReviewSchema.js +4 -0
- package/build/SubagentDrivenDevelopment/Policies/AssignmentPolicy.js +0 -3
- package/build/SubagentDrivenDevelopment/Policies/CheckPolicy.js +6 -3
- package/build/SubagentDrivenDevelopment/Policies/ReadinessPolicy.js +16 -6
- package/build/SubagentDrivenDevelopment/Policies/ReviewPolicy.js +16 -2
- package/build/SubagentDrivenDevelopment/Policies/WorkflowPolicy.js +8 -8
- package/build/SubagentDrivenDevelopment/Services/AssignmentService.js +73 -51
- package/build/SubagentDrivenDevelopment/Services/ChecksService.js +376 -21
- package/build/SubagentDrivenDevelopment/Services/CorrectionService.js +83 -14
- package/build/SubagentDrivenDevelopment/Services/DeliveryCorrectionService.js +3 -7
- package/build/SubagentDrivenDevelopment/Services/DeliveryService.js +43 -38
- package/build/SubagentDrivenDevelopment/Services/FeatureCreationService.js +1 -1
- package/build/SubagentDrivenDevelopment/Services/FeatureFinalizationService.js +1202 -0
- package/build/SubagentDrivenDevelopment/Services/FeatureInspectionService.js +41 -49
- package/build/SubagentDrivenDevelopment/Services/FeatureWorktreeService.js +544 -0
- package/build/SubagentDrivenDevelopment/Services/ReadinessService.js +2 -2
- package/build/SubagentDrivenDevelopment/Services/ReviewService.js +39 -3
- package/build/SubagentDrivenDevelopment/Services/SubmissionService.js +4 -1
- package/build/SubagentDrivenDevelopment/Services/TicketIntegrationService.js +621 -0
- package/build/SubagentDrivenDevelopment/Services/TicketService.js +3 -0
- package/build/SubagentDrivenDevelopment/Services/TicketWorktreeService.js +317 -0
- package/build/SubagentDrivenDevelopment/Services/WorkflowRecordsCommitter.js +336 -0
- package/build/SubagentDrivenDevelopment/SubagentDrivenDevelopmentServiceProvider.js +18 -8
- package/build/TemplateRendering/Computed/Providers/AuthoringFlowchartProvider.js +1 -1
- package/package.json +2 -2
- package/build/SubagentDrivenDevelopment/Services/IntegrationService.js +0 -258
- /package/build/Assets/Blueprints/skills/{asd-ste100 → ref-asd-ste100}/CREDITS.MD +0 -0
- /package/build/Assets/Blueprints/skills/{asd-ste100 → ref-asd-ste100}/examples/before-after.md +0 -0
- /package/build/Assets/Blueprints/skills/{asd-ste100 → ref-asd-ste100}/references/writing-rules.md +0 -0
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
1
4
|
import { Service } from "../../Foundation/Service.js";
|
|
2
5
|
import { isSafeCoveragePath } from "../Persistence/CheckSchema.js";
|
|
3
6
|
import { validateAuthor, validateTicketId, } from "../Policies/InputValidation.js";
|
|
7
|
+
import { ReadinessPolicy } from "../Policies/ReadinessPolicy.js";
|
|
8
|
+
import { Records } from "../../Support/Records.js";
|
|
4
9
|
export class ChecksService extends Service {
|
|
5
10
|
gitEvidence;
|
|
6
11
|
ticketRepository;
|
|
@@ -18,10 +23,19 @@ export class ChecksService extends Service {
|
|
|
18
23
|
this.featureInspectionService = featureInspectionService;
|
|
19
24
|
}
|
|
20
25
|
checksRequire(options) {
|
|
26
|
+
if (options.rebaseBaseline && options.scope !== undefined)
|
|
27
|
+
throw new Error("--rebase-baseline requires ticket review scope.");
|
|
28
|
+
if (options.rebaseBaseline && (options.remove || options.details !== undefined))
|
|
29
|
+
throw new Error("--rebase-baseline cannot change check requirements.");
|
|
30
|
+
if (options.scope === "post-merge")
|
|
31
|
+
return this.postMergeRequire(options);
|
|
32
|
+
if (typeof options.command !== "string")
|
|
33
|
+
throw new Error("Exactly one command is required.");
|
|
34
|
+
const single = { ...options, command: options.command };
|
|
21
35
|
if (options.scope === "delivery") {
|
|
22
36
|
if (options.ticket)
|
|
23
37
|
throw new Error("--ticket and --scope delivery are mutually exclusive.");
|
|
24
|
-
return this.deliveryChecksRequire(
|
|
38
|
+
return this.deliveryChecksRequire(single);
|
|
25
39
|
}
|
|
26
40
|
if (options.scope !== undefined)
|
|
27
41
|
throw new Error("--scope must be 'delivery'.");
|
|
@@ -42,10 +56,17 @@ export class ChecksService extends Service {
|
|
|
42
56
|
return this.workspaceTransaction.run(feature.workspace, () => {
|
|
43
57
|
const ledger = this.ledgerRepository.readLedger(feature);
|
|
44
58
|
const progress = this.workflowPolicy.requireProgress(ledger, options.ticket);
|
|
45
|
-
if (progress.execution === "
|
|
59
|
+
if (progress.execution === "integrated")
|
|
60
|
+
throw new Error("Integrated tickets use --scope post-merge for validation.");
|
|
61
|
+
const rebased = options.rebaseBaseline
|
|
62
|
+
? this.adoptRebasedTicket(feature, ledger, progress, options.author, command)
|
|
63
|
+
: false;
|
|
64
|
+
if (!rebased &&
|
|
65
|
+
progress.execution === "reviewing" &&
|
|
66
|
+
(progress.reviewAssignments?.length ?? 0) > 0) {
|
|
46
67
|
throw new Error("The review snapshot is frozen; required checks cannot change while review assignments are active.");
|
|
47
68
|
}
|
|
48
|
-
const checks = (progress.checks ??= { required: [], records: []
|
|
69
|
+
const checks = (progress.checks ??= { required: [], records: [] });
|
|
49
70
|
const active = checks.required.find((requirement) => requirement.command === command && !requirement.removedAt);
|
|
50
71
|
if (options.remove) {
|
|
51
72
|
if (!active)
|
|
@@ -56,11 +77,14 @@ export class ChecksService extends Service {
|
|
|
56
77
|
}
|
|
57
78
|
else {
|
|
58
79
|
if (active) {
|
|
80
|
+
const identity = this.gitEvidence.contentIdentity(feature, this.ticketRepository.requireTicket(this.ticketRepository.readTickets(feature.workspace), options.ticket), progress.baseSha, checks, undefined, rebased ? progress.worktree?.workspace : undefined);
|
|
81
|
+
if (rebased)
|
|
82
|
+
this.workspaceTransaction.writeLedger(feature, ledger);
|
|
59
83
|
return {
|
|
60
84
|
ticket: options.ticket,
|
|
61
85
|
checks,
|
|
62
86
|
idempotent: true,
|
|
63
|
-
...
|
|
87
|
+
...identity,
|
|
64
88
|
};
|
|
65
89
|
}
|
|
66
90
|
checks.required.push({
|
|
@@ -69,37 +93,364 @@ export class ChecksService extends Service {
|
|
|
69
93
|
addedBy: options.author.trim(),
|
|
70
94
|
});
|
|
71
95
|
}
|
|
72
|
-
let invalidatedDecision;
|
|
73
|
-
if (progress.execution === "accepted" || progress.execution === "integrated") {
|
|
74
|
-
const from = progress.execution;
|
|
75
|
-
progress.execution = "reviewing";
|
|
76
|
-
const invalidation = {
|
|
77
|
-
at: new Date().toISOString(),
|
|
78
|
-
author: options.author.trim(),
|
|
79
|
-
command,
|
|
80
|
-
from,
|
|
81
|
-
to: "reviewing",
|
|
82
|
-
};
|
|
83
|
-
(checks.invalidations ??= []).push(invalidation);
|
|
84
|
-
invalidatedDecision = { from, to: "reviewing" };
|
|
85
|
-
}
|
|
86
96
|
const currentTicket = this.ticketRepository.requireTicket(this.ticketRepository.readTickets(feature.workspace), options.ticket);
|
|
87
97
|
const identity = this.gitEvidence.contentIdentity(feature, currentTicket, progress.baseSha, checks);
|
|
88
98
|
this.workspaceTransaction.writeLedger(feature, ledger);
|
|
89
99
|
return {
|
|
90
100
|
ticket: options.ticket,
|
|
91
101
|
checks,
|
|
92
|
-
...(invalidatedDecision ? { invalidatedDecision } : {}),
|
|
93
102
|
...identity,
|
|
94
103
|
};
|
|
95
104
|
});
|
|
96
105
|
}
|
|
106
|
+
adoptRebasedTicket(feature, ledger, progress, author, command) {
|
|
107
|
+
if (progress.owner !== author.trim())
|
|
108
|
+
throw new Error("Only the ticket controller may adopt a rebased baseline.");
|
|
109
|
+
if (progress.execution !== "reviewing" ||
|
|
110
|
+
progress.assignment?.status !== "submitted" ||
|
|
111
|
+
!progress.worktree)
|
|
112
|
+
throw new Error("Rebasing requires a submitted ticket in review with an isolated worktree.");
|
|
113
|
+
if (!progress.checks?.required.some((item) => item.command === command && !item.removedAt))
|
|
114
|
+
throw new Error("--rebase-baseline requires an existing required command.");
|
|
115
|
+
for (const variable of [
|
|
116
|
+
"GIT_DIR",
|
|
117
|
+
"GIT_WORK_TREE",
|
|
118
|
+
"GIT_COMMON_DIR",
|
|
119
|
+
"GIT_INDEX_FILE",
|
|
120
|
+
"GIT_OBJECT_DIRECTORY",
|
|
121
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
122
|
+
"GIT_NAMESPACE",
|
|
123
|
+
]) {
|
|
124
|
+
if (process.env[variable])
|
|
125
|
+
throw new Error(`Unset ${variable} before adopting a rebased ticket.`);
|
|
126
|
+
}
|
|
127
|
+
const root = realpathSync(this.app.workspaceRoot);
|
|
128
|
+
const git = (args, cwd = root) => this.gitEvidence.gitBuffer(args, cwd).toString("utf8").trim();
|
|
129
|
+
if (git(["symbolic-ref", "--quiet", "--short", "HEAD"]) !== `features/${feature.feature}` ||
|
|
130
|
+
realpathSync(feature.workspace) !== realpathSync(join(root, ".scratch", feature.feature)))
|
|
131
|
+
throw new Error("The canonical feature worktree and branch are required.");
|
|
132
|
+
const workspace = this.gitEvidence.ticketWorkspace(progress);
|
|
133
|
+
if (!workspace)
|
|
134
|
+
throw new Error("Ticket worktree is missing.");
|
|
135
|
+
for (const cwd of [root, workspace]) {
|
|
136
|
+
for (const operation of [
|
|
137
|
+
"MERGE_HEAD",
|
|
138
|
+
"CHERRY_PICK_HEAD",
|
|
139
|
+
"REVERT_HEAD",
|
|
140
|
+
"rebase-merge",
|
|
141
|
+
"rebase-apply",
|
|
142
|
+
"sequencer",
|
|
143
|
+
]) {
|
|
144
|
+
if (existsSync(resolve(cwd, git(["rev-parse", "--git-path", operation], cwd))))
|
|
145
|
+
throw new Error(`Resolve Git operation ${operation} before adopting the baseline.`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const status = git(["status", "--porcelain=v1", "--untracked-files=all", "--ignore-submodules=none"], workspace);
|
|
149
|
+
if (status)
|
|
150
|
+
throw new Error("Ticket worktree must be clean; user changes were preserved.");
|
|
151
|
+
const allowed = this.gitEvidence.workflowDeliveryRecordPaths(feature, ledger);
|
|
152
|
+
const lock = `.scratch/${feature.feature}/.sdd-tickets.lock`;
|
|
153
|
+
const dirty = this.gitEvidence
|
|
154
|
+
.allGitChangePaths()
|
|
155
|
+
.filter((path) => path !== lock && !allowed.has(path));
|
|
156
|
+
if (dirty.length)
|
|
157
|
+
throw new Error(`Feature code must be clean: ${dirty.join(", ")}.`);
|
|
158
|
+
const base = this.gitEvidence.headSha(root);
|
|
159
|
+
const head = this.gitEvidence.headSha(workspace);
|
|
160
|
+
if (head === base)
|
|
161
|
+
throw new Error("Ticket must contain a distinct reviewed code commit.");
|
|
162
|
+
git(["merge-base", "--is-ancestor", base, head], workspace);
|
|
163
|
+
if (!progress.baseSha)
|
|
164
|
+
throw new Error("Ticket baseline is missing.");
|
|
165
|
+
git(["merge-base", "--is-ancestor", progress.baseSha, base], root);
|
|
166
|
+
this.assertPrerequisiteBaseline(ledger, progress, base);
|
|
167
|
+
if (git(["diff", "--name-only", base, head, "--", ".scratch"], workspace))
|
|
168
|
+
throw new Error("Rebased ticket commits must not change canonical workflow records.");
|
|
169
|
+
if (progress.baseSha === base)
|
|
170
|
+
return false;
|
|
171
|
+
const attempt = progress.assignment.action === "correct"
|
|
172
|
+
? progress.correctionAttempts?.find((item) => item.number === progress.assignment?.attempt)
|
|
173
|
+
: undefined;
|
|
174
|
+
if (progress.assignment.action === "correct" && !attempt)
|
|
175
|
+
throw new Error("Correction attempt is missing.");
|
|
176
|
+
const previous = attempt
|
|
177
|
+
? (attempt.reviewAssignments ?? [])
|
|
178
|
+
: (progress.reviewAssignments ?? []);
|
|
179
|
+
if (previous.some((item) => item.status !== "submitted"))
|
|
180
|
+
throw new Error("Pending reviews must finish before rebasing; assignments were preserved.");
|
|
181
|
+
if (previous.length) {
|
|
182
|
+
(progress.reviewHistory ??= []).push(...previous);
|
|
183
|
+
if (attempt)
|
|
184
|
+
delete attempt.reviewAssignments;
|
|
185
|
+
else
|
|
186
|
+
delete progress.reviewAssignments;
|
|
187
|
+
}
|
|
188
|
+
(progress.baselineHistory ??= []).push({
|
|
189
|
+
from: progress.baseSha,
|
|
190
|
+
to: base,
|
|
191
|
+
ticketHead: head,
|
|
192
|
+
at: new Date().toISOString(),
|
|
193
|
+
author: author.trim(),
|
|
194
|
+
});
|
|
195
|
+
progress.baseSha = base;
|
|
196
|
+
progress.worktree.baseSha = base;
|
|
197
|
+
if (this.gitEvidence.headSha(root) !== base || this.gitEvidence.headSha(workspace) !== head)
|
|
198
|
+
throw new Error("A Git HEAD changed while adopting the baseline; retry.");
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
assertPrerequisiteBaseline(ledger, progress, baseline) {
|
|
202
|
+
for (const id of progress.blockedBy) {
|
|
203
|
+
const dependency = this.workflowPolicy.requireProgress(ledger, id);
|
|
204
|
+
const mergeSha = dependency.integration?.mergeSha;
|
|
205
|
+
if (dependency.status !== "closed" ||
|
|
206
|
+
dependency.blocker ||
|
|
207
|
+
dependency.execution !== "integrated" ||
|
|
208
|
+
dependency.integration?.postMerge?.status !== "passed" ||
|
|
209
|
+
!mergeSha) {
|
|
210
|
+
throw new Error(`Ticket ${id} is not closed and post-merge validated.`);
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
this.gitEvidence.gitBuffer(["merge-base", "--is-ancestor", mergeSha, baseline]);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
throw new Error(`Ticket baseline does not include validated prerequisite ${id}.`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
97
220
|
checksRecord(options) {
|
|
221
|
+
if (options.scope === "post-merge")
|
|
222
|
+
return this.postMergeRecord(options);
|
|
98
223
|
if (options.scope === "delivery") {
|
|
99
224
|
if (options.ticket)
|
|
100
225
|
throw new Error("--ticket and --scope delivery are mutually exclusive.");
|
|
101
226
|
return this.deliveryChecksRecord(options);
|
|
102
227
|
}
|
|
228
|
+
return this.ticketChecksRecord(options);
|
|
229
|
+
}
|
|
230
|
+
postMergeRequire(options) {
|
|
231
|
+
validateTicketId(options.ticket);
|
|
232
|
+
validateAuthor(options.author);
|
|
233
|
+
if (options.remove || options.details !== undefined)
|
|
234
|
+
throw new Error("Post-merge checks must be declared as a complete list, without --remove or --details.");
|
|
235
|
+
const commands = (Array.isArray(options.command) ? options.command : [options.command]).map((command) => command.trim());
|
|
236
|
+
if (!commands.length ||
|
|
237
|
+
commands.some((command) => !command) ||
|
|
238
|
+
new Set(commands).size !== commands.length)
|
|
239
|
+
throw new Error("Declare a non-empty list of unique post-merge commands.");
|
|
240
|
+
const feature = this.featureInspectionService.resolveFeature(options.spec);
|
|
241
|
+
return this.workspaceTransaction.run(feature.workspace, () => {
|
|
242
|
+
const ledger = this.ledgerRepository.readLedger(feature);
|
|
243
|
+
const { progress, postMerge } = this.postMergeProgress(ledger, options.ticket, options.author);
|
|
244
|
+
const previous = progress.integrationHistory?.at(-1)?.postMerge?.checks;
|
|
245
|
+
if (previous &&
|
|
246
|
+
JSON.stringify(previous.required.map((item) => item.command)) !== JSON.stringify(commands))
|
|
247
|
+
throw new Error("Post-merge correction must retain the entire prior required check list.");
|
|
248
|
+
const prior = postMerge.checks;
|
|
249
|
+
const same = prior &&
|
|
250
|
+
JSON.stringify(prior.required.map((item) => item.command)) === JSON.stringify(commands);
|
|
251
|
+
if (prior?.records.length && !same)
|
|
252
|
+
throw new Error("Post-merge check list is frozen after the first result.");
|
|
253
|
+
const checks = same && prior
|
|
254
|
+
? prior
|
|
255
|
+
: {
|
|
256
|
+
required: commands.map((command) => ({
|
|
257
|
+
command,
|
|
258
|
+
addedAt: new Date().toISOString(),
|
|
259
|
+
addedBy: options.author.trim(),
|
|
260
|
+
})),
|
|
261
|
+
records: [],
|
|
262
|
+
};
|
|
263
|
+
if (!same)
|
|
264
|
+
postMerge.checks = checks;
|
|
265
|
+
const identity = this.postMergeIdentity(feature, ledger, progress, checks);
|
|
266
|
+
if (!same)
|
|
267
|
+
this.workspaceTransaction.writeLedger(feature, ledger);
|
|
268
|
+
const closure = same && checks.records.length
|
|
269
|
+
? this.closePostMergeIfReady(feature, ledger, progress, checks, identity, options.author)
|
|
270
|
+
: {};
|
|
271
|
+
return {
|
|
272
|
+
ticket: options.ticket,
|
|
273
|
+
scope: "post-merge",
|
|
274
|
+
checks,
|
|
275
|
+
...(same ? { idempotent: true } : {}),
|
|
276
|
+
...closure,
|
|
277
|
+
...identity,
|
|
278
|
+
};
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
postMergeRecord(options) {
|
|
282
|
+
validateTicketId(options.ticket);
|
|
283
|
+
validateAuthor(options.author);
|
|
284
|
+
if (options.paths !== undefined)
|
|
285
|
+
throw new Error("--paths is not supported for post-merge checks.");
|
|
286
|
+
const command = options.command.trim();
|
|
287
|
+
const result = options.result;
|
|
288
|
+
if (result !== "passed" && result !== "failed" && result !== "unavailable")
|
|
289
|
+
throw new Error(`Invalid check result: ${result}.`);
|
|
290
|
+
if (result !== "passed" && !options.details?.trim())
|
|
291
|
+
throw new Error("--details is required for failed or unavailable checks.");
|
|
292
|
+
if (!/^[0-9a-f]{64}$/i.test(options.snapshot))
|
|
293
|
+
throw new Error("Snapshot must be a 64-character hexadecimal content identity.");
|
|
294
|
+
const feature = this.featureInspectionService.resolveFeature(options.spec);
|
|
295
|
+
return this.workspaceTransaction.run(feature.workspace, () => {
|
|
296
|
+
const ledger = this.ledgerRepository.readLedger(feature);
|
|
297
|
+
const { progress, postMerge } = this.postMergeProgress(ledger, options.ticket, options.author);
|
|
298
|
+
const checks = postMerge.checks;
|
|
299
|
+
if (!checks?.required.length)
|
|
300
|
+
throw new Error("Declare the entire non-empty post-merge check list first.");
|
|
301
|
+
if (!checks.required.some((item) => item.command === command))
|
|
302
|
+
throw new Error(`Check command is not required: ${command}.`);
|
|
303
|
+
const identity = this.postMergeIdentity(feature, ledger, progress, checks);
|
|
304
|
+
if (identity.snapshot !== options.snapshot)
|
|
305
|
+
throw new Error(`Snapshot is stale; current post-merge identity is ${identity.snapshot}.`);
|
|
306
|
+
const record = {
|
|
307
|
+
command,
|
|
308
|
+
result,
|
|
309
|
+
author: options.author.trim(),
|
|
310
|
+
...identity,
|
|
311
|
+
recordedAt: new Date().toISOString(),
|
|
312
|
+
...(options.details?.trim() ? { details: options.details.trim() } : {}),
|
|
313
|
+
};
|
|
314
|
+
checks.records.push(record);
|
|
315
|
+
const confirmed = this.postMergeIdentity(feature, ledger, progress, checks);
|
|
316
|
+
if (confirmed.snapshot !== identity.snapshot || confirmed.headSha !== identity.headSha)
|
|
317
|
+
throw new Error("Feature changed while recording the check; retry with a fresh snapshot.");
|
|
318
|
+
const closure = this.closePostMergeIfReady(feature, ledger, progress, checks, identity, options.author);
|
|
319
|
+
if (!closure.closed)
|
|
320
|
+
this.workspaceTransaction.writeLedger(feature, ledger);
|
|
321
|
+
return { ticket: options.ticket, scope: "post-merge", record, ...closure, ...identity };
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
closePostMergeIfReady(feature, ledger, progress, checks, identity, author) {
|
|
325
|
+
const latest = checks.required.map((item) => [...checks.records].reverse().find((candidate) => candidate.command === item.command));
|
|
326
|
+
if (!latest.length || latest.some((record) => record?.result !== "passed"))
|
|
327
|
+
return {};
|
|
328
|
+
if (latest.some((record) => record?.snapshot !== identity.snapshot || record.headSha !== identity.headSha)) {
|
|
329
|
+
return {
|
|
330
|
+
closed: false,
|
|
331
|
+
closureBlocked: ["Post-merge check evidence is stale for the current requirements."],
|
|
332
|
+
retry: "Run and record each stale check against the current snapshot before retrying closure.",
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
const tickets = this.ticketRepository.readTickets(feature.workspace);
|
|
336
|
+
this.ticketRepository.validateDependencyGraph(tickets);
|
|
337
|
+
const ticket = this.ticketRepository.requireTicket(tickets, progress.id);
|
|
338
|
+
const unmet = ReadinessPolicy.unmetDependencies(ticket, tickets, ledger);
|
|
339
|
+
const closureBlocked = [
|
|
340
|
+
...(ticket.blocker ? [`Ticket blocker: ${ticket.blocker}`] : []),
|
|
341
|
+
...(unmet.length ? [`Unmet or unvalidated dependencies: ${unmet.join(", ")}`] : []),
|
|
342
|
+
];
|
|
343
|
+
if (closureBlocked.length) {
|
|
344
|
+
return {
|
|
345
|
+
closed: false,
|
|
346
|
+
closureBlocked,
|
|
347
|
+
retry: "Resolve the blockers, then repeat checks require with the same complete post-merge command list to retry closure.",
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
const postMerge = progress.integration?.postMerge;
|
|
351
|
+
if (!postMerge || postMerge.status !== "pending")
|
|
352
|
+
throw new Error("Post-merge integration is no longer pending.");
|
|
353
|
+
postMerge.status = "passed";
|
|
354
|
+
progress.status = "closed";
|
|
355
|
+
const body = this.ticketRepository.appendAudit(ticket.body, author, `Post-merge checks passed at ${identity.headSha}; ticket closed.`);
|
|
356
|
+
this.workspaceTransaction.writeWorkflowState(feature, ticket, this.ticketRepository.serializeTicket(ticket, body), ledger);
|
|
357
|
+
return { closed: true };
|
|
358
|
+
}
|
|
359
|
+
postMergeProgress(ledger, ticket, author) {
|
|
360
|
+
const progress = this.workflowPolicy.requireProgress(ledger, ticket);
|
|
361
|
+
const integration = progress.integration;
|
|
362
|
+
const postMerge = integration?.postMerge;
|
|
363
|
+
if (progress.owner !== author.trim())
|
|
364
|
+
throw new Error("Only the ticket controller may manage post-merge checks.");
|
|
365
|
+
if (progress.execution !== "integrated" ||
|
|
366
|
+
progress.status === "closed" ||
|
|
367
|
+
!integration?.mergeSha ||
|
|
368
|
+
postMerge?.status !== "pending" ||
|
|
369
|
+
postMerge.mergeSha !== integration.mergeSha)
|
|
370
|
+
throw new Error("Ticket must have a pending post-merge integration.");
|
|
371
|
+
return { progress, postMerge };
|
|
372
|
+
}
|
|
373
|
+
postMergeIdentity(feature, ledger, progress, checks, allowWorkflowDescendants = false) {
|
|
374
|
+
for (const variable of [
|
|
375
|
+
"GIT_DIR",
|
|
376
|
+
"GIT_WORK_TREE",
|
|
377
|
+
"GIT_COMMON_DIR",
|
|
378
|
+
"GIT_INDEX_FILE",
|
|
379
|
+
"GIT_OBJECT_DIRECTORY",
|
|
380
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
381
|
+
"GIT_NAMESPACE",
|
|
382
|
+
]) {
|
|
383
|
+
if (process.env[variable])
|
|
384
|
+
throw new Error(`Unset ${variable} before post-merge validation.`);
|
|
385
|
+
}
|
|
386
|
+
const root = realpathSync(this.app.workspaceRoot);
|
|
387
|
+
const git = (args) => this.gitEvidence.gitBuffer(args, root).toString("utf8").trim();
|
|
388
|
+
const common = realpathSync(git(["rev-parse", "--path-format=absolute", "--git-common-dir"]));
|
|
389
|
+
const statePath = join(common, "spindle-feature-start", `${feature.feature}.json`);
|
|
390
|
+
const entry = lstatSync(statePath);
|
|
391
|
+
if (!entry.isFile() || entry.isSymbolicLink())
|
|
392
|
+
throw new Error("Canonical feature startup state is unsafe.");
|
|
393
|
+
const state = JSON.parse(readFileSync(statePath, "utf8"));
|
|
394
|
+
if (!Records.isRecord(state) ||
|
|
395
|
+
state.version !== 1 ||
|
|
396
|
+
state.phase !== "created" ||
|
|
397
|
+
state.feature !== feature.feature ||
|
|
398
|
+
state.featureWorkspace !== root ||
|
|
399
|
+
state.featureBranch !== `features/${feature.feature}` ||
|
|
400
|
+
git(["symbolic-ref", "--quiet", "--short", "HEAD"]) !== state.featureBranch)
|
|
401
|
+
throw new Error("Post-merge checks require the canonical feature worktree and branch.");
|
|
402
|
+
const currentHead = git(["rev-parse", "--verify", "HEAD"]);
|
|
403
|
+
const mergeSha = progress.integration?.mergeSha;
|
|
404
|
+
if (!mergeSha)
|
|
405
|
+
throw new Error("The recorded merge commit is missing.");
|
|
406
|
+
const allowed = this.gitEvidence.workflowDeliveryRecordPaths(feature, ledger);
|
|
407
|
+
if (currentHead !== mergeSha) {
|
|
408
|
+
if (!allowWorkflowDescendants)
|
|
409
|
+
throw new Error("Feature HEAD is not the exact recorded merge commit.");
|
|
410
|
+
git(["merge-base", "--is-ancestor", mergeSha, currentHead]);
|
|
411
|
+
const changed = this.gitEvidence
|
|
412
|
+
.gitBuffer(["diff", "--name-only", "-z", "--no-renames", mergeSha, currentHead, "--", "."], root)
|
|
413
|
+
.toString("utf8")
|
|
414
|
+
.split("\0")
|
|
415
|
+
.filter(Boolean);
|
|
416
|
+
if (changed.some((path) => !allowed.has(path)))
|
|
417
|
+
throw new Error("Feature code changed after the recorded merge; post-merge failure is stale.");
|
|
418
|
+
}
|
|
419
|
+
const headSha = mergeSha;
|
|
420
|
+
for (const operation of [
|
|
421
|
+
"MERGE_HEAD",
|
|
422
|
+
"CHERRY_PICK_HEAD",
|
|
423
|
+
"REVERT_HEAD",
|
|
424
|
+
"rebase-merge",
|
|
425
|
+
"rebase-apply",
|
|
426
|
+
"sequencer",
|
|
427
|
+
]) {
|
|
428
|
+
if (existsSync(resolve(root, git(["rev-parse", "--git-path", operation]))))
|
|
429
|
+
throw new Error(`Post-merge checks cannot run during Git operation ${operation}.`);
|
|
430
|
+
}
|
|
431
|
+
const lock = `.scratch/${feature.feature}/.sdd-tickets.lock`;
|
|
432
|
+
const dirty = this.gitEvidence
|
|
433
|
+
.allGitChangePaths()
|
|
434
|
+
.filter((path) => path !== lock && !allowed.has(path));
|
|
435
|
+
if (dirty.length)
|
|
436
|
+
throw new Error(`Post-merge checks require clean code: ${dirty.join(", ")}`);
|
|
437
|
+
const ticket = this.ticketRepository.requireTicket(this.ticketRepository.readTickets(feature.workspace), progress.id);
|
|
438
|
+
const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
439
|
+
const codeSnapshot = hash(["post-merge-code-v1", headSha]);
|
|
440
|
+
const requirementsSnapshot = hash([
|
|
441
|
+
"post-merge-requirements-v1",
|
|
442
|
+
readFileSync(feature.spec, "utf8"),
|
|
443
|
+
this.gitEvidence.ticketRequirements(ticket.raw),
|
|
444
|
+
checks.required.map((item) => item.command),
|
|
445
|
+
]);
|
|
446
|
+
return {
|
|
447
|
+
headSha,
|
|
448
|
+
codeSnapshot,
|
|
449
|
+
requirementsSnapshot,
|
|
450
|
+
snapshot: hash(["post-merge-v1", codeSnapshot, requirementsSnapshot]),
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
ticketChecksRecord(options) {
|
|
103
454
|
if (options.scope !== undefined)
|
|
104
455
|
throw new Error("--scope must be 'delivery'.");
|
|
105
456
|
if (!options.ticket)
|
|
@@ -122,6 +473,8 @@ export class ChecksService extends Service {
|
|
|
122
473
|
return this.workspaceTransaction.run(feature.workspace, () => {
|
|
123
474
|
const ledger = this.ledgerRepository.readLedger(feature);
|
|
124
475
|
const progress = this.workflowPolicy.requireProgress(ledger, options.ticket);
|
|
476
|
+
if (progress.integration?.postMerge)
|
|
477
|
+
throw new Error("Integrated tickets use --scope post-merge for validation.");
|
|
125
478
|
const checks = progress.checks;
|
|
126
479
|
if (!checks)
|
|
127
480
|
throw new Error("No required checks are declared for this ticket.");
|
|
@@ -147,6 +500,7 @@ export class ChecksService extends Service {
|
|
|
147
500
|
latestForCommand.result === options.result &&
|
|
148
501
|
latestForCommand.author === options.author.trim() &&
|
|
149
502
|
latestForCommand.snapshot === options.snapshot &&
|
|
503
|
+
(!progress.worktree || latestForCommand.headSha === identity.headSha) &&
|
|
150
504
|
latestForCommand.details === options.details?.trim() &&
|
|
151
505
|
JSON.stringify(latestForCommand.coveredPaths) === JSON.stringify(coveredPaths)
|
|
152
506
|
? latestForCommand
|
|
@@ -184,7 +538,8 @@ export class ChecksService extends Service {
|
|
|
184
538
|
}
|
|
185
539
|
const confirmedTicket = this.ticketRepository.requireTicket(this.ticketRepository.readTickets(feature.workspace), options.ticket);
|
|
186
540
|
const confirmedIdentity = this.gitEvidence.contentIdentity(feature, confirmedTicket, progress.baseSha, checks);
|
|
187
|
-
if (confirmedIdentity.snapshot !== identity.snapshot
|
|
541
|
+
if (confirmedIdentity.snapshot !== identity.snapshot ||
|
|
542
|
+
(progress.worktree && confirmedIdentity.headSha !== identity.headSha)) {
|
|
188
543
|
checks.records.pop();
|
|
189
544
|
correctionCheckHistory?.pop();
|
|
190
545
|
throw new Error("Content changed while recording the check; retry with a fresh snapshot.");
|
|
@@ -216,7 +571,7 @@ export class ChecksService extends Service {
|
|
|
216
571
|
throw new Error("Aggregate review is frozen; required delivery checks cannot change.");
|
|
217
572
|
}
|
|
218
573
|
delivery.owner = options.author.trim();
|
|
219
|
-
const checks = (delivery.checks ??= { required: [], records: []
|
|
574
|
+
const checks = (delivery.checks ??= { required: [], records: [] });
|
|
220
575
|
const active = checks.required.find((requirement) => requirement.command === command && !requirement.removedAt);
|
|
221
576
|
if (options.remove) {
|
|
222
577
|
if (!active)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
3
|
import matter from "gray-matter";
|
|
4
4
|
import { Service } from "../../Foundation/Service.js";
|
|
5
5
|
import { lstatIfPresent } from "../Persistence/FileSystem.js";
|
|
@@ -13,7 +13,8 @@ export class CorrectionService extends Service {
|
|
|
13
13
|
workflowPolicy;
|
|
14
14
|
deliveryCorrectionService;
|
|
15
15
|
featureInspectionService;
|
|
16
|
-
|
|
16
|
+
checksService;
|
|
17
|
+
constructor(app, gitEvidence, reportEvidence, ticketRepository, ledgerRepository, workspaceTransaction, workflowPolicy, deliveryCorrectionService, featureInspectionService, checksService) {
|
|
17
18
|
super(app);
|
|
18
19
|
this.gitEvidence = gitEvidence;
|
|
19
20
|
this.reportEvidence = reportEvidence;
|
|
@@ -23,6 +24,7 @@ export class CorrectionService extends Service {
|
|
|
23
24
|
this.workflowPolicy = workflowPolicy;
|
|
24
25
|
this.deliveryCorrectionService = deliveryCorrectionService;
|
|
25
26
|
this.featureInspectionService = featureInspectionService;
|
|
27
|
+
this.checksService = checksService;
|
|
26
28
|
}
|
|
27
29
|
correct(options) {
|
|
28
30
|
if (options.scope === "delivery") {
|
|
@@ -59,18 +61,14 @@ export class CorrectionService extends Service {
|
|
|
59
61
|
].includes(options.reason ?? "")) {
|
|
60
62
|
throw new Error("--reason is required for a pause and must identify the adjudication category.");
|
|
61
63
|
}
|
|
62
|
-
if (options.assessment
|
|
64
|
+
if (options.assessment)
|
|
63
65
|
throw new Error("--assessment is only valid with --decision continue.");
|
|
64
66
|
}
|
|
65
67
|
else if (options.reason) {
|
|
66
68
|
throw new Error("--reason is only valid with --decision pause.");
|
|
67
69
|
}
|
|
68
|
-
if (options.decision === "continue" &&
|
|
69
|
-
(
|
|
70
|
-
throw new Error("--assessment and --assessment-result productive are required with --decision continue.");
|
|
71
|
-
}
|
|
72
|
-
if (options.assessmentResult && options.decision !== "continue") {
|
|
73
|
-
throw new Error("--assessment-result is only valid with --decision continue.");
|
|
70
|
+
if (options.decision === "continue" && !options.assessment?.trim()) {
|
|
71
|
+
throw new Error("--assessment is required with --decision continue.");
|
|
74
72
|
}
|
|
75
73
|
const feature = this.featureInspectionService.resolveFeature(options.spec);
|
|
76
74
|
return this.workspaceTransaction.run(feature.workspace, () => {
|
|
@@ -150,10 +148,72 @@ export class CorrectionService extends Service {
|
|
|
150
148
|
throw new Error(`Ticket ${options.ticket} is paused for adjudication (${progress.adjudication?.reason}); resolve it before another correction.`);
|
|
151
149
|
}
|
|
152
150
|
const checkDriven = !!options.checkFailure?.trim();
|
|
151
|
+
const failedIntegration = progress.execution === "integrated" ? progress.integration : undefined;
|
|
152
|
+
const postMergeChecks = failedIntegration?.postMerge?.checks;
|
|
153
|
+
let failedPostMergeRecord;
|
|
154
|
+
if (failedIntegration && options.checkFailure?.trim()) {
|
|
155
|
+
if (failedIntegration.postMerge?.status !== "pending" || !postMergeChecks) {
|
|
156
|
+
throw new Error("Post-merge correction requires a pending failed validation.");
|
|
157
|
+
}
|
|
158
|
+
const latest = [...postMergeChecks.records]
|
|
159
|
+
.reverse()
|
|
160
|
+
.find((record) => record.command === options.checkFailure?.trim());
|
|
161
|
+
if (!latest || latest.result !== "failed") {
|
|
162
|
+
throw new Error("Post-merge correction requires the latest result for that command to be failed.");
|
|
163
|
+
}
|
|
164
|
+
const current = this.checksService.postMergeIdentity(feature, ledger, progress, postMergeChecks, true);
|
|
165
|
+
if (latest.snapshot !== current.snapshot || latest.headSha !== current.headSha) {
|
|
166
|
+
throw new Error("Post-merge failure is stale; record current failure evidence before correction.");
|
|
167
|
+
}
|
|
168
|
+
const workspace = this.gitEvidence.ticketWorkspace(progress);
|
|
169
|
+
const mergeSha = failedIntegration.mergeSha;
|
|
170
|
+
if (!workspace || !mergeSha || this.gitEvidence.headSha(workspace) !== mergeSha) {
|
|
171
|
+
throw new Error("Fast-forward the clean ticket worktree to the failed feature merge before dispatching correction.");
|
|
172
|
+
}
|
|
173
|
+
const status = this.gitEvidence
|
|
174
|
+
.gitBuffer(["status", "--porcelain=v1", "--untracked-files=all", "--ignore-submodules=none"], workspace)
|
|
175
|
+
.toString("utf8");
|
|
176
|
+
if (status)
|
|
177
|
+
throw new Error("Ticket worktree must be clean before post-merge correction; changes were preserved.");
|
|
178
|
+
for (const operation of [
|
|
179
|
+
"MERGE_HEAD",
|
|
180
|
+
"CHERRY_PICK_HEAD",
|
|
181
|
+
"REVERT_HEAD",
|
|
182
|
+
"rebase-merge",
|
|
183
|
+
"rebase-apply",
|
|
184
|
+
"sequencer",
|
|
185
|
+
]) {
|
|
186
|
+
const path = this.gitEvidence
|
|
187
|
+
.gitBuffer(["rev-parse", "--git-path", operation], workspace)
|
|
188
|
+
.toString("utf8")
|
|
189
|
+
.trim();
|
|
190
|
+
if (lstatIfPresent(resolve(workspace, path)))
|
|
191
|
+
throw new Error(`Ticket Git operation ${operation} must be resolved before correction.`);
|
|
192
|
+
}
|
|
193
|
+
if (!progress.worktree)
|
|
194
|
+
throw new Error("Post-merge correction requires an isolated ticket worktree.");
|
|
195
|
+
if (!progress.baseSha)
|
|
196
|
+
throw new Error("Ticket baseline is missing.");
|
|
197
|
+
this.gitEvidence.gitBuffer(["merge-base", "--is-ancestor", progress.baseSha, mergeSha]);
|
|
198
|
+
this.checksService.assertPrerequisiteBaseline(ledger, progress, mergeSha);
|
|
199
|
+
(progress.baselineHistory ??= []).push({
|
|
200
|
+
from: progress.baseSha,
|
|
201
|
+
to: mergeSha,
|
|
202
|
+
ticketHead: mergeSha,
|
|
203
|
+
at: new Date().toISOString(),
|
|
204
|
+
author: options.author.trim(),
|
|
205
|
+
});
|
|
206
|
+
progress.baseSha = mergeSha;
|
|
207
|
+
progress.worktree.baseSha = mergeSha;
|
|
208
|
+
this.gitEvidence.assertTicketCodeCommitted(progress);
|
|
209
|
+
failedPostMergeRecord = latest;
|
|
210
|
+
}
|
|
153
211
|
if (!checkDriven && progress.execution !== "changes-requested") {
|
|
154
212
|
throw new Error(`Ticket ${options.ticket} must have a failed review before correction.`);
|
|
155
213
|
}
|
|
156
|
-
if (checkDriven &&
|
|
214
|
+
if (checkDriven &&
|
|
215
|
+
!["reviewing", "changes-requested"].includes(progress.execution) &&
|
|
216
|
+
!failedPostMergeRecord) {
|
|
157
217
|
throw new Error(`Ticket ${options.ticket} must have a submitted implementation before correction.`);
|
|
158
218
|
}
|
|
159
219
|
if (!progress.baseSha)
|
|
@@ -191,7 +251,7 @@ export class CorrectionService extends Service {
|
|
|
191
251
|
}
|
|
192
252
|
}
|
|
193
253
|
const checkFailure = options.checkFailure?.trim();
|
|
194
|
-
if (checkFailure) {
|
|
254
|
+
if (checkFailure && !failedPostMergeRecord) {
|
|
195
255
|
const record = progress.checks?.records
|
|
196
256
|
.filter((candidate) => candidate.command === checkFailure &&
|
|
197
257
|
candidate.snapshot === identity.snapshot &&
|
|
@@ -213,6 +273,9 @@ export class CorrectionService extends Service {
|
|
|
213
273
|
...(checkFailure ? { checkFailure } : {}),
|
|
214
274
|
objective,
|
|
215
275
|
report: join(feature.workspace, "reports", `${options.ticket}-round-${number}-fix.md`),
|
|
276
|
+
...(progress.worktree
|
|
277
|
+
? { workspace: progress.worktree.workspace, branch: progress.worktree.branch }
|
|
278
|
+
: {}),
|
|
216
279
|
requestedAt: new Date().toISOString(),
|
|
217
280
|
status: "pending",
|
|
218
281
|
handoffs: [],
|
|
@@ -233,9 +296,11 @@ export class CorrectionService extends Service {
|
|
|
233
296
|
...(progress.assignment ? { priorAssignment: progress.assignment } : {}),
|
|
234
297
|
...(checkFailure
|
|
235
298
|
? {
|
|
236
|
-
checks:
|
|
237
|
-
|
|
238
|
-
record.
|
|
299
|
+
checks: failedPostMergeRecord
|
|
300
|
+
? [failedPostMergeRecord]
|
|
301
|
+
: (progress.checks?.records ?? []).filter((record) => record.snapshot === identity.snapshot &&
|
|
302
|
+
record.codeSnapshot === identity.codeSnapshot &&
|
|
303
|
+
record.requirementsSnapshot === identity.requirementsSnapshot),
|
|
239
304
|
}
|
|
240
305
|
: {}),
|
|
241
306
|
};
|
|
@@ -249,6 +314,10 @@ export class CorrectionService extends Service {
|
|
|
249
314
|
};
|
|
250
315
|
}
|
|
251
316
|
attempts.push(attempt);
|
|
317
|
+
if (failedPostMergeRecord && failedIntegration) {
|
|
318
|
+
(progress.integrationHistory ??= []).push(failedIntegration);
|
|
319
|
+
delete progress.integration;
|
|
320
|
+
}
|
|
252
321
|
progress.execution = "implementing";
|
|
253
322
|
progress.assignment = assignment;
|
|
254
323
|
if (pendingAdjudication && progress.adjudication) {
|