@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
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { accessSync, constants, closeSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { isAbsolute, join } from "node:path";
|
|
4
|
+
import { Service } from "../../Foundation/Service.js";
|
|
5
|
+
import { isPathInside, lstatIfPresent } from "../Persistence/FileSystem.js";
|
|
6
|
+
import { Records } from "../../Support/Records.js";
|
|
7
|
+
export class TicketWorktreeService extends Service {
|
|
8
|
+
git;
|
|
9
|
+
gitEvidence;
|
|
10
|
+
constructor(app, git, gitEvidence) {
|
|
11
|
+
super(app);
|
|
12
|
+
this.git = git;
|
|
13
|
+
this.gitEvidence = gitEvidence;
|
|
14
|
+
}
|
|
15
|
+
create(feature, progress, directory, requiredAncestors, allowPendingRecords = false) {
|
|
16
|
+
for (const variable of [
|
|
17
|
+
"GIT_DIR",
|
|
18
|
+
"GIT_WORK_TREE",
|
|
19
|
+
"GIT_COMMON_DIR",
|
|
20
|
+
"GIT_INDEX_FILE",
|
|
21
|
+
"GIT_OBJECT_DIRECTORY",
|
|
22
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
23
|
+
"GIT_NAMESPACE",
|
|
24
|
+
]) {
|
|
25
|
+
if (process.env[variable])
|
|
26
|
+
throw new Error(`Unset ${variable} before ticket dispatch.`);
|
|
27
|
+
}
|
|
28
|
+
if (!directory || !isAbsolute(directory)) {
|
|
29
|
+
throw new Error("--workspace must be an absolute existing external directory.");
|
|
30
|
+
}
|
|
31
|
+
const root = realpathSync(this.app.workspaceRoot);
|
|
32
|
+
const entry = lstatIfPresent(directory);
|
|
33
|
+
if (!entry?.isDirectory() || entry.isSymbolicLink()) {
|
|
34
|
+
throw new Error("--workspace must be an existing real directory.");
|
|
35
|
+
}
|
|
36
|
+
const parent = realpathSync(directory);
|
|
37
|
+
accessSync(parent, constants.W_OK | constants.X_OK);
|
|
38
|
+
const common = realpathSync(this.run(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]));
|
|
39
|
+
const statePath = join(common, "spindle-feature-start", `${feature.feature}.json`);
|
|
40
|
+
const stateEntry = lstatIfPresent(statePath);
|
|
41
|
+
if (!stateEntry?.isFile() || stateEntry.isSymbolicLink()) {
|
|
42
|
+
throw new Error(`Feature startup state is missing or unsafe: ${statePath}.`);
|
|
43
|
+
}
|
|
44
|
+
const state = JSON.parse(readFileSync(statePath, "utf8"));
|
|
45
|
+
if (!Records.isRecord(state) ||
|
|
46
|
+
state.version !== 1 ||
|
|
47
|
+
state.phase !== "created" ||
|
|
48
|
+
state.feature !== feature.feature ||
|
|
49
|
+
state.featureBranch !== `features/${feature.feature}` ||
|
|
50
|
+
state.featureWorkspace !== root ||
|
|
51
|
+
typeof state.originalWorkspace !== "string" ||
|
|
52
|
+
!isAbsolute(state.originalWorkspace)) {
|
|
53
|
+
throw new Error("Feature startup state does not identify this canonical feature worktree.");
|
|
54
|
+
}
|
|
55
|
+
const original = realpathSync(state.originalWorkspace);
|
|
56
|
+
if (realpathSync(this.run(original, ["rev-parse", "--show-toplevel"])) !== original ||
|
|
57
|
+
realpathSync(this.run(original, ["rev-parse", "--path-format=absolute", "--git-common-dir"])) !== common) {
|
|
58
|
+
throw new Error("Recorded original checkout no longer matches the feature repository.");
|
|
59
|
+
}
|
|
60
|
+
if (isPathInside(original, parent) ||
|
|
61
|
+
isPathInside(root, parent) ||
|
|
62
|
+
isPathInside(common, parent)) {
|
|
63
|
+
throw new Error("--workspace must be outside the original and feature checkouts and Git directory.");
|
|
64
|
+
}
|
|
65
|
+
const branch = `tickets/${feature.feature}/${progress.id}`;
|
|
66
|
+
const workspace = join(parent, `${feature.feature}-${progress.id}`);
|
|
67
|
+
if (this.run(root, ["symbolic-ref", "--quiet", "--short", "HEAD"]) !== state.featureBranch) {
|
|
68
|
+
throw new Error("Ticket worktrees must start from the canonical feature branch.");
|
|
69
|
+
}
|
|
70
|
+
const currentSha = this.run(root, ["rev-parse", "--verify", "HEAD"]);
|
|
71
|
+
const lockPath = `.scratch/${feature.feature}/.sdd-tickets.lock`;
|
|
72
|
+
if (allowPendingRecords) {
|
|
73
|
+
if (this.gitEvidence.cleanDispatchBaseline(feature, true) !== currentSha) {
|
|
74
|
+
throw new Error("Feature HEAD changed during ticket dispatch.");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
const status = this.run(root, [
|
|
79
|
+
"status",
|
|
80
|
+
"--porcelain=v1",
|
|
81
|
+
"--untracked-files=all",
|
|
82
|
+
"--ignore-submodules=none",
|
|
83
|
+
"--",
|
|
84
|
+
".",
|
|
85
|
+
`:(exclude,literal)${lockPath}`,
|
|
86
|
+
]);
|
|
87
|
+
if (status) {
|
|
88
|
+
throw new Error(`Commit canonical workflow records and resolve other changes before ticket dispatch:\n${status}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const hook = this.run(root, ["rev-parse", "--git-path", "hooks/post-checkout"]);
|
|
92
|
+
let activeHook = false;
|
|
93
|
+
try {
|
|
94
|
+
accessSync(isAbsolute(hook) ? hook : join(root, hook), constants.X_OK);
|
|
95
|
+
activeHook = true;
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (typeof error !== "object" ||
|
|
99
|
+
error === null ||
|
|
100
|
+
!("code" in error) ||
|
|
101
|
+
(error.code !== "ENOENT" && error.code !== "EACCES")) {
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (activeHook) {
|
|
106
|
+
throw new Error("Active Git post-checkout hook requires manual handling before ticket dispatch.");
|
|
107
|
+
}
|
|
108
|
+
const journals = join(common, "spindle-ticket-start");
|
|
109
|
+
try {
|
|
110
|
+
mkdirSync(journals, { mode: 0o700 });
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (!lstatIfPresent(journals)?.isDirectory())
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
if (!lstatSync(journals).isDirectory() || lstatSync(journals).isSymbolicLink()) {
|
|
117
|
+
throw new Error("Ticket startup state directory must be a real directory.");
|
|
118
|
+
}
|
|
119
|
+
const journal = join(journals, `${feature.feature}-${progress.id}.json`);
|
|
120
|
+
if (!progress.owner)
|
|
121
|
+
throw new Error("Ticket startup requires a recorded controller.");
|
|
122
|
+
let stateRecord;
|
|
123
|
+
const journalEntry = lstatIfPresent(journal);
|
|
124
|
+
if (journalEntry) {
|
|
125
|
+
if (!journalEntry.isFile() || journalEntry.isSymbolicLink()) {
|
|
126
|
+
throw new Error(`Ticket startup journal is not a regular file: ${journal}.`);
|
|
127
|
+
}
|
|
128
|
+
const saved = JSON.parse(readFileSync(journal, "utf8"));
|
|
129
|
+
if (!Records.isRecord(saved) ||
|
|
130
|
+
saved.version !== 2 ||
|
|
131
|
+
typeof saved.id !== "string" ||
|
|
132
|
+
saved.feature !== feature.feature ||
|
|
133
|
+
saved.ticket !== progress.id ||
|
|
134
|
+
saved.owner !== progress.owner ||
|
|
135
|
+
saved.featureWorkspace !== root ||
|
|
136
|
+
saved.workspace !== workspace ||
|
|
137
|
+
saved.branch !== branch ||
|
|
138
|
+
typeof saved.baseSha !== "string" ||
|
|
139
|
+
!/^[0-9a-f]{40,64}$/i.test(saved.baseSha) ||
|
|
140
|
+
(saved.directory !== undefined && !Records.isRecord(saved.directory))) {
|
|
141
|
+
throw new Error(`Ticket startup journal does not match the current assignment: ${journal}.`);
|
|
142
|
+
}
|
|
143
|
+
const directoryState = saved.directory;
|
|
144
|
+
let directoryIdentity;
|
|
145
|
+
if (directoryState !== undefined) {
|
|
146
|
+
if (!Records.isRecord(directoryState) ||
|
|
147
|
+
typeof directoryState.device !== "number" ||
|
|
148
|
+
typeof directoryState.inode !== "number") {
|
|
149
|
+
throw new Error(`Ticket startup journal contains invalid directory identity: ${journal}.`);
|
|
150
|
+
}
|
|
151
|
+
directoryIdentity = { device: directoryState.device, inode: directoryState.inode };
|
|
152
|
+
}
|
|
153
|
+
stateRecord = {
|
|
154
|
+
version: 2,
|
|
155
|
+
id: saved.id,
|
|
156
|
+
feature: feature.feature,
|
|
157
|
+
ticket: progress.id,
|
|
158
|
+
owner: progress.owner,
|
|
159
|
+
featureWorkspace: root,
|
|
160
|
+
workspace,
|
|
161
|
+
branch,
|
|
162
|
+
baseSha: saved.baseSha,
|
|
163
|
+
...(directoryIdentity ? { directory: directoryIdentity } : {}),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
if (lstatIfPresent(workspace)) {
|
|
168
|
+
throw new Error(`Ticket worktree destination already exists: ${workspace}.`);
|
|
169
|
+
}
|
|
170
|
+
if (this.branchExists(root, branch))
|
|
171
|
+
throw new Error(`Ticket branch already exists: ${branch}.`);
|
|
172
|
+
if (requiredAncestors.some((ancestor) => !this.isAncestor(root, ancestor, currentSha))) {
|
|
173
|
+
throw new Error("Feature branch does not contain every validated prerequisite.");
|
|
174
|
+
}
|
|
175
|
+
stateRecord = {
|
|
176
|
+
version: 2,
|
|
177
|
+
id: randomUUID(),
|
|
178
|
+
feature: feature.feature,
|
|
179
|
+
ticket: progress.id,
|
|
180
|
+
owner: progress.owner,
|
|
181
|
+
featureWorkspace: root,
|
|
182
|
+
workspace,
|
|
183
|
+
branch,
|
|
184
|
+
baseSha: currentSha,
|
|
185
|
+
};
|
|
186
|
+
try {
|
|
187
|
+
this.writeState(journal, stateRecord, true);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
throw new Error(`Ticket startup state could not be created; inspect ${journal}. ${error instanceof Error ? error.message : String(error)}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (!this.isAncestor(root, stateRecord.baseSha, currentSha) ||
|
|
194
|
+
requiredAncestors.some((ancestor) => !this.isAncestor(root, ancestor, stateRecord.baseSha))) {
|
|
195
|
+
throw new Error("Ticket startup baseline does not include the current prerequisite integrations; preserve the recorded worktree and inspect it before continuing.");
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const registered = this.run(root, ["worktree", "list", "--porcelain", "-z"])
|
|
199
|
+
.split("\0")
|
|
200
|
+
.includes(`worktree ${workspace}`);
|
|
201
|
+
if (!registered) {
|
|
202
|
+
if (this.branchExists(root, branch)) {
|
|
203
|
+
throw new Error(`Ticket branch exists without its recorded worktree: ${branch}.`);
|
|
204
|
+
}
|
|
205
|
+
let directoryEntry = lstatIfPresent(workspace);
|
|
206
|
+
if (!directoryEntry) {
|
|
207
|
+
if (stateRecord.directory)
|
|
208
|
+
throw new Error("Recorded ticket directory is missing.");
|
|
209
|
+
mkdirSync(workspace);
|
|
210
|
+
directoryEntry = lstatSync(workspace);
|
|
211
|
+
stateRecord.directory = { device: directoryEntry.dev, inode: directoryEntry.ino };
|
|
212
|
+
this.writeState(journal, stateRecord);
|
|
213
|
+
}
|
|
214
|
+
if (!directoryEntry.isDirectory() ||
|
|
215
|
+
directoryEntry.isSymbolicLink() ||
|
|
216
|
+
!stateRecord.directory ||
|
|
217
|
+
directoryEntry.dev !== stateRecord.directory.device ||
|
|
218
|
+
directoryEntry.ino !== stateRecord.directory.inode) {
|
|
219
|
+
throw new Error(`Ticket worktree directory ownership cannot be verified: ${workspace}.`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (this.run(root, ["symbolic-ref", "--quiet", "--short", "HEAD"]) !== state.featureBranch ||
|
|
223
|
+
this.run(root, ["rev-parse", "--verify", "HEAD"]) !== currentSha) {
|
|
224
|
+
throw new Error("Feature branch advanced during ticket worktree creation.");
|
|
225
|
+
}
|
|
226
|
+
if (!registered) {
|
|
227
|
+
this.run(root, ["worktree", "add", "-b", branch, workspace, stateRecord.baseSha]);
|
|
228
|
+
}
|
|
229
|
+
const directoryEntry = lstatSync(workspace);
|
|
230
|
+
if (!stateRecord.directory ||
|
|
231
|
+
!directoryEntry.isDirectory() ||
|
|
232
|
+
directoryEntry.isSymbolicLink() ||
|
|
233
|
+
directoryEntry.dev !== stateRecord.directory.device ||
|
|
234
|
+
directoryEntry.ino !== stateRecord.directory.inode ||
|
|
235
|
+
realpathSync(workspace) !== workspace ||
|
|
236
|
+
realpathSync(this.run(workspace, ["rev-parse", "--show-toplevel"])) !== workspace ||
|
|
237
|
+
realpathSync(this.run(workspace, ["rev-parse", "--path-format=absolute", "--git-common-dir"])) !== common ||
|
|
238
|
+
this.run(workspace, ["symbolic-ref", "--quiet", "--short", "HEAD"]) !== branch ||
|
|
239
|
+
this.run(workspace, ["rev-parse", "--verify", "HEAD"]) !== stateRecord.baseSha ||
|
|
240
|
+
this.run(workspace, ["status", "--porcelain=v1", "--untracked-files=all"]) !== "") {
|
|
241
|
+
throw new Error("Created ticket worktree failed verification.");
|
|
242
|
+
}
|
|
243
|
+
const finalStatus = allowPendingRecords
|
|
244
|
+
? this.gitEvidence.cleanDispatchBaseline(feature, true) === currentSha
|
|
245
|
+
: this.run(root, [
|
|
246
|
+
"status",
|
|
247
|
+
"--porcelain=v1",
|
|
248
|
+
"--untracked-files=all",
|
|
249
|
+
"--ignore-submodules=none",
|
|
250
|
+
"--",
|
|
251
|
+
".",
|
|
252
|
+
`:(exclude,literal)${lockPath}`,
|
|
253
|
+
]) === "";
|
|
254
|
+
if (this.run(root, ["symbolic-ref", "--quiet", "--short", "HEAD"]) !== state.featureBranch ||
|
|
255
|
+
this.run(root, ["rev-parse", "--verify", "HEAD"]) !== currentSha ||
|
|
256
|
+
!finalStatus) {
|
|
257
|
+
throw new Error("Feature branch or checkout changed during ticket worktree creation.");
|
|
258
|
+
}
|
|
259
|
+
return { workspace, branch, baseSha: stateRecord.baseSha, journal };
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
throw new Error(`Ticket worktree startup stopped; preserve and inspect ${journal}, ${branch}, and ${workspace}. ${error instanceof Error ? error.message : String(error)}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
branchExists(root, branch) {
|
|
266
|
+
const result = this.git.run(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
|
|
267
|
+
cwd: root,
|
|
268
|
+
encoding: "utf8",
|
|
269
|
+
});
|
|
270
|
+
if (result.error)
|
|
271
|
+
throw result.error;
|
|
272
|
+
if (result.status !== 0 && result.status !== 1) {
|
|
273
|
+
throw new Error(`Cannot check ticket branch: ${result.stderr.trim()}`);
|
|
274
|
+
}
|
|
275
|
+
return result.status === 0;
|
|
276
|
+
}
|
|
277
|
+
isAncestor(root, ancestor, descendant) {
|
|
278
|
+
const result = this.git.run(["merge-base", "--is-ancestor", ancestor, descendant], {
|
|
279
|
+
cwd: root,
|
|
280
|
+
encoding: "utf8",
|
|
281
|
+
});
|
|
282
|
+
if (result.error)
|
|
283
|
+
throw result.error;
|
|
284
|
+
if (result.status !== 0 && result.status !== 1) {
|
|
285
|
+
throw new Error(`Cannot verify ticket startup ancestry: ${result.stderr.trim()}`);
|
|
286
|
+
}
|
|
287
|
+
return result.status === 0;
|
|
288
|
+
}
|
|
289
|
+
writeState(path, state, create = false) {
|
|
290
|
+
const target = create ? path : `${path}.${randomUUID()}.tmp`;
|
|
291
|
+
const fd = openSync(target, "wx", 0o600);
|
|
292
|
+
try {
|
|
293
|
+
writeFileSync(fd, `${JSON.stringify(state, null, 2)}\n`);
|
|
294
|
+
fsyncSync(fd);
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
closeSync(fd);
|
|
298
|
+
}
|
|
299
|
+
if (!create)
|
|
300
|
+
renameSync(target, path);
|
|
301
|
+
const directory = openSync(join(path, ".."), "r");
|
|
302
|
+
try {
|
|
303
|
+
fsyncSync(directory);
|
|
304
|
+
}
|
|
305
|
+
finally {
|
|
306
|
+
closeSync(directory);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
run(root, args) {
|
|
310
|
+
const result = this.git.run(args, { cwd: root, encoding: "utf8" });
|
|
311
|
+
if (result.error)
|
|
312
|
+
throw result.error;
|
|
313
|
+
if (result.status !== 0)
|
|
314
|
+
throw new Error(`git ${args[0]} failed: ${result.stderr.trim()}`);
|
|
315
|
+
return result.stdout.trim();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { accessSync, closeSync, constants, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { Service } from "../../Foundation/Service.js";
|
|
5
|
+
import { lstatIfPresent } from "../Persistence/FileSystem.js";
|
|
6
|
+
import { Records } from "../../Support/Records.js";
|
|
7
|
+
export class WorkflowRecordsCommitter extends Service {
|
|
8
|
+
git;
|
|
9
|
+
constructor(app, git) {
|
|
10
|
+
super(app);
|
|
11
|
+
this.git = git;
|
|
12
|
+
}
|
|
13
|
+
commit(feature, confirmed) {
|
|
14
|
+
for (const variable of [
|
|
15
|
+
"GIT_DIR",
|
|
16
|
+
"GIT_WORK_TREE",
|
|
17
|
+
"GIT_COMMON_DIR",
|
|
18
|
+
"GIT_INDEX_FILE",
|
|
19
|
+
"GIT_OBJECT_DIRECTORY",
|
|
20
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
21
|
+
"GIT_NAMESPACE",
|
|
22
|
+
]) {
|
|
23
|
+
if (process.env[variable]) {
|
|
24
|
+
throw new Error(`Unset ${variable} before committing workflow records.`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const root = realpathSync(this.app.workspaceRoot);
|
|
28
|
+
if (realpathSync(feature.workspace) !== realpathSync(join(root, ".scratch", feature.feature))) {
|
|
29
|
+
throw new Error("Canonical workflow records do not belong to this feature worktree.");
|
|
30
|
+
}
|
|
31
|
+
const branch = this.run(root, ["symbolic-ref", "--quiet", "--short", "HEAD"]).trim();
|
|
32
|
+
if (branch !== `features/${feature.feature}`) {
|
|
33
|
+
throw new Error(`Canonical workflow records can only be committed on features/${feature.feature}; current branch is ${branch}.`);
|
|
34
|
+
}
|
|
35
|
+
this.assertNoGitOperation(root);
|
|
36
|
+
this.assertNoHooks(root);
|
|
37
|
+
const lock = `.scratch/${feature.feature}/.sdd-tickets.lock`;
|
|
38
|
+
const staged = this.paths(root, ["diff", "--cached", "--name-only", "--no-renames", "-z"]);
|
|
39
|
+
const unstaged = this.paths(root, ["diff", "--name-only", "--no-renames", "-z"]);
|
|
40
|
+
const untracked = this.paths(root, ["ls-files", "--others", "--exclude-standard", "-z"]);
|
|
41
|
+
const changed = [...new Set([...staged, ...unstaged, ...untracked])]
|
|
42
|
+
.filter((path) => path !== lock)
|
|
43
|
+
.sort();
|
|
44
|
+
const common = realpathSync(this.run(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]));
|
|
45
|
+
const directory = join(common, "spindle-record-commit");
|
|
46
|
+
const journal = join(directory, `${feature.feature}.json`);
|
|
47
|
+
const journalEntry = lstatIfPresent(journal);
|
|
48
|
+
let state;
|
|
49
|
+
if (journalEntry) {
|
|
50
|
+
const directoryEntry = lstatSync(directory);
|
|
51
|
+
if (!directoryEntry.isDirectory() || directoryEntry.isSymbolicLink()) {
|
|
52
|
+
throw new Error("Canonical record journal directory must be a real directory.");
|
|
53
|
+
}
|
|
54
|
+
if (!journalEntry.isFile() || journalEntry.isSymbolicLink()) {
|
|
55
|
+
throw new Error(`Canonical record journal is not a regular file: ${journal}.`);
|
|
56
|
+
}
|
|
57
|
+
const saved = JSON.parse(readFileSync(journal, "utf8"));
|
|
58
|
+
if (!Records.isRecord(saved) ||
|
|
59
|
+
saved.version !== 1 ||
|
|
60
|
+
saved.feature !== feature.feature ||
|
|
61
|
+
saved.workspace !== root ||
|
|
62
|
+
saved.branch !== branch ||
|
|
63
|
+
typeof saved.parent !== "string" ||
|
|
64
|
+
!/^[0-9a-f]{40,64}$/i.test(saved.parent) ||
|
|
65
|
+
!Array.isArray(saved.paths) ||
|
|
66
|
+
saved.paths.length === 0 ||
|
|
67
|
+
!saved.paths.every((path) => typeof path === "string" && this.isCanonicalRecord(feature, path)) ||
|
|
68
|
+
typeof saved.digest !== "string" ||
|
|
69
|
+
!/^[0-9a-f]{64}$/i.test(saved.digest) ||
|
|
70
|
+
(saved.tree !== undefined &&
|
|
71
|
+
(typeof saved.tree !== "string" || !/^[0-9a-f]{40,64}$/i.test(saved.tree)))) {
|
|
72
|
+
throw new Error(`Canonical record journal does not match this worktree: ${journal}.`);
|
|
73
|
+
}
|
|
74
|
+
const paths = saved.paths.map((path) => {
|
|
75
|
+
if (typeof path !== "string")
|
|
76
|
+
throw new Error("Invalid canonical record path.");
|
|
77
|
+
return path;
|
|
78
|
+
});
|
|
79
|
+
if (JSON.stringify([...new Set(paths)].sort()) !== JSON.stringify(paths)) {
|
|
80
|
+
throw new Error(`Canonical record journal has invalid paths: ${journal}.`);
|
|
81
|
+
}
|
|
82
|
+
state = {
|
|
83
|
+
version: 1,
|
|
84
|
+
feature: feature.feature,
|
|
85
|
+
workspace: root,
|
|
86
|
+
branch,
|
|
87
|
+
parent: saved.parent,
|
|
88
|
+
paths,
|
|
89
|
+
digest: saved.digest,
|
|
90
|
+
...(typeof saved.tree === "string" ? { tree: saved.tree } : {}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
if (staged.length > 0) {
|
|
95
|
+
throw new Error(`Staged data was preserved; inspect and commit or unstage it before continuing: ${staged.join(", ")}`);
|
|
96
|
+
}
|
|
97
|
+
if (changed.length === 0)
|
|
98
|
+
return undefined;
|
|
99
|
+
const invalid = changed.filter((path) => !this.isCanonicalRecord(feature, path));
|
|
100
|
+
if (invalid.length > 0) {
|
|
101
|
+
throw new Error(`Unrelated or non-canonical changes were preserved; resolve them before continuing:\n${invalid.join("\n")}`);
|
|
102
|
+
}
|
|
103
|
+
if (!confirmed) {
|
|
104
|
+
throw new Error(`Inspect these canonical records, then retry with --confirm-records; nothing was staged or committed:\n${changed.join("\n")}`);
|
|
105
|
+
}
|
|
106
|
+
state = {
|
|
107
|
+
version: 1,
|
|
108
|
+
feature: feature.feature,
|
|
109
|
+
workspace: root,
|
|
110
|
+
branch,
|
|
111
|
+
parent: this.run(root, ["rev-parse", "--verify", "HEAD"]),
|
|
112
|
+
paths: changed,
|
|
113
|
+
digest: this.digest(root, changed),
|
|
114
|
+
};
|
|
115
|
+
try {
|
|
116
|
+
mkdirSync(directory, { mode: 0o700 });
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (!lstatIfPresent(directory)?.isDirectory())
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
const entry = lstatSync(directory);
|
|
123
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
|
124
|
+
throw new Error("Canonical record journal directory must be a real directory.");
|
|
125
|
+
}
|
|
126
|
+
this.writeState(journal, state, true);
|
|
127
|
+
}
|
|
128
|
+
if (!confirmed) {
|
|
129
|
+
throw new Error(`Inspect the journalled records and retry with --confirm-records: ${journal}.`);
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const head = this.run(root, ["rev-parse", "--verify", "HEAD"]);
|
|
133
|
+
if (head !== state.parent) {
|
|
134
|
+
this.assertCommitted(root, state, head);
|
|
135
|
+
if (changed.length > 0 || this.digest(root, state.paths) !== state.digest) {
|
|
136
|
+
throw new Error("Committed records or the worktree changed during recovery.");
|
|
137
|
+
}
|
|
138
|
+
this.removeState(journal);
|
|
139
|
+
return head;
|
|
140
|
+
}
|
|
141
|
+
if (JSON.stringify(changed) !== JSON.stringify(state.paths) ||
|
|
142
|
+
this.digest(root, state.paths) !== state.digest ||
|
|
143
|
+
staged.some((path) => unstaged.includes(path))) {
|
|
144
|
+
throw new Error("Journalled records or staged data changed during recovery.");
|
|
145
|
+
}
|
|
146
|
+
this.run(root, ["add", "-A", "--", ...state.paths.map((path) => `:(literal)${path}`)]);
|
|
147
|
+
const selected = this.paths(root, [
|
|
148
|
+
"diff",
|
|
149
|
+
"--cached",
|
|
150
|
+
"--name-only",
|
|
151
|
+
"--no-renames",
|
|
152
|
+
"-z",
|
|
153
|
+
]).sort();
|
|
154
|
+
if (JSON.stringify(selected) !== JSON.stringify(state.paths)) {
|
|
155
|
+
throw new Error(`Staged records changed during batching; preserve and inspect the index. Expected ${state.paths.join(", ")}, found ${selected.join(", ")}.`);
|
|
156
|
+
}
|
|
157
|
+
const remaining = [
|
|
158
|
+
...this.paths(root, ["diff", "--name-only", "-z"]),
|
|
159
|
+
...this.paths(root, ["ls-files", "--others", "--exclude-standard", "-z"]),
|
|
160
|
+
].filter((path) => path !== lock);
|
|
161
|
+
if (remaining.length > 0 || this.digest(root, state.paths) !== state.digest) {
|
|
162
|
+
throw new Error(`Feature records changed during batching; preserve the index and worktree: ${remaining.join(", ")}`);
|
|
163
|
+
}
|
|
164
|
+
const tree = this.run(root, ["write-tree"]);
|
|
165
|
+
if (state.tree && state.tree !== tree) {
|
|
166
|
+
throw new Error("Journalled record index changed during recovery.");
|
|
167
|
+
}
|
|
168
|
+
if (!state.tree) {
|
|
169
|
+
state.tree = tree;
|
|
170
|
+
this.writeState(journal, state);
|
|
171
|
+
}
|
|
172
|
+
if (this.run(root, ["rev-parse", "--verify", "HEAD"]) !== state.parent) {
|
|
173
|
+
throw new Error("Feature branch advanced during canonical record batching.");
|
|
174
|
+
}
|
|
175
|
+
this.assertNoGitOperation(root);
|
|
176
|
+
this.assertNoHooks(root);
|
|
177
|
+
this.run(root, ["commit", "-m", `spindle: record ${feature.feature} workflow`]);
|
|
178
|
+
const committedHead = this.run(root, ["rev-parse", "--verify", "HEAD"]);
|
|
179
|
+
this.assertCommitted(root, state, committedHead);
|
|
180
|
+
const after = [
|
|
181
|
+
...this.paths(root, ["diff", "--name-only", "-z"]),
|
|
182
|
+
...this.paths(root, ["diff", "--cached", "--name-only", "-z"]),
|
|
183
|
+
...this.paths(root, ["ls-files", "--others", "--exclude-standard", "-z"]),
|
|
184
|
+
].filter((path) => path !== lock);
|
|
185
|
+
if (after.length > 0 || this.digest(root, state.paths) !== state.digest) {
|
|
186
|
+
throw new Error(`Canonical records were committed, but additional changes remain; preserve and inspect them: ${after.join(", ")}`);
|
|
187
|
+
}
|
|
188
|
+
this.removeState(journal);
|
|
189
|
+
return committedHead;
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
throw new Error(`Canonical record batching stopped; preserve ${journal}, the index, and the worktree. ${error instanceof Error ? error.message : String(error)}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
digest(root, paths) {
|
|
196
|
+
const entries = paths.map((path) => {
|
|
197
|
+
const entry = lstatIfPresent(join(root, path));
|
|
198
|
+
if (!entry)
|
|
199
|
+
return [path, "deleted"];
|
|
200
|
+
if (!entry.isFile() || entry.isSymbolicLink()) {
|
|
201
|
+
throw new Error(`Canonical record is not a regular file: ${path}.`);
|
|
202
|
+
}
|
|
203
|
+
return [
|
|
204
|
+
path,
|
|
205
|
+
entry.mode & 0o111 ? "100755" : "100644",
|
|
206
|
+
createHash("sha256")
|
|
207
|
+
.update(readFileSync(join(root, path)))
|
|
208
|
+
.digest("hex"),
|
|
209
|
+
];
|
|
210
|
+
});
|
|
211
|
+
return createHash("sha256").update(JSON.stringify(entries)).digest("hex");
|
|
212
|
+
}
|
|
213
|
+
assertCommitted(root, state, head) {
|
|
214
|
+
const ancestry = this.run(root, ["rev-list", "--parents", "-n", "1", head]).split(/\s+/);
|
|
215
|
+
const committed = this.paths(root, [
|
|
216
|
+
"diff",
|
|
217
|
+
"--name-only",
|
|
218
|
+
"-z",
|
|
219
|
+
"--no-renames",
|
|
220
|
+
state.parent,
|
|
221
|
+
head,
|
|
222
|
+
]).sort();
|
|
223
|
+
if (!state.tree ||
|
|
224
|
+
head === state.parent ||
|
|
225
|
+
ancestry.length !== 2 ||
|
|
226
|
+
ancestry[1] !== state.parent ||
|
|
227
|
+
this.run(root, ["show", "-s", "--format=%T", head]) !== state.tree ||
|
|
228
|
+
this.run(root, ["show", "-s", "--format=%s", head]) !==
|
|
229
|
+
`spindle: record ${state.feature} workflow` ||
|
|
230
|
+
JSON.stringify(committed) !== JSON.stringify(state.paths)) {
|
|
231
|
+
throw new Error("Canonical record commit failed verification; preserve and inspect Git state.");
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
assertNoGitOperation(root) {
|
|
235
|
+
if (this.paths(root, ["ls-files", "--unmerged", "-z"]).length > 0) {
|
|
236
|
+
throw new Error("Resolve unmerged index entries before committing workflow records.");
|
|
237
|
+
}
|
|
238
|
+
for (const operation of [
|
|
239
|
+
"MERGE_HEAD",
|
|
240
|
+
"CHERRY_PICK_HEAD",
|
|
241
|
+
"REVERT_HEAD",
|
|
242
|
+
"REBASE_HEAD",
|
|
243
|
+
"rebase-merge",
|
|
244
|
+
"rebase-apply",
|
|
245
|
+
"sequencer",
|
|
246
|
+
]) {
|
|
247
|
+
const path = resolve(root, this.run(root, ["rev-parse", "--git-path", operation]));
|
|
248
|
+
if (lstatIfPresent(path)) {
|
|
249
|
+
throw new Error(`Complete the existing Git operation (${operation}) before committing records.`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
assertNoHooks(root) {
|
|
254
|
+
for (const hook of ["pre-commit", "prepare-commit-msg", "commit-msg", "post-commit"]) {
|
|
255
|
+
const path = resolve(root, this.run(root, ["rev-parse", "--git-path", `hooks/${hook}`]));
|
|
256
|
+
try {
|
|
257
|
+
accessSync(path, constants.X_OK);
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
if (typeof error === "object" &&
|
|
261
|
+
error !== null &&
|
|
262
|
+
"code" in error &&
|
|
263
|
+
(error.code === "ENOENT" || error.code === "EACCES")) {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
throw new Error(`Active Git hook ${path} requires manual handling before committing records.`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
writeState(path, state, create = false) {
|
|
272
|
+
const target = create ? path : `${path}.${randomUUID()}.tmp`;
|
|
273
|
+
const fd = openSync(target, "wx", 0o600);
|
|
274
|
+
try {
|
|
275
|
+
writeFileSync(fd, `${JSON.stringify(state, null, 2)}\n`);
|
|
276
|
+
fsyncSync(fd);
|
|
277
|
+
}
|
|
278
|
+
finally {
|
|
279
|
+
closeSync(fd);
|
|
280
|
+
}
|
|
281
|
+
if (!create)
|
|
282
|
+
renameSync(target, path);
|
|
283
|
+
this.syncDirectory(join(path, ".."));
|
|
284
|
+
}
|
|
285
|
+
removeState(path) {
|
|
286
|
+
unlinkSync(path);
|
|
287
|
+
this.syncDirectory(join(path, ".."));
|
|
288
|
+
}
|
|
289
|
+
syncDirectory(path) {
|
|
290
|
+
const fd = openSync(path, "r");
|
|
291
|
+
try {
|
|
292
|
+
fsyncSync(fd);
|
|
293
|
+
}
|
|
294
|
+
finally {
|
|
295
|
+
closeSync(fd);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
isCanonicalRecord(feature, path) {
|
|
299
|
+
const prefix = `.scratch/${feature.feature}/`;
|
|
300
|
+
if (!path.startsWith(prefix))
|
|
301
|
+
return false;
|
|
302
|
+
const relative = path.slice(prefix.length);
|
|
303
|
+
return (relative === "spec.md" ||
|
|
304
|
+
relative === "design.md" ||
|
|
305
|
+
relative === "sdd.json" ||
|
|
306
|
+
/^tickets\/T-\d{4,}\.md$/.test(relative) ||
|
|
307
|
+
/^reports\/[^/]+\.md$/.test(relative));
|
|
308
|
+
}
|
|
309
|
+
paths(root, args) {
|
|
310
|
+
const result = this.git.run(args, {
|
|
311
|
+
cwd: root,
|
|
312
|
+
encoding: "utf8",
|
|
313
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
|
|
314
|
+
});
|
|
315
|
+
if (result.error)
|
|
316
|
+
throw result.error;
|
|
317
|
+
if (result.status !== 0) {
|
|
318
|
+
throw new Error(`git ${args[0]} failed: ${result.stderr.trim()}`);
|
|
319
|
+
}
|
|
320
|
+
const output = result.stdout;
|
|
321
|
+
return output ? output.split("\0").filter(Boolean) : [];
|
|
322
|
+
}
|
|
323
|
+
run(root, args) {
|
|
324
|
+
const result = this.git.run(args, {
|
|
325
|
+
cwd: root,
|
|
326
|
+
encoding: "utf8",
|
|
327
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
|
|
328
|
+
});
|
|
329
|
+
if (result.error)
|
|
330
|
+
throw result.error;
|
|
331
|
+
if (result.status !== 0) {
|
|
332
|
+
throw new Error(`git ${args[0]} failed: ${result.stderr.trim()}`);
|
|
333
|
+
}
|
|
334
|
+
return result.stdout.trim();
|
|
335
|
+
}
|
|
336
|
+
}
|