@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.
Files changed (60) hide show
  1. package/build/Assets/Blueprints/Catalog.js +7 -2
  2. package/build/Assets/Blueprints/agents/sdd-implementer.agent.md +6 -6
  3. package/build/Assets/Blueprints/agents/sdd-orchestrator.agent.md +4 -7
  4. package/build/Assets/Blueprints/skills/author-agent/SKILL.md +2 -2
  5. package/build/Assets/Blueprints/skills/author-instruction/SKILL.md +2 -2
  6. package/build/Assets/Blueprints/skills/author-skill/SKILL.md +2 -2
  7. package/build/Assets/Blueprints/skills/int-subagent-driven-dev/SKILL.md +13 -9
  8. package/build/Assets/Blueprints/skills/int-subagent-driven-dev/assets/implementer-prompt.md +2 -2
  9. package/build/Assets/Blueprints/skills/int-subagent-driven-dev/references/example-workflow.md +11 -9
  10. package/build/Assets/Blueprints/skills/int-subagent-driven-dev/references/full-spec.md +33 -13
  11. package/build/Assets/Blueprints/skills/int-subagent-driven-dev/references/review-inputs.md +7 -7
  12. package/build/Assets/Blueprints/skills/{asd-ste100 → ref-asd-ste100}/SKILL.md +1 -1
  13. package/build/Assets/Blueprints/skills/sdd-author-design/SKILL.md +2 -2
  14. package/build/Assets/Blueprints/skills/sdd-implement-spec/SKILL.md +4 -6
  15. package/build/Assets/Templates/project/ticket-tracker.md +10 -2
  16. package/build/SubagentDrivenDevelopment/Commands/ChecksRecordCommand.js +27 -5
  17. package/build/SubagentDrivenDevelopment/Commands/ChecksRequireCommand.js +31 -7
  18. package/build/SubagentDrivenDevelopment/Commands/CorrectCommand.js +0 -1
  19. package/build/SubagentDrivenDevelopment/Commands/FinishCommand.js +2 -1
  20. package/build/SubagentDrivenDevelopment/Commands/ImplementCommand.js +2 -0
  21. package/build/SubagentDrivenDevelopment/Commands/{ClaimCommand.js → IntegrateCommand.js} +8 -7
  22. package/build/SubagentDrivenDevelopment/Commands/ReviewCommand.js +9 -9
  23. package/build/SubagentDrivenDevelopment/Commands/StartCommand.js +23 -0
  24. package/build/SubagentDrivenDevelopment/Commands/StatusCommand.js +5 -0
  25. package/build/SubagentDrivenDevelopment/Commands/groups.js +3 -2
  26. package/build/SubagentDrivenDevelopment/Evidence/GitEvidence.js +188 -37
  27. package/build/SubagentDrivenDevelopment/Models/Workflow.js +0 -2
  28. package/build/SubagentDrivenDevelopment/Persistence/AssignmentSchema.js +4 -0
  29. package/build/SubagentDrivenDevelopment/Persistence/CheckSchema.js +4 -13
  30. package/build/SubagentDrivenDevelopment/Persistence/LedgerRepository.js +66 -5
  31. package/build/SubagentDrivenDevelopment/Persistence/ProgressSchema.js +54 -4
  32. package/build/SubagentDrivenDevelopment/Persistence/ReviewSchema.js +4 -0
  33. package/build/SubagentDrivenDevelopment/Policies/AssignmentPolicy.js +0 -3
  34. package/build/SubagentDrivenDevelopment/Policies/CheckPolicy.js +6 -3
  35. package/build/SubagentDrivenDevelopment/Policies/ReadinessPolicy.js +16 -6
  36. package/build/SubagentDrivenDevelopment/Policies/ReviewPolicy.js +16 -2
  37. package/build/SubagentDrivenDevelopment/Policies/WorkflowPolicy.js +8 -8
  38. package/build/SubagentDrivenDevelopment/Services/AssignmentService.js +73 -51
  39. package/build/SubagentDrivenDevelopment/Services/ChecksService.js +376 -21
  40. package/build/SubagentDrivenDevelopment/Services/CorrectionService.js +83 -14
  41. package/build/SubagentDrivenDevelopment/Services/DeliveryCorrectionService.js +3 -7
  42. package/build/SubagentDrivenDevelopment/Services/DeliveryService.js +43 -38
  43. package/build/SubagentDrivenDevelopment/Services/FeatureCreationService.js +1 -1
  44. package/build/SubagentDrivenDevelopment/Services/FeatureFinalizationService.js +1202 -0
  45. package/build/SubagentDrivenDevelopment/Services/FeatureInspectionService.js +41 -49
  46. package/build/SubagentDrivenDevelopment/Services/FeatureWorktreeService.js +544 -0
  47. package/build/SubagentDrivenDevelopment/Services/ReadinessService.js +2 -2
  48. package/build/SubagentDrivenDevelopment/Services/ReviewService.js +39 -3
  49. package/build/SubagentDrivenDevelopment/Services/SubmissionService.js +4 -1
  50. package/build/SubagentDrivenDevelopment/Services/TicketIntegrationService.js +621 -0
  51. package/build/SubagentDrivenDevelopment/Services/TicketService.js +3 -0
  52. package/build/SubagentDrivenDevelopment/Services/TicketWorktreeService.js +317 -0
  53. package/build/SubagentDrivenDevelopment/Services/WorkflowRecordsCommitter.js +336 -0
  54. package/build/SubagentDrivenDevelopment/SubagentDrivenDevelopmentServiceProvider.js +18 -8
  55. package/build/TemplateRendering/Computed/Providers/AuthoringFlowchartProvider.js +1 -1
  56. package/package.json +2 -2
  57. package/build/SubagentDrivenDevelopment/Services/IntegrationService.js +0 -258
  58. /package/build/Assets/Blueprints/skills/{asd-ste100 → ref-asd-ste100}/CREDITS.MD +0 -0
  59. /package/build/Assets/Blueprints/skills/{asd-ste100 → ref-asd-ste100}/examples/before-after.md +0 -0
  60. /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(options);
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 === "reviewing" && (progress.reviewAssignments?.length ?? 0) > 0) {
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: [], invalidations: [] });
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
- ...this.gitEvidence.contentIdentity(feature, this.ticketRepository.requireTicket(this.ticketRepository.readTickets(feature.workspace), options.ticket), progress.baseSha, checks),
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: [], invalidations: [] });
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
- constructor(app, gitEvidence, reportEvidence, ticketRepository, ledgerRepository, workspaceTransaction, workflowPolicy, deliveryCorrectionService, featureInspectionService) {
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 || options.assessmentResult)
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
- (!options.assessment?.trim() || options.assessmentResult !== "productive")) {
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 && !["reviewing", "changes-requested"].includes(progress.execution)) {
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: (progress.checks?.records ?? []).filter((record) => record.snapshot === identity.snapshot &&
237
- record.codeSnapshot === identity.codeSnapshot &&
238
- record.requirementsSnapshot === identity.requirementsSnapshot),
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) {