@use-the-fork/spindle 0.0.2 → 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 +19 -9
- 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 +9 -8
- package/build/Assets/Blueprints/skills/author-instruction/SKILL.md +9 -8
- package/build/Assets/Blueprints/skills/author-skill/SKILL.md +9 -8
- package/build/Assets/Blueprints/skills/grill/SKILL.md +3 -3
- package/build/Assets/Blueprints/skills/int-subagent-driven-dev/SKILL.md +16 -12
- 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/corrective-rounds.md +1 -1
- package/build/Assets/Blueprints/skills/int-subagent-driven-dev/references/example-workflow.md +14 -12
- package/build/Assets/Blueprints/skills/int-subagent-driven-dev/references/full-spec.md +43 -23
- package/build/Assets/Blueprints/skills/int-subagent-driven-dev/references/review-inputs.md +7 -7
- package/build/Assets/Blueprints/skills/int-testing-agent-guidance/SKILL.md +2 -2
- package/build/Assets/Blueprints/skills/int-writing-skills/SKILL.md +2 -2
- package/build/Assets/Blueprints/skills/int-writing-skills/references/testing-skills.md +2 -2
- package/build/Assets/Blueprints/skills/{asd-ste100 → ref-asd-ste100}/SKILL.md +1 -1
- package/build/Assets/Blueprints/skills/ref-php-laravel-saloon-api-integrations/SKILL.md +38 -0
- package/build/Assets/Blueprints/skills/ref-php-laravel-saloon-api-integrations/references/concurrency-and-pools.md +73 -0
- package/build/Assets/Blueprints/skills/ref-php-laravel-saloon-api-integrations/references/connectors.md +99 -0
- package/build/Assets/Blueprints/skills/ref-php-laravel-saloon-api-integrations/references/data-transfer-objects.md +104 -0
- package/build/Assets/Blueprints/skills/ref-php-laravel-saloon-api-integrations/references/handling-failures.md +95 -0
- package/build/Assets/Blueprints/skills/ref-php-laravel-saloon-api-integrations/references/requests.md +111 -0
- package/build/Assets/Blueprints/skills/ref-php-laravel-saloon-api-integrations/references/responses.md +56 -0
- package/build/Assets/Blueprints/skills/ref-php-laravel-saloon-api-integrations/references/sending-requests.md +42 -0
- package/build/Assets/Blueprints/skills/ref-php-laravel-saloon-api-integrations/references/testing.md +119 -0
- package/build/Assets/Blueprints/skills/{author-design → sdd-author-design}/SKILL.md +12 -4
- package/build/Assets/Blueprints/skills/{grill-design → sdd-grill-design}/SKILL.md +1 -1
- package/build/Assets/Blueprints/skills/{implement-spec → sdd-implement-spec}/SKILL.md +6 -8
- package/build/Assets/Blueprints/skills/{to-spec → sdd-to-spec}/SKILL.md +3 -3
- package/build/Assets/Blueprints/skills/{to-tickets → sdd-to-tickets}/SKILL.md +3 -3
- package/build/Assets/Blueprints/skills/{setup-project → spindle-project-setup}/SKILL.md +4 -4
- package/build/Assets/Blueprints/skills/{update-tooling → spindle-tooling-update}/SKILL.md +5 -5
- package/build/Assets/Templates/project/copilot-instructions.md +3 -9
- package/build/Assets/Templates/project/ticket-tracker.md +14 -6
- package/build/BlueprintManagement/Commands/Prompts.js +1 -1
- 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/ComputedVariableProviders.js +2 -0
- package/build/TemplateRendering/Computed/Providers/AuthoringFlowchartProvider.js +26 -0
- 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
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { accessSync, closeSync, constants, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import matter from "gray-matter";
|
|
5
|
+
import { Service } from "../../Foundation/Service.js";
|
|
6
|
+
import { lstatIfPresent } from "../Persistence/FileSystem.js";
|
|
7
|
+
import { validateAuthor, validateTicketId, } from "../Policies/InputValidation.js";
|
|
8
|
+
import { Records } from "../../Support/Records.js";
|
|
9
|
+
export class TicketIntegrationService extends Service {
|
|
10
|
+
git;
|
|
11
|
+
gitEvidence;
|
|
12
|
+
reports;
|
|
13
|
+
tickets;
|
|
14
|
+
ledgers;
|
|
15
|
+
transaction;
|
|
16
|
+
acceptance;
|
|
17
|
+
workflow;
|
|
18
|
+
inspection;
|
|
19
|
+
workflowRecordsCommitter;
|
|
20
|
+
constructor(app, git, gitEvidence, reports, tickets, ledgers, transaction, acceptance, workflow, inspection, workflowRecordsCommitter) {
|
|
21
|
+
super(app);
|
|
22
|
+
this.git = git;
|
|
23
|
+
this.gitEvidence = gitEvidence;
|
|
24
|
+
this.reports = reports;
|
|
25
|
+
this.tickets = tickets;
|
|
26
|
+
this.ledgers = ledgers;
|
|
27
|
+
this.transaction = transaction;
|
|
28
|
+
this.acceptance = acceptance;
|
|
29
|
+
this.workflow = workflow;
|
|
30
|
+
this.inspection = inspection;
|
|
31
|
+
this.workflowRecordsCommitter = workflowRecordsCommitter;
|
|
32
|
+
}
|
|
33
|
+
integrate(options) {
|
|
34
|
+
validateTicketId(options.ticket);
|
|
35
|
+
validateAuthor(options.author);
|
|
36
|
+
for (const variable of [
|
|
37
|
+
"GIT_DIR",
|
|
38
|
+
"GIT_WORK_TREE",
|
|
39
|
+
"GIT_COMMON_DIR",
|
|
40
|
+
"GIT_INDEX_FILE",
|
|
41
|
+
"GIT_OBJECT_DIRECTORY",
|
|
42
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
43
|
+
"GIT_NAMESPACE",
|
|
44
|
+
]) {
|
|
45
|
+
if (process.env[variable])
|
|
46
|
+
throw new Error(`Unset ${variable} before ticket integration.`);
|
|
47
|
+
}
|
|
48
|
+
const root = realpathSync(this.app.workspaceRoot);
|
|
49
|
+
const feature = this.inspection.resolveFeature(options.spec);
|
|
50
|
+
return this.transaction.run(feature.workspace, () => {
|
|
51
|
+
const ledger = this.ledgers.readLedger(feature);
|
|
52
|
+
const ticket = this.tickets.requireTicket(this.tickets.readTickets(feature.workspace), options.ticket);
|
|
53
|
+
const progress = this.workflow.requireProgress(ledger, options.ticket);
|
|
54
|
+
if (!progress.worktree || !progress.baseSha) {
|
|
55
|
+
throw new Error("Ticket integration requires an isolated ticket worktree.");
|
|
56
|
+
}
|
|
57
|
+
if (progress.execution !== "reviewing" &&
|
|
58
|
+
progress.execution !== "changes-requested" &&
|
|
59
|
+
progress.execution !== "integrated") {
|
|
60
|
+
throw new Error(`Ticket ${options.ticket} must have current review before integration.`);
|
|
61
|
+
}
|
|
62
|
+
if (progress.owner !== options.author.trim()) {
|
|
63
|
+
throw new Error("Only the ticket controller may integrate this ticket.");
|
|
64
|
+
}
|
|
65
|
+
this.assertCanonical(root, feature);
|
|
66
|
+
const identity = this.gitEvidence.contentIdentity(feature, ticket, progress.baseSha, progress.checks);
|
|
67
|
+
const outstandingPostMerge = ledger.tickets.filter((candidate) => {
|
|
68
|
+
const integration = candidate.integration ?? candidate.integrationHistory?.at(-1);
|
|
69
|
+
return integration?.postMerge?.status === "pending";
|
|
70
|
+
});
|
|
71
|
+
const alreadyIntegrated = progress.execution === "integrated" &&
|
|
72
|
+
progress.integration?.commitSha === identity.headSha &&
|
|
73
|
+
!!progress.integration?.mergeSha;
|
|
74
|
+
const idempotentPendingValidation = alreadyIntegrated && String(progress.integration?.postMerge?.status) === "pending";
|
|
75
|
+
const previousIntegration = progress.integrationHistory?.at(-1);
|
|
76
|
+
const correction = progress.correctionAttempts?.at(-1);
|
|
77
|
+
const failedCommand = correction?.checkFailure;
|
|
78
|
+
const previousFailure = failedCommand
|
|
79
|
+
? previousIntegration?.postMerge?.checks?.records
|
|
80
|
+
.filter((record) => record.command === failedCommand)
|
|
81
|
+
.at(-1)
|
|
82
|
+
: undefined;
|
|
83
|
+
const failedCorrection = outstandingPostMerge.length === 1 &&
|
|
84
|
+
outstandingPostMerge[0]?.id === options.ticket &&
|
|
85
|
+
!progress.integration &&
|
|
86
|
+
!!previousIntegration?.mergeSha &&
|
|
87
|
+
previousIntegration.postMerge?.status === "pending" &&
|
|
88
|
+
previousFailure?.result === "failed" &&
|
|
89
|
+
previousFailure.headSha === previousIntegration.mergeSha &&
|
|
90
|
+
progress.assignment?.action === "correct" &&
|
|
91
|
+
progress.assignment.attempt === correction?.number;
|
|
92
|
+
if (outstandingPostMerge.length > 0 && !idempotentPendingValidation && !failedCorrection) {
|
|
93
|
+
throw new Error(`Ticket integration is blocked while post-merge validation is pending or failed for: ${outstandingPostMerge.map((item) => item.id).join(", ")}.`);
|
|
94
|
+
}
|
|
95
|
+
if (failedCorrection && previousIntegration?.mergeSha) {
|
|
96
|
+
this.assertAncestor(root, previousIntegration.mergeSha, progress.baseSha, "Correction baseline must descend from the failed feature merge.");
|
|
97
|
+
this.assertAncestor(root, progress.baseSha, identity.headSha, "Correction must descend from its reviewed feature baseline.");
|
|
98
|
+
this.assertAncestor(root, progress.baseSha, "HEAD", "The reviewed correction baseline is no longer on the feature branch.");
|
|
99
|
+
const allowed = this.gitEvidence.workflowDeliveryRecordPaths(feature, ledger);
|
|
100
|
+
const changed = this.buffer(root, [
|
|
101
|
+
"diff",
|
|
102
|
+
"--name-only",
|
|
103
|
+
"-z",
|
|
104
|
+
"--no-renames",
|
|
105
|
+
progress.baseSha,
|
|
106
|
+
"HEAD",
|
|
107
|
+
"--",
|
|
108
|
+
".",
|
|
109
|
+
])
|
|
110
|
+
.toString("utf8")
|
|
111
|
+
.split("\0")
|
|
112
|
+
.filter((path) => path && !allowed.has(path));
|
|
113
|
+
if (changed.length)
|
|
114
|
+
throw new Error(`Feature code changed since the reviewed correction baseline; rebase and re-review before integration: ${changed.join(", ")}.`);
|
|
115
|
+
}
|
|
116
|
+
const blockers = this.acceptance.acceptBlockers(feature, ledger, ticket, progress, identity);
|
|
117
|
+
if (blockers.length)
|
|
118
|
+
throw new Error(`Ticket review or checks are not current:\n${blockers.join("\n")}`);
|
|
119
|
+
const accepted = this.acceptedEvidence(feature, progress, identity);
|
|
120
|
+
if (alreadyIntegrated &&
|
|
121
|
+
(identity.snapshot !== accepted.snapshot ||
|
|
122
|
+
identity.codeSnapshot !== accepted.codeSnapshot ||
|
|
123
|
+
identity.requirementsSnapshot !== accepted.requirementsSnapshot)) {
|
|
124
|
+
throw new Error("Ticket review evidence is stale; fresh review is required.");
|
|
125
|
+
}
|
|
126
|
+
this.reports.assertAcceptedReportsCurrent(feature, progress);
|
|
127
|
+
const workspace = this.gitEvidence.ticketWorkspace(progress);
|
|
128
|
+
this.gitEvidence.assertTicketCodeCommitted(progress);
|
|
129
|
+
if (JSON.stringify(this.gitEvidence.changedPaths(progress.baseSha, workspace)) !==
|
|
130
|
+
JSON.stringify(accepted.files) ||
|
|
131
|
+
this.gitEvidence.worktreeContentDigest(accepted.files, workspace) !== accepted.contentDigest) {
|
|
132
|
+
throw new Error("Ticket inventory or content differs from its current review.");
|
|
133
|
+
}
|
|
134
|
+
if (!alreadyIntegrated) {
|
|
135
|
+
this.assertNoOperation(root);
|
|
136
|
+
this.assertNoHooks(root);
|
|
137
|
+
this.workflowRecordsCommitter.commit(feature, options.confirmRecords === true);
|
|
138
|
+
}
|
|
139
|
+
const common = realpathSync(this.run(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]));
|
|
140
|
+
const directory = join(common, "spindle-ticket-integration");
|
|
141
|
+
try {
|
|
142
|
+
mkdirSync(directory, { mode: 0o700 });
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (!lstatIfPresent(directory)?.isDirectory())
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
if (!lstatSync(directory).isDirectory() || lstatSync(directory).isSymbolicLink()) {
|
|
149
|
+
throw new Error("Integration journal directory must be a real directory.");
|
|
150
|
+
}
|
|
151
|
+
const journalPath = join(directory, `${feature.feature}-${options.ticket}-${identity.headSha}.json`);
|
|
152
|
+
let journal;
|
|
153
|
+
if (lstatIfPresent(journalPath)) {
|
|
154
|
+
journal = this.readJournal(journalPath, feature, progress, identity.headSha, accepted);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
if (progress.execution === "integrated") {
|
|
158
|
+
throw new Error(`Integration journal is missing: ${journalPath}.`);
|
|
159
|
+
}
|
|
160
|
+
this.assertNoOperation(root);
|
|
161
|
+
this.assertNoHooks(root);
|
|
162
|
+
this.assertClean(root, feature);
|
|
163
|
+
const featureHead = this.run(root, ["rev-parse", "--verify", "HEAD"]);
|
|
164
|
+
this.assertAncestor(root, progress.baseSha, featureHead, "Ticket baseline is no longer on the feature branch.");
|
|
165
|
+
if (this.isAncestor(root, identity.headSha, featureHead)) {
|
|
166
|
+
throw new Error("Ticket commit is already in feature history; refusing an unverified integration.");
|
|
167
|
+
}
|
|
168
|
+
this.assertMergePreflight(root, featureHead, identity.headSha, accepted.files, workspace);
|
|
169
|
+
journal = {
|
|
170
|
+
version: 1,
|
|
171
|
+
id: randomUUID(),
|
|
172
|
+
feature: feature.feature,
|
|
173
|
+
ticket: options.ticket,
|
|
174
|
+
featureHead,
|
|
175
|
+
ticketHead: identity.headSha,
|
|
176
|
+
snapshot: identity.snapshot,
|
|
177
|
+
files: accepted.files,
|
|
178
|
+
contentDigest: accepted.contentDigest,
|
|
179
|
+
};
|
|
180
|
+
this.createJournal(journalPath, journal);
|
|
181
|
+
}
|
|
182
|
+
if (alreadyIntegrated) {
|
|
183
|
+
const mergeSha = progress.integration?.mergeSha;
|
|
184
|
+
if (!mergeSha ||
|
|
185
|
+
progress.integration?.postMerge?.status !== "pending" ||
|
|
186
|
+
progress.integration.postMerge.mergeSha !== mergeSha ||
|
|
187
|
+
progress.integration.commitSha !== journal.ticketHead ||
|
|
188
|
+
progress.integration.contentDigest !== journal.contentDigest ||
|
|
189
|
+
JSON.stringify(progress.integration.files) !== JSON.stringify(journal.files)) {
|
|
190
|
+
throw new Error("Ticket integration has no valid pending post-merge record.");
|
|
191
|
+
}
|
|
192
|
+
this.assertMergeCommit(root, journal, mergeSha);
|
|
193
|
+
this.assertAncestor(root, mergeSha, "HEAD", "Recorded merge is no longer on the feature branch.");
|
|
194
|
+
return {
|
|
195
|
+
ticket: options.ticket,
|
|
196
|
+
ticketCommit: journal.ticketHead,
|
|
197
|
+
mergeCommit: mergeSha,
|
|
198
|
+
postMerge: "pending",
|
|
199
|
+
journal: journalPath,
|
|
200
|
+
idempotent: true,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
const currentHead = this.run(root, ["rev-parse", "--verify", "HEAD"]);
|
|
205
|
+
let mergeSha;
|
|
206
|
+
if (currentHead === journal.featureHead) {
|
|
207
|
+
this.assertNoOperation(root);
|
|
208
|
+
this.assertNoHooks(root);
|
|
209
|
+
this.assertClean(root, feature);
|
|
210
|
+
this.assertNoIgnoredCollisions(root, journal);
|
|
211
|
+
this.run(root, [
|
|
212
|
+
"-c",
|
|
213
|
+
"merge.autostash=false",
|
|
214
|
+
"merge",
|
|
215
|
+
"--no-ff",
|
|
216
|
+
"--no-commit",
|
|
217
|
+
"--no-autostash",
|
|
218
|
+
"--no-overwrite-ignore",
|
|
219
|
+
journal.ticketHead,
|
|
220
|
+
]);
|
|
221
|
+
const mergeHead = this.run(root, ["rev-parse", "--verify", "MERGE_HEAD"]);
|
|
222
|
+
if (mergeHead !== journal.ticketHead)
|
|
223
|
+
throw new Error("Unexpected merge input was preserved.");
|
|
224
|
+
this.assertIndexMatchesReview(root, journal);
|
|
225
|
+
const confirmed = this.gitEvidence.contentIdentity(feature, ticket, progress.baseSha, progress.checks);
|
|
226
|
+
if (confirmed.snapshot !== journal.snapshot || confirmed.headSha !== journal.ticketHead) {
|
|
227
|
+
throw new Error("Ticket evidence changed during merge; re-review is required.");
|
|
228
|
+
}
|
|
229
|
+
const confirmedBlockers = this.acceptance.acceptBlockers(feature, ledger, ticket, progress, confirmed);
|
|
230
|
+
if (confirmedBlockers.length)
|
|
231
|
+
throw new Error(`Ticket review changed during merge:\n${confirmedBlockers.join("\n")}`);
|
|
232
|
+
this.reports.assertAcceptedReportsCurrent(feature, progress);
|
|
233
|
+
if (this.run(root, ["rev-parse", "--verify", "HEAD"]) !== journal.featureHead) {
|
|
234
|
+
throw new Error("Feature branch changed during merge.");
|
|
235
|
+
}
|
|
236
|
+
this.run(root, ["commit", "-m", this.message(journal)]);
|
|
237
|
+
mergeSha = this.run(root, ["rev-parse", "--verify", "HEAD"]);
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
mergeSha = currentHead;
|
|
241
|
+
}
|
|
242
|
+
this.assertMergeCommit(root, journal, mergeSha);
|
|
243
|
+
this.assertClean(root, feature);
|
|
244
|
+
progress.execution = "integrated";
|
|
245
|
+
const carriedChecks = failedCorrection ? previousIntegration?.postMerge?.checks : undefined;
|
|
246
|
+
progress.integration = {
|
|
247
|
+
commitSha: journal.ticketHead,
|
|
248
|
+
mergeSha,
|
|
249
|
+
postMerge: {
|
|
250
|
+
status: "pending",
|
|
251
|
+
mergeSha,
|
|
252
|
+
...(carriedChecks
|
|
253
|
+
? {
|
|
254
|
+
checks: {
|
|
255
|
+
required: carriedChecks.required.map((requirement) => ({ ...requirement })),
|
|
256
|
+
records: [],
|
|
257
|
+
},
|
|
258
|
+
}
|
|
259
|
+
: {}),
|
|
260
|
+
},
|
|
261
|
+
integratedAt: new Date().toISOString(),
|
|
262
|
+
integratedBy: options.author.trim(),
|
|
263
|
+
files: accepted.files,
|
|
264
|
+
contentDigest: accepted.contentDigest,
|
|
265
|
+
};
|
|
266
|
+
const afterTicket = matter.stringify(this.tickets.appendAudit(ticket.body, options.author, `Reviewed ticket commit ${journal.ticketHead} merged as ${mergeSha}; post-merge checks are pending.`), this.tickets.metadata(ticket));
|
|
267
|
+
progress.acceptedEvidence = accepted;
|
|
268
|
+
this.transaction.writeWorkflowState(feature, ticket, afterTicket, ledger);
|
|
269
|
+
return {
|
|
270
|
+
ticket: options.ticket,
|
|
271
|
+
ticketCommit: journal.ticketHead,
|
|
272
|
+
mergeCommit: mergeSha,
|
|
273
|
+
postMerge: "pending",
|
|
274
|
+
journal: journalPath,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
throw new Error(`Ticket integration stopped; preserve the feature checkout and inspect ${journalPath}. ${error instanceof Error ? error.message : String(error)}`);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
assertCanonical(root, feature) {
|
|
283
|
+
const common = realpathSync(this.run(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]));
|
|
284
|
+
const path = join(common, "spindle-feature-start", `${feature.feature}.json`);
|
|
285
|
+
const entry = lstatIfPresent(path);
|
|
286
|
+
if (!entry?.isFile() || entry.isSymbolicLink())
|
|
287
|
+
throw new Error("Canonical feature startup state is missing or unsafe.");
|
|
288
|
+
const state = JSON.parse(readFileSync(path, "utf8"));
|
|
289
|
+
if (!Records.isRecord(state) ||
|
|
290
|
+
state.version !== 1 ||
|
|
291
|
+
state.phase !== "created" ||
|
|
292
|
+
state.feature !== feature.feature ||
|
|
293
|
+
state.featureWorkspace !== root ||
|
|
294
|
+
state.featureBranch !== `features/${feature.feature}` ||
|
|
295
|
+
this.run(root, ["symbolic-ref", "--quiet", "--short", "HEAD"]) !== state.featureBranch) {
|
|
296
|
+
throw new Error("Integration must run in the recorded canonical feature worktree.");
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
acceptedEvidence(feature, progress, identity) {
|
|
300
|
+
if (progress.execution === "integrated" &&
|
|
301
|
+
progress.integration?.commitSha === identity.headSha &&
|
|
302
|
+
progress.acceptedEvidence) {
|
|
303
|
+
return progress.acceptedEvidence;
|
|
304
|
+
}
|
|
305
|
+
const files = this.workflow.acceptedInventory(progress, progress.id);
|
|
306
|
+
const workspace = this.gitEvidence.ticketWorkspace(progress);
|
|
307
|
+
this.gitEvidence.assertTicketCodeCommitted(progress);
|
|
308
|
+
return {
|
|
309
|
+
snapshot: identity.snapshot,
|
|
310
|
+
codeSnapshot: identity.codeSnapshot,
|
|
311
|
+
requirementsSnapshot: identity.requirementsSnapshot,
|
|
312
|
+
files,
|
|
313
|
+
contentDigest: this.gitEvidence.worktreeContentDigest(files, workspace),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
readJournal(path, feature, progress, ticketHead, accepted) {
|
|
317
|
+
const entry = lstatSync(path);
|
|
318
|
+
if (!entry.isFile() || entry.isSymbolicLink())
|
|
319
|
+
throw new Error(`Integration journal is unsafe: ${path}.`);
|
|
320
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
321
|
+
if (!Records.isRecord(value) ||
|
|
322
|
+
value.version !== 1 ||
|
|
323
|
+
typeof value.id !== "string" ||
|
|
324
|
+
!/^[0-9a-f-]{36}$/i.test(value.id) ||
|
|
325
|
+
value.feature !== feature.feature ||
|
|
326
|
+
value.ticket !== progress.id ||
|
|
327
|
+
typeof value.featureHead !== "string" ||
|
|
328
|
+
!/^[0-9a-f]{40,64}$/i.test(value.featureHead) ||
|
|
329
|
+
value.ticketHead !== ticketHead ||
|
|
330
|
+
value.snapshot !== accepted.snapshot ||
|
|
331
|
+
value.contentDigest !== accepted.contentDigest ||
|
|
332
|
+
JSON.stringify(value.files) !== JSON.stringify(accepted.files)) {
|
|
333
|
+
throw new Error(`Integration journal conflicts with current ticket evidence: ${path}.`);
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
version: 1,
|
|
337
|
+
id: value.id,
|
|
338
|
+
feature: feature.feature,
|
|
339
|
+
ticket: progress.id,
|
|
340
|
+
featureHead: value.featureHead,
|
|
341
|
+
ticketHead,
|
|
342
|
+
snapshot: accepted.snapshot,
|
|
343
|
+
files: accepted.files,
|
|
344
|
+
contentDigest: accepted.contentDigest,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
createJournal(path, journal) {
|
|
348
|
+
const fd = openSync(path, "wx", 0o600);
|
|
349
|
+
try {
|
|
350
|
+
writeFileSync(fd, `${JSON.stringify(journal, null, 2)}\n`);
|
|
351
|
+
fsyncSync(fd);
|
|
352
|
+
}
|
|
353
|
+
finally {
|
|
354
|
+
closeSync(fd);
|
|
355
|
+
}
|
|
356
|
+
const directory = openSync(resolve(path, ".."), "r");
|
|
357
|
+
try {
|
|
358
|
+
fsyncSync(directory);
|
|
359
|
+
}
|
|
360
|
+
finally {
|
|
361
|
+
closeSync(directory);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
assertMergePreflight(root, featureHead, ticketHead, files, workspace) {
|
|
365
|
+
const result = this.git.run(["merge-tree", "--write-tree", "--name-only", featureHead, ticketHead], {
|
|
366
|
+
cwd: root,
|
|
367
|
+
encoding: "utf8",
|
|
368
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
|
|
369
|
+
});
|
|
370
|
+
if (result.error)
|
|
371
|
+
throw result.error;
|
|
372
|
+
if (result.status === 1) {
|
|
373
|
+
throw new Error(`Merge conflicts require resolution in ticket worktree ${workspace}; rebase it onto the current feature branch, resolve and commit conflicts, adopt the baseline with checks require --rebase-baseline, then obtain fresh checks and review. Feature checkout was preserved.\n${result.stdout.trim()}`);
|
|
374
|
+
}
|
|
375
|
+
if (result.status !== 0)
|
|
376
|
+
throw new Error(`Merge preflight failed; preserve both worktrees: ${result.stderr.trim()}`);
|
|
377
|
+
const tree = result.stdout.trim().split(/\s+/)[0];
|
|
378
|
+
if (!tree || !/^[0-9a-f]{40,64}$/i.test(tree))
|
|
379
|
+
throw new Error("Merge preflight returned an invalid tree.");
|
|
380
|
+
const paths = this.buffer(root, [
|
|
381
|
+
"diff",
|
|
382
|
+
"--name-only",
|
|
383
|
+
"--no-renames",
|
|
384
|
+
"-z",
|
|
385
|
+
featureHead,
|
|
386
|
+
tree,
|
|
387
|
+
"--",
|
|
388
|
+
".",
|
|
389
|
+
])
|
|
390
|
+
.toString("utf8")
|
|
391
|
+
.split("\0")
|
|
392
|
+
.filter(Boolean);
|
|
393
|
+
if (paths.some((path) => !files.includes(path)))
|
|
394
|
+
throw new Error("Merge would introduce unreviewed paths; re-review in the ticket worktree.");
|
|
395
|
+
const diff = this.git.run(["diff", "--quiet", ticketHead, tree, "--", ...files.map((path) => `:(literal)${path}`)], { cwd: root, encoding: "utf8" });
|
|
396
|
+
if (diff.error)
|
|
397
|
+
throw diff.error;
|
|
398
|
+
if (diff.status !== 0)
|
|
399
|
+
throw new Error(`Merge differs from the reviewed ticket commit; reconcile in ${workspace} and obtain fresh review.`);
|
|
400
|
+
}
|
|
401
|
+
assertNoIgnoredCollisions(root, journal) {
|
|
402
|
+
// Some Git versions still overwrite ignored files during a non-fast-forward merge
|
|
403
|
+
// despite --no-overwrite-ignore, so check the reviewed incoming paths as well.
|
|
404
|
+
if (!journal.files.length)
|
|
405
|
+
return;
|
|
406
|
+
const literals = journal.files.map((path) => `:(literal)${path}`);
|
|
407
|
+
const incoming = this.buffer(root, [
|
|
408
|
+
"ls-tree",
|
|
409
|
+
"-r",
|
|
410
|
+
"--name-only",
|
|
411
|
+
"-z",
|
|
412
|
+
journal.ticketHead,
|
|
413
|
+
"--",
|
|
414
|
+
...literals,
|
|
415
|
+
])
|
|
416
|
+
.toString("utf8")
|
|
417
|
+
.split("\0")
|
|
418
|
+
.filter(Boolean);
|
|
419
|
+
if (!incoming.length)
|
|
420
|
+
return;
|
|
421
|
+
const candidates = new Set(incoming);
|
|
422
|
+
for (const path of incoming) {
|
|
423
|
+
const components = path.split("/");
|
|
424
|
+
for (let count = 1; count < components.length; count++) {
|
|
425
|
+
const ancestor = components.slice(0, count).join("/");
|
|
426
|
+
const entry = lstatIfPresent(join(root, ancestor));
|
|
427
|
+
if (!entry)
|
|
428
|
+
break;
|
|
429
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
|
430
|
+
candidates.add(ancestor);
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const ignored = this.buffer(root, [
|
|
436
|
+
"ls-files",
|
|
437
|
+
"--others",
|
|
438
|
+
"--ignored",
|
|
439
|
+
"--exclude-standard",
|
|
440
|
+
"--full-name",
|
|
441
|
+
"-z",
|
|
442
|
+
"--",
|
|
443
|
+
...[...candidates].map((path) => `:(literal)${path}`),
|
|
444
|
+
])
|
|
445
|
+
.toString("utf8")
|
|
446
|
+
.split("\0")
|
|
447
|
+
.filter((path) => path &&
|
|
448
|
+
incoming.some((target) => path === target || path.startsWith(`${target}/`) || target.startsWith(`${path}/`)));
|
|
449
|
+
if (!ignored.length) {
|
|
450
|
+
for (const path of incoming) {
|
|
451
|
+
if (!lstatIfPresent(join(root, path))?.isDirectory())
|
|
452
|
+
continue;
|
|
453
|
+
const result = this.git.run(["check-ignore", "--quiet", "--", path], {
|
|
454
|
+
cwd: root,
|
|
455
|
+
encoding: "utf8",
|
|
456
|
+
});
|
|
457
|
+
if (result.error)
|
|
458
|
+
throw result.error;
|
|
459
|
+
if (result.status === 0)
|
|
460
|
+
ignored.push(path);
|
|
461
|
+
else if (result.status !== 1)
|
|
462
|
+
throw new Error("Cannot verify ignored directory collision safety.");
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (ignored.length) {
|
|
466
|
+
throw new Error(`Ignored files would be overwritten by the reviewed merge; preserve or move them before retrying: ${ignored.join(", ")}.`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
assertIndexMatchesReview(root, journal) {
|
|
470
|
+
if (this.buffer(root, ["ls-files", "--unmerged", "-z"]).length) {
|
|
471
|
+
throw new Error("Merge conflicts remain; conflict resolution requires fresh review.");
|
|
472
|
+
}
|
|
473
|
+
const paths = this.buffer(root, [
|
|
474
|
+
"diff",
|
|
475
|
+
"--cached",
|
|
476
|
+
"--name-only",
|
|
477
|
+
"--no-renames",
|
|
478
|
+
"-z",
|
|
479
|
+
journal.featureHead,
|
|
480
|
+
"--",
|
|
481
|
+
".",
|
|
482
|
+
])
|
|
483
|
+
.toString("utf8")
|
|
484
|
+
.split("\0")
|
|
485
|
+
.filter(Boolean);
|
|
486
|
+
if (paths.some((path) => !journal.files.includes(path))) {
|
|
487
|
+
throw new Error("Merge introduces unreviewed paths; preserve the merge for review.");
|
|
488
|
+
}
|
|
489
|
+
const diff = this.git.run([
|
|
490
|
+
"diff",
|
|
491
|
+
"--cached",
|
|
492
|
+
"--quiet",
|
|
493
|
+
journal.ticketHead,
|
|
494
|
+
"--",
|
|
495
|
+
...journal.files.map((path) => `:(literal)${path}`),
|
|
496
|
+
], { cwd: root, encoding: "utf8" });
|
|
497
|
+
if (diff.error)
|
|
498
|
+
throw diff.error;
|
|
499
|
+
if (diff.status !== 0)
|
|
500
|
+
throw new Error("Merged content differs from the reviewed ticket commit; preserve it for fresh review.");
|
|
501
|
+
}
|
|
502
|
+
assertMergeCommit(root, journal, mergeSha) {
|
|
503
|
+
const parents = this.run(root, ["rev-list", "--parents", "-n", "1", mergeSha]).split(" ");
|
|
504
|
+
const message = this.run(root, ["show", "-s", "--format=%B", mergeSha]);
|
|
505
|
+
if (parents.length !== 3 ||
|
|
506
|
+
parents[1] !== journal.featureHead ||
|
|
507
|
+
parents[2] !== journal.ticketHead ||
|
|
508
|
+
message !== this.message(journal)) {
|
|
509
|
+
throw new Error("Feature HEAD is not the journaled reviewed merge; preserve it for inspection.");
|
|
510
|
+
}
|
|
511
|
+
const paths = this.buffer(root, [
|
|
512
|
+
"diff",
|
|
513
|
+
"--name-only",
|
|
514
|
+
"--no-renames",
|
|
515
|
+
"-z",
|
|
516
|
+
journal.featureHead,
|
|
517
|
+
mergeSha,
|
|
518
|
+
"--",
|
|
519
|
+
".",
|
|
520
|
+
])
|
|
521
|
+
.toString("utf8")
|
|
522
|
+
.split("\0")
|
|
523
|
+
.filter(Boolean);
|
|
524
|
+
if (paths.some((path) => !journal.files.includes(path)))
|
|
525
|
+
throw new Error("Merge commit contains unreviewed paths.");
|
|
526
|
+
const diff = this.git.run([
|
|
527
|
+
"diff",
|
|
528
|
+
"--quiet",
|
|
529
|
+
journal.ticketHead,
|
|
530
|
+
mergeSha,
|
|
531
|
+
"--",
|
|
532
|
+
...journal.files.map((path) => `:(literal)${path}`),
|
|
533
|
+
], { cwd: root, encoding: "utf8" });
|
|
534
|
+
if (diff.error)
|
|
535
|
+
throw diff.error;
|
|
536
|
+
if (diff.status !== 0)
|
|
537
|
+
throw new Error("Merge commit differs from the reviewed ticket content.");
|
|
538
|
+
}
|
|
539
|
+
message(journal) {
|
|
540
|
+
return `spindle: merge ${journal.ticket} (${journal.id})`;
|
|
541
|
+
}
|
|
542
|
+
assertClean(root, feature) {
|
|
543
|
+
const lock = `.scratch/${feature.feature}/.sdd-tickets.lock`;
|
|
544
|
+
const status = this.run(root, [
|
|
545
|
+
"status",
|
|
546
|
+
"--porcelain=v1",
|
|
547
|
+
"--untracked-files=all",
|
|
548
|
+
"--ignore-submodules=none",
|
|
549
|
+
"--",
|
|
550
|
+
".",
|
|
551
|
+
`:(exclude,literal)${lock}`,
|
|
552
|
+
]);
|
|
553
|
+
if (status)
|
|
554
|
+
throw new Error(`Commit canonical workflow records and resolve other changes before integration:\n${status}`);
|
|
555
|
+
}
|
|
556
|
+
assertNoOperation(root) {
|
|
557
|
+
if (this.buffer(root, ["ls-files", "--unmerged", "-z"]).length) {
|
|
558
|
+
throw new Error("Merge conflicts require reviewed resolution before continuing.");
|
|
559
|
+
}
|
|
560
|
+
for (const operation of [
|
|
561
|
+
"MERGE_HEAD",
|
|
562
|
+
"CHERRY_PICK_HEAD",
|
|
563
|
+
"REVERT_HEAD",
|
|
564
|
+
"rebase-merge",
|
|
565
|
+
"rebase-apply",
|
|
566
|
+
"sequencer",
|
|
567
|
+
]) {
|
|
568
|
+
const path = resolve(root, this.run(root, ["rev-parse", "--git-path", operation]));
|
|
569
|
+
if (lstatIfPresent(path))
|
|
570
|
+
throw new Error(`Existing Git operation ${operation} was preserved; inspect and review before continuing.`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
assertNoHooks(root) {
|
|
574
|
+
for (const hook of [
|
|
575
|
+
"pre-merge-commit",
|
|
576
|
+
"pre-commit",
|
|
577
|
+
"prepare-commit-msg",
|
|
578
|
+
"commit-msg",
|
|
579
|
+
"post-commit",
|
|
580
|
+
"post-merge",
|
|
581
|
+
"post-rewrite",
|
|
582
|
+
"pre-auto-gc",
|
|
583
|
+
"reference-transaction",
|
|
584
|
+
]) {
|
|
585
|
+
const path = this.run(root, ["rev-parse", "--git-path", `hooks/${hook}`]);
|
|
586
|
+
try {
|
|
587
|
+
accessSync(isAbsolute(path) ? path : join(root, path), constants.X_OK);
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
throw new Error(`Active Git hook ${path} requires manual handling before integration.`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
isAncestor(root, ancestor, descendant) {
|
|
596
|
+
const result = this.git.run(["merge-base", "--is-ancestor", ancestor, descendant], {
|
|
597
|
+
cwd: root,
|
|
598
|
+
encoding: "utf8",
|
|
599
|
+
});
|
|
600
|
+
if (result.error)
|
|
601
|
+
throw result.error;
|
|
602
|
+
if (result.status !== 0 && result.status !== 1)
|
|
603
|
+
throw new Error(`git merge-base failed: ${result.stderr.trim()}`);
|
|
604
|
+
return result.status === 0;
|
|
605
|
+
}
|
|
606
|
+
assertAncestor(root, ancestor, descendant, message) {
|
|
607
|
+
if (!this.isAncestor(root, ancestor, descendant))
|
|
608
|
+
throw new Error(message);
|
|
609
|
+
}
|
|
610
|
+
run(root, args) {
|
|
611
|
+
return this.buffer(root, args).toString("utf8").trim();
|
|
612
|
+
}
|
|
613
|
+
buffer(root, args) {
|
|
614
|
+
const result = this.git.run(args, { cwd: root, encoding: "buffer" });
|
|
615
|
+
if (result.error)
|
|
616
|
+
throw result.error;
|
|
617
|
+
if (result.status !== 0)
|
|
618
|
+
throw new Error(`git ${args[0]} failed: ${result.stderr.toString("utf8").trim()}`);
|
|
619
|
+
return result.stdout;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
@@ -108,6 +108,9 @@ export class TicketService extends Service {
|
|
|
108
108
|
!TRACKER_STATUSES.some((status) => status === options.status)) {
|
|
109
109
|
throw new Error(`Ticket status must be one of: ${TRACKER_STATUSES.join(", ")}.`);
|
|
110
110
|
}
|
|
111
|
+
if (options.status === "closed") {
|
|
112
|
+
throw new Error("Tickets close automatically after passing post-merge checks.");
|
|
113
|
+
}
|
|
111
114
|
if (options.blocker !== undefined && !options.blocker.trim()) {
|
|
112
115
|
throw new Error("Blocker reason must not be empty.");
|
|
113
116
|
}
|