@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,544 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { accessSync, constants, fsyncSync, lstatSync, mkdirSync, openSync, closeSync, readFileSync, realpathSync, renameSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { isAbsolute, join, resolve } 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 FeatureWorktreeService extends Service {
|
|
8
|
+
git;
|
|
9
|
+
inspection;
|
|
10
|
+
transaction;
|
|
11
|
+
constructor(app, git, inspection, transaction) {
|
|
12
|
+
super(app);
|
|
13
|
+
this.git = git;
|
|
14
|
+
this.inspection = inspection;
|
|
15
|
+
this.transaction = transaction;
|
|
16
|
+
}
|
|
17
|
+
start(options) {
|
|
18
|
+
if (options.confirmRecords !== true) {
|
|
19
|
+
throw new Error("Inspect feature records for sensitive content and pass --confirm-records.");
|
|
20
|
+
}
|
|
21
|
+
for (const variable of [
|
|
22
|
+
"GIT_DIR",
|
|
23
|
+
"GIT_WORK_TREE",
|
|
24
|
+
"GIT_COMMON_DIR",
|
|
25
|
+
"GIT_INDEX_FILE",
|
|
26
|
+
"GIT_OBJECT_DIRECTORY",
|
|
27
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
28
|
+
"GIT_NAMESPACE",
|
|
29
|
+
]) {
|
|
30
|
+
if (process.env[variable]) {
|
|
31
|
+
throw new Error(`Unset ${variable} before starting a feature worktree.`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const root = realpathSync(this.app.workspaceRoot);
|
|
35
|
+
const feature = this.inspection.resolveFeature(options.spec);
|
|
36
|
+
if (!isAbsolute(options.workspace)) {
|
|
37
|
+
throw new Error("--workspace must be an absolute path to an existing external directory.");
|
|
38
|
+
}
|
|
39
|
+
const parentEntry = lstatIfPresent(options.workspace);
|
|
40
|
+
if (!parentEntry?.isDirectory() || parentEntry.isSymbolicLink()) {
|
|
41
|
+
throw new Error("--workspace must be an existing real directory, not a file or symlink.");
|
|
42
|
+
}
|
|
43
|
+
const parent = realpathSync(options.workspace);
|
|
44
|
+
accessSync(parent, constants.W_OK | constants.X_OK);
|
|
45
|
+
const common = realpathSync(this.run(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]));
|
|
46
|
+
if (isPathInside(root, parent) || isPathInside(common, parent)) {
|
|
47
|
+
throw new Error("--workspace must be outside the original checkout and Git directory.");
|
|
48
|
+
}
|
|
49
|
+
const target = join(parent, feature.feature);
|
|
50
|
+
const branch = `features/${feature.feature}`;
|
|
51
|
+
const prefix = `.scratch/${feature.feature}/`;
|
|
52
|
+
const featurePath = `.scratch/${feature.feature}`;
|
|
53
|
+
const lockPath = `${prefix}.sdd-tickets.lock`;
|
|
54
|
+
return this.transaction.run(feature.workspace, () => {
|
|
55
|
+
const featureLedger = JSON.parse(readFileSync(feature.ledger, "utf8"));
|
|
56
|
+
if (this.referencesFeaturePath(featureLedger, feature.workspace)) {
|
|
57
|
+
throw new Error("Feature ledger contains paths to the original feature workspace; resolve them before starting.");
|
|
58
|
+
}
|
|
59
|
+
const branchResult = this.git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], {
|
|
60
|
+
cwd: root,
|
|
61
|
+
encoding: "utf8",
|
|
62
|
+
});
|
|
63
|
+
if (branchResult.error)
|
|
64
|
+
throw branchResult.error;
|
|
65
|
+
if (branchResult.status !== 0 || !branchResult.stdout.trim()) {
|
|
66
|
+
throw new Error("Starting a feature requires an attached original branch.");
|
|
67
|
+
}
|
|
68
|
+
const originalBranch = branchResult.stdout.trim();
|
|
69
|
+
const originalRevision = this.run(root, ["rev-parse", "--verify", "HEAD"]);
|
|
70
|
+
if (!/^[0-9a-f]{40,64}$/i.test(originalRevision))
|
|
71
|
+
throw new Error("Invalid Git HEAD.");
|
|
72
|
+
this.assertNoGitOperation(root);
|
|
73
|
+
this.assertNoHooks(root);
|
|
74
|
+
this.assertNoUnrelatedChanges(root, prefix, lockPath);
|
|
75
|
+
this.assertFeatureRecords(root, prefix, lockPath);
|
|
76
|
+
this.assertNoDivergentStagedRecords(root);
|
|
77
|
+
const ignored = this.runBuffer(root, [
|
|
78
|
+
"ls-files",
|
|
79
|
+
"--others",
|
|
80
|
+
"--ignored",
|
|
81
|
+
"--exclude-standard",
|
|
82
|
+
"-z",
|
|
83
|
+
"--",
|
|
84
|
+
`:(literal)${featurePath}`,
|
|
85
|
+
])
|
|
86
|
+
.toString("utf8")
|
|
87
|
+
.split("\0")
|
|
88
|
+
.filter((path) => path && path !== lockPath);
|
|
89
|
+
if (ignored.length) {
|
|
90
|
+
throw new Error(`Ignored feature files must be handled before starting: ${ignored.join(", ")}.`);
|
|
91
|
+
}
|
|
92
|
+
const stateDirectory = join(common, "spindle-feature-start");
|
|
93
|
+
try {
|
|
94
|
+
mkdirSync(stateDirectory, { mode: 0o700 });
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (!lstatIfPresent(stateDirectory)?.isDirectory())
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
const directoryEntry = lstatSync(stateDirectory);
|
|
101
|
+
if (!directoryEntry.isDirectory() || directoryEntry.isSymbolicLink()) {
|
|
102
|
+
throw new Error("Feature start state directory must be a real directory.");
|
|
103
|
+
}
|
|
104
|
+
const stateFile = join(stateDirectory, `${feature.feature}.json`);
|
|
105
|
+
let state;
|
|
106
|
+
const stateEntry = lstatIfPresent(stateFile);
|
|
107
|
+
if (stateEntry) {
|
|
108
|
+
if (!stateEntry.isFile() || stateEntry.isSymbolicLink()) {
|
|
109
|
+
throw new Error(`Feature startup state must be a regular file: ${stateFile}.`);
|
|
110
|
+
}
|
|
111
|
+
state = this.readState(stateFile, feature.feature, root, target, branch, originalBranch);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
if (lstatIfPresent(target))
|
|
115
|
+
throw new Error(`Worktree destination already exists: ${target}.`);
|
|
116
|
+
if (this.branchExists(root, branch))
|
|
117
|
+
throw new Error(`Feature branch already exists: ${branch}.`);
|
|
118
|
+
const paths = [
|
|
119
|
+
...new Set(this.changePaths(root).filter((path) => path !== lockPath)),
|
|
120
|
+
].sort();
|
|
121
|
+
state = {
|
|
122
|
+
version: 1,
|
|
123
|
+
id: randomUUID(),
|
|
124
|
+
feature: feature.feature,
|
|
125
|
+
originalWorkspace: root,
|
|
126
|
+
originalBranch,
|
|
127
|
+
originalRevision,
|
|
128
|
+
featureBranch: branch,
|
|
129
|
+
featureWorkspace: target,
|
|
130
|
+
phase: "planned",
|
|
131
|
+
records: { paths, digest: this.recordDigest(root, paths) },
|
|
132
|
+
};
|
|
133
|
+
try {
|
|
134
|
+
const fd = openSync(stateFile, "wx", 0o600);
|
|
135
|
+
try {
|
|
136
|
+
writeFileSync(fd, `${JSON.stringify(state, null, 2)}\n`);
|
|
137
|
+
fsyncSync(fd);
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
closeSync(fd);
|
|
141
|
+
}
|
|
142
|
+
this.syncDirectory(stateDirectory);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
throw new Error(`Unable to persist feature start state; inspect ${stateFile}. ${error instanceof Error ? error.message : String(error)}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
if (state.phase === "planned") {
|
|
150
|
+
let revision = this.run(root, ["rev-parse", "--verify", "HEAD"]);
|
|
151
|
+
if (revision === state.originalRevision) {
|
|
152
|
+
const changes = [
|
|
153
|
+
...new Set(this.changePaths(root).filter((path) => path !== lockPath)),
|
|
154
|
+
].sort();
|
|
155
|
+
if (JSON.stringify(changes) !== JSON.stringify(state.records.paths) ||
|
|
156
|
+
this.recordDigest(root, changes) !== state.records.digest) {
|
|
157
|
+
throw new Error("Feature records changed during startup recovery.");
|
|
158
|
+
}
|
|
159
|
+
if (changes.length > 0) {
|
|
160
|
+
this.assertOriginal(root, state.originalBranch, state.originalRevision);
|
|
161
|
+
this.run(root, [
|
|
162
|
+
"add",
|
|
163
|
+
"-A",
|
|
164
|
+
"--",
|
|
165
|
+
`:(literal)${featurePath}`,
|
|
166
|
+
`:(exclude,literal)${lockPath}`,
|
|
167
|
+
]);
|
|
168
|
+
this.assertNoUnrelatedChanges(root, prefix, lockPath);
|
|
169
|
+
this.assertFeatureRecords(root, prefix, lockPath);
|
|
170
|
+
this.assertNoDivergentStagedRecords(root);
|
|
171
|
+
if (this.recordDigest(root, changes) !== state.records.digest) {
|
|
172
|
+
throw new Error("Feature records changed while staging.");
|
|
173
|
+
}
|
|
174
|
+
this.assertOriginal(root, state.originalBranch, state.originalRevision);
|
|
175
|
+
this.run(root, [
|
|
176
|
+
"commit",
|
|
177
|
+
"--only",
|
|
178
|
+
"-m",
|
|
179
|
+
`spindle: prepare ${feature.feature}`,
|
|
180
|
+
"--",
|
|
181
|
+
`:(literal)${featurePath}`,
|
|
182
|
+
`:(exclude,literal)${lockPath}`,
|
|
183
|
+
]);
|
|
184
|
+
revision = this.run(root, ["rev-parse", "--verify", "HEAD"]);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (revision !== state.originalRevision) {
|
|
188
|
+
this.assertPreparationCommit(root, state, revision);
|
|
189
|
+
}
|
|
190
|
+
else if (state.records.paths.length > 0) {
|
|
191
|
+
throw new Error("Feature preparation did not commit its recorded changes.");
|
|
192
|
+
}
|
|
193
|
+
this.assertOriginal(root, state.originalBranch, revision);
|
|
194
|
+
this.assertClean(root, lockPath);
|
|
195
|
+
state.featureRevision = revision;
|
|
196
|
+
state.phase = "committed";
|
|
197
|
+
this.writeState(stateFile, state);
|
|
198
|
+
}
|
|
199
|
+
const revision = state.featureRevision;
|
|
200
|
+
if (!revision)
|
|
201
|
+
throw new Error("Feature startup has no recorded preparation revision.");
|
|
202
|
+
this.assertOriginal(root, state.originalBranch, revision);
|
|
203
|
+
this.assertClean(root, lockPath);
|
|
204
|
+
if (state.phase === "committed") {
|
|
205
|
+
if (lstatIfPresent(target) || this.branchExists(root, branch)) {
|
|
206
|
+
throw new Error("Feature destination or branch exists without a recorded allocation.");
|
|
207
|
+
}
|
|
208
|
+
mkdirSync(target);
|
|
209
|
+
const entry = lstatSync(target);
|
|
210
|
+
state.targetIdentity = { device: entry.dev, inode: entry.ino };
|
|
211
|
+
state.phase = "allocated";
|
|
212
|
+
this.writeState(stateFile, state);
|
|
213
|
+
}
|
|
214
|
+
const targetEntry = lstatIfPresent(target);
|
|
215
|
+
if (!targetEntry?.isDirectory() ||
|
|
216
|
+
targetEntry.isSymbolicLink() ||
|
|
217
|
+
!state.targetIdentity ||
|
|
218
|
+
targetEntry.dev !== state.targetIdentity.device ||
|
|
219
|
+
targetEntry.ino !== state.targetIdentity.inode ||
|
|
220
|
+
realpathSync(target) !== target) {
|
|
221
|
+
throw new Error("Feature worktree directory ownership cannot be verified.");
|
|
222
|
+
}
|
|
223
|
+
const registered = this.runBuffer(root, ["worktree", "list", "--porcelain", "-z"])
|
|
224
|
+
.toString("utf8")
|
|
225
|
+
.split("\0")
|
|
226
|
+
.includes(`worktree ${target}`);
|
|
227
|
+
if (!registered) {
|
|
228
|
+
if (state.phase !== "allocated" || this.branchExists(root, branch)) {
|
|
229
|
+
throw new Error("Feature branch or worktree registration changed during startup.");
|
|
230
|
+
}
|
|
231
|
+
this.run(root, ["worktree", "add", "-b", branch, target, revision]);
|
|
232
|
+
}
|
|
233
|
+
for (const directory of ["tickets", "reports"]) {
|
|
234
|
+
const path = join(target, featurePath, directory);
|
|
235
|
+
if (!lstatIfPresent(path))
|
|
236
|
+
mkdirSync(path);
|
|
237
|
+
if (!lstatSync(path).isDirectory() || lstatSync(path).isSymbolicLink()) {
|
|
238
|
+
throw new Error(`Feature ${directory} path is not a real directory: ${path}.`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const worktreeSpec = join(target, featurePath, "spec.md");
|
|
242
|
+
const specEntry = lstatIfPresent(worktreeSpec);
|
|
243
|
+
if (!specEntry?.isFile() || specEntry.isSymbolicLink()) {
|
|
244
|
+
throw new Error(`Created worktree has no regular feature spec: ${worktreeSpec}.`);
|
|
245
|
+
}
|
|
246
|
+
if (this.run(target, ["symbolic-ref", "--quiet", "--short", "HEAD"]) !== branch ||
|
|
247
|
+
(state.phase !== "created" &&
|
|
248
|
+
this.run(target, ["rev-parse", "--verify", "HEAD"]) !== revision) ||
|
|
249
|
+
realpathSync(this.run(target, ["rev-parse", "--path-format=absolute", "--git-common-dir"])) !== common) {
|
|
250
|
+
throw new Error("Created worktree does not match the recorded branch and revision.");
|
|
251
|
+
}
|
|
252
|
+
if (state.phase !== "created")
|
|
253
|
+
this.assertClean(target, lockPath);
|
|
254
|
+
this.assertOriginal(root, state.originalBranch, revision);
|
|
255
|
+
this.assertClean(root, lockPath);
|
|
256
|
+
if (state.phase !== "created") {
|
|
257
|
+
state.phase = "created";
|
|
258
|
+
this.writeState(stateFile, state);
|
|
259
|
+
}
|
|
260
|
+
return { ...state, stateFile, spec: worktreeSpec };
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
throw new Error(`Feature start stopped; inspect and preserve ${stateFile}, branch ${branch}, and ${target} before recovery. ${error instanceof Error ? error.message : String(error)}`);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
readState(path, feature, root, target, branch, originalBranch) {
|
|
268
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
269
|
+
if (!Records.isRecord(value) ||
|
|
270
|
+
value.version !== 1 ||
|
|
271
|
+
typeof value.id !== "string" ||
|
|
272
|
+
value.feature !== feature ||
|
|
273
|
+
value.originalWorkspace !== root ||
|
|
274
|
+
value.originalBranch !== originalBranch ||
|
|
275
|
+
typeof value.originalRevision !== "string" ||
|
|
276
|
+
!/^[0-9a-f]{40,64}$/i.test(value.originalRevision) ||
|
|
277
|
+
value.featureBranch !== branch ||
|
|
278
|
+
value.featureWorkspace !== target ||
|
|
279
|
+
(value.phase !== "planned" &&
|
|
280
|
+
value.phase !== "committed" &&
|
|
281
|
+
value.phase !== "allocated" &&
|
|
282
|
+
value.phase !== "created") ||
|
|
283
|
+
(value.featureRevision !== undefined &&
|
|
284
|
+
(typeof value.featureRevision !== "string" ||
|
|
285
|
+
!/^[0-9a-f]{40,64}$/i.test(value.featureRevision))) ||
|
|
286
|
+
!Records.isRecord(value.records) ||
|
|
287
|
+
!Array.isArray(value.records.paths) ||
|
|
288
|
+
typeof value.records.digest !== "string" ||
|
|
289
|
+
!/^[0-9a-f]{64}$/i.test(value.records.digest)) {
|
|
290
|
+
throw new Error(`Feature startup state does not match this checkout: ${path}.`);
|
|
291
|
+
}
|
|
292
|
+
const paths = value.records.paths.map((entry) => {
|
|
293
|
+
if (typeof entry !== "string" ||
|
|
294
|
+
!entry.startsWith(`.scratch/${feature}/`) ||
|
|
295
|
+
!/^(?:spec\.md|design\.md|sdd\.json|tickets\/T-\d{4,}\.md|reports\/[^/]+\.md)$/.test(entry.slice(`.scratch/${feature}/`.length))) {
|
|
296
|
+
throw new Error(`Feature startup state contains an invalid record path: ${path}.`);
|
|
297
|
+
}
|
|
298
|
+
return entry;
|
|
299
|
+
});
|
|
300
|
+
if (JSON.stringify([...new Set(paths)].sort()) !== JSON.stringify(paths)) {
|
|
301
|
+
throw new Error(`Feature startup state contains duplicate or unordered paths: ${path}.`);
|
|
302
|
+
}
|
|
303
|
+
let targetIdentity;
|
|
304
|
+
if (value.targetIdentity !== undefined) {
|
|
305
|
+
if (!Records.isRecord(value.targetIdentity) ||
|
|
306
|
+
typeof value.targetIdentity.device !== "number" ||
|
|
307
|
+
typeof value.targetIdentity.inode !== "number") {
|
|
308
|
+
throw new Error(`Feature startup state has invalid directory identity: ${path}.`);
|
|
309
|
+
}
|
|
310
|
+
targetIdentity = {
|
|
311
|
+
device: value.targetIdentity.device,
|
|
312
|
+
inode: value.targetIdentity.inode,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
version: 1,
|
|
317
|
+
id: value.id,
|
|
318
|
+
feature,
|
|
319
|
+
originalWorkspace: root,
|
|
320
|
+
originalBranch,
|
|
321
|
+
originalRevision: value.originalRevision,
|
|
322
|
+
featureBranch: branch,
|
|
323
|
+
featureWorkspace: target,
|
|
324
|
+
phase: value.phase,
|
|
325
|
+
records: { paths, digest: value.records.digest },
|
|
326
|
+
...(typeof value.featureRevision === "string"
|
|
327
|
+
? { featureRevision: value.featureRevision }
|
|
328
|
+
: {}),
|
|
329
|
+
...(targetIdentity ? { targetIdentity } : {}),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
recordDigest(root, paths) {
|
|
333
|
+
const entries = paths.map((path) => {
|
|
334
|
+
const entry = lstatIfPresent(join(root, path));
|
|
335
|
+
if (!entry)
|
|
336
|
+
return [path, "deleted"];
|
|
337
|
+
if (!entry.isFile() || entry.isSymbolicLink()) {
|
|
338
|
+
throw new Error(`Feature record is not a regular file: ${path}.`);
|
|
339
|
+
}
|
|
340
|
+
return [
|
|
341
|
+
path,
|
|
342
|
+
entry.mode & 0o111 ? "100755" : "100644",
|
|
343
|
+
createHash("sha256")
|
|
344
|
+
.update(readFileSync(join(root, path)))
|
|
345
|
+
.digest("hex"),
|
|
346
|
+
];
|
|
347
|
+
});
|
|
348
|
+
return createHash("sha256").update(JSON.stringify(entries)).digest("hex");
|
|
349
|
+
}
|
|
350
|
+
assertPreparationCommit(root, state, revision) {
|
|
351
|
+
const parents = this.run(root, ["rev-list", "--parents", "-n", "1", revision]).split(" ");
|
|
352
|
+
const committed = this.runBuffer(root, [
|
|
353
|
+
"diff",
|
|
354
|
+
"--name-only",
|
|
355
|
+
"--no-renames",
|
|
356
|
+
"-z",
|
|
357
|
+
state.originalRevision,
|
|
358
|
+
revision,
|
|
359
|
+
])
|
|
360
|
+
.toString("utf8")
|
|
361
|
+
.split("\0")
|
|
362
|
+
.filter(Boolean)
|
|
363
|
+
.sort();
|
|
364
|
+
if (parents.length !== 2 ||
|
|
365
|
+
parents[1] !== state.originalRevision ||
|
|
366
|
+
this.run(root, ["show", "-s", "--format=%s", revision]) !==
|
|
367
|
+
`spindle: prepare ${state.feature}` ||
|
|
368
|
+
JSON.stringify(committed) !== JSON.stringify(state.records.paths) ||
|
|
369
|
+
this.recordDigest(root, state.records.paths) !== state.records.digest) {
|
|
370
|
+
throw new Error("Feature preparation commit does not match the recorded changes.");
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
branchExists(root, branch) {
|
|
374
|
+
const result = this.git.run(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
|
|
375
|
+
cwd: root,
|
|
376
|
+
encoding: "utf8",
|
|
377
|
+
});
|
|
378
|
+
if (result.error)
|
|
379
|
+
throw result.error;
|
|
380
|
+
if (result.status !== 0 && result.status !== 1) {
|
|
381
|
+
throw new Error(`Cannot check feature branch: ${result.stderr.trim()}`);
|
|
382
|
+
}
|
|
383
|
+
return result.status === 0;
|
|
384
|
+
}
|
|
385
|
+
assertOriginal(root, branch, revision) {
|
|
386
|
+
if (this.run(root, ["symbolic-ref", "--quiet", "--short", "HEAD"]) !== branch ||
|
|
387
|
+
this.run(root, ["rev-parse", "--verify", "HEAD"]) !== revision) {
|
|
388
|
+
throw new Error("Original branch or revision changed during feature startup.");
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
referencesFeaturePath(value, workspace) {
|
|
392
|
+
if (typeof value === "string") {
|
|
393
|
+
return isAbsolute(value) && isPathInside(workspace, resolve(value));
|
|
394
|
+
}
|
|
395
|
+
if (Array.isArray(value)) {
|
|
396
|
+
return value.some((entry) => this.referencesFeaturePath(entry, workspace));
|
|
397
|
+
}
|
|
398
|
+
if (typeof value === "object" && value !== null) {
|
|
399
|
+
return Object.values(value).some((entry) => this.referencesFeaturePath(entry, workspace));
|
|
400
|
+
}
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
assertNoHooks(root) {
|
|
404
|
+
for (const hook of [
|
|
405
|
+
"pre-commit",
|
|
406
|
+
"prepare-commit-msg",
|
|
407
|
+
"commit-msg",
|
|
408
|
+
"post-commit",
|
|
409
|
+
"post-checkout",
|
|
410
|
+
]) {
|
|
411
|
+
const path = resolve(root, this.run(root, ["rev-parse", "--git-path", `hooks/${hook}`]));
|
|
412
|
+
try {
|
|
413
|
+
accessSync(path, constants.X_OK);
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
if (typeof error === "object" &&
|
|
417
|
+
error !== null &&
|
|
418
|
+
"code" in error &&
|
|
419
|
+
(error.code === "ENOENT" || error.code === "EACCES")) {
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
throw error;
|
|
423
|
+
}
|
|
424
|
+
throw new Error(`Active Git hook ${path} requires manual handling before feature startup.`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
assertNoGitOperation(root) {
|
|
428
|
+
if (this.runBuffer(root, ["ls-files", "--unmerged", "-z"]).length > 0) {
|
|
429
|
+
throw new Error("Resolve unmerged index entries before starting a feature worktree.");
|
|
430
|
+
}
|
|
431
|
+
for (const operation of [
|
|
432
|
+
"MERGE_HEAD",
|
|
433
|
+
"CHERRY_PICK_HEAD",
|
|
434
|
+
"REVERT_HEAD",
|
|
435
|
+
"REBASE_HEAD",
|
|
436
|
+
"rebase-merge",
|
|
437
|
+
"rebase-apply",
|
|
438
|
+
"sequencer",
|
|
439
|
+
]) {
|
|
440
|
+
const path = resolve(root, this.run(root, ["rev-parse", "--git-path", operation]));
|
|
441
|
+
if (lstatIfPresent(path)) {
|
|
442
|
+
throw new Error(`Complete the existing Git operation (${operation}) before starting.`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
assertNoDivergentStagedRecords(root) {
|
|
447
|
+
const staged = this.runBuffer(root, ["diff", "--cached", "--name-only", "--no-renames", "-z"])
|
|
448
|
+
.toString("utf8")
|
|
449
|
+
.split("\0")
|
|
450
|
+
.filter(Boolean);
|
|
451
|
+
const unstaged = new Set(this.runBuffer(root, ["diff", "--name-only", "--no-renames", "-z"])
|
|
452
|
+
.toString("utf8")
|
|
453
|
+
.split("\0")
|
|
454
|
+
.filter(Boolean));
|
|
455
|
+
const divergent = staged.filter((path) => unstaged.has(path));
|
|
456
|
+
if (divergent.length) {
|
|
457
|
+
throw new Error(`Staged feature records differ from the worktree and were preserved: ${divergent.join(", ")}.`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
changePaths(root) {
|
|
461
|
+
const entries = this.runBuffer(root, [
|
|
462
|
+
"status",
|
|
463
|
+
"--porcelain=v1",
|
|
464
|
+
"-z",
|
|
465
|
+
"--untracked-files=all",
|
|
466
|
+
"--ignore-submodules=none",
|
|
467
|
+
])
|
|
468
|
+
.toString("utf8")
|
|
469
|
+
.split("\0");
|
|
470
|
+
const paths = [];
|
|
471
|
+
for (let index = 0; index < entries.length; index++) {
|
|
472
|
+
const entry = entries[index];
|
|
473
|
+
if (!entry)
|
|
474
|
+
continue;
|
|
475
|
+
if (entry.length < 4 || entry[2] !== " ")
|
|
476
|
+
throw new Error("Unexpected Git status output.");
|
|
477
|
+
paths.push(entry.slice(3));
|
|
478
|
+
if (entry[0] === "R" || entry[0] === "C" || entry[1] === "R" || entry[1] === "C") {
|
|
479
|
+
const previous = entries[++index];
|
|
480
|
+
if (!previous)
|
|
481
|
+
throw new Error("Incomplete Git rename status.");
|
|
482
|
+
paths.push(previous);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return paths;
|
|
486
|
+
}
|
|
487
|
+
assertNoUnrelatedChanges(root, prefix, lockPath) {
|
|
488
|
+
const unrelated = this.changePaths(root).filter((path) => path !== lockPath && !path.startsWith(prefix));
|
|
489
|
+
if (unrelated.length) {
|
|
490
|
+
throw new Error(`Handle unrelated uncommitted changes before starting: ${unrelated.join(", ")}.`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
assertClean(root, lockPath) {
|
|
494
|
+
const changes = this.changePaths(root).filter((path) => path !== lockPath);
|
|
495
|
+
if (changes.length)
|
|
496
|
+
throw new Error(`Original checkout is not clean: ${changes.join(", ")}.`);
|
|
497
|
+
}
|
|
498
|
+
assertFeatureRecords(root, prefix, lockPath) {
|
|
499
|
+
for (const path of this.changePaths(root)) {
|
|
500
|
+
if (path === lockPath || !path.startsWith(prefix))
|
|
501
|
+
continue;
|
|
502
|
+
const name = path.slice(prefix.length);
|
|
503
|
+
if (!/^(?:spec\.md|design\.md|sdd\.json|tickets\/T-\d{4,}\.md|reports\/[^/]+\.md)$/.test(name)) {
|
|
504
|
+
throw new Error(`Unexpected feature file must be handled before starting: ${path}.`);
|
|
505
|
+
}
|
|
506
|
+
if (lstatIfPresent(join(root, path))?.isSymbolicLink()) {
|
|
507
|
+
throw new Error(`Feature records must not be symlinks: ${path}.`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
writeState(path, state) {
|
|
512
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
513
|
+
const fd = openSync(temporary, "wx", 0o600);
|
|
514
|
+
try {
|
|
515
|
+
writeFileSync(fd, `${JSON.stringify(state, null, 2)}\n`);
|
|
516
|
+
fsyncSync(fd);
|
|
517
|
+
}
|
|
518
|
+
finally {
|
|
519
|
+
closeSync(fd);
|
|
520
|
+
}
|
|
521
|
+
renameSync(temporary, path);
|
|
522
|
+
this.syncDirectory(resolve(path, ".."));
|
|
523
|
+
}
|
|
524
|
+
syncDirectory(path) {
|
|
525
|
+
const fd = openSync(path, "r");
|
|
526
|
+
try {
|
|
527
|
+
fsyncSync(fd);
|
|
528
|
+
}
|
|
529
|
+
finally {
|
|
530
|
+
closeSync(fd);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
run(root, args) {
|
|
534
|
+
return this.runBuffer(root, args).toString("utf8").trim();
|
|
535
|
+
}
|
|
536
|
+
runBuffer(root, args) {
|
|
537
|
+
const result = this.git.run(args, { cwd: root, encoding: "buffer" });
|
|
538
|
+
if (result.error)
|
|
539
|
+
throw result.error;
|
|
540
|
+
if (result.status !== 0)
|
|
541
|
+
throw new Error(`git ${args[0]} failed: ${result.stderr.toString("utf8").trim()}`);
|
|
542
|
+
return result.stdout;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
@@ -13,8 +13,8 @@ export class ReadinessService extends Service {
|
|
|
13
13
|
}
|
|
14
14
|
ready(options) {
|
|
15
15
|
const feature = this.featureInspectionService.resolveFeature(options.spec);
|
|
16
|
-
const { tickets } = this.reader.read(feature);
|
|
17
|
-
const result = this.policy.evaluate(tickets);
|
|
16
|
+
const { tickets, ledger } = this.reader.read(feature);
|
|
17
|
+
const result = this.policy.evaluate(tickets, ledger);
|
|
18
18
|
this.pathSafety.assertReadyReadable(feature.workspace);
|
|
19
19
|
return result;
|
|
20
20
|
}
|
|
@@ -163,11 +163,13 @@ export class ReviewService extends Service {
|
|
|
163
163
|
throw new Error(implementationBlocker);
|
|
164
164
|
if (!progress.baseSha)
|
|
165
165
|
throw new Error("Review requires the ticket's recorded Git baseline.");
|
|
166
|
+
const ticketWorkspace = this.gitEvidence.ticketWorkspace(progress);
|
|
167
|
+
this.gitEvidence.assertTicketCodeCommitted(progress);
|
|
166
168
|
const identity = this.gitEvidence.contentIdentity(feature, ticket, progress.baseSha, progress.checks);
|
|
167
169
|
const reviewCheckBlockers = this.checkPolicy.reviewCheckBlockers(feature, ticket, progress, identity);
|
|
168
170
|
if (reviewCheckBlockers.length > 0)
|
|
169
171
|
throw new Error(reviewCheckBlockers.join("\n"));
|
|
170
|
-
const inventory = this.gitEvidence.changedPaths(progress.baseSha);
|
|
172
|
+
const inventory = this.gitEvidence.changedPaths(progress.baseSha, ticketWorkspace);
|
|
171
173
|
const suppliedPaths = new Set(confirmedInventory);
|
|
172
174
|
if (suppliedPaths.size !== inventory.length ||
|
|
173
175
|
inventory.some((path) => !suppliedPaths.has(path))) {
|
|
@@ -234,6 +236,9 @@ export class ReviewService extends Service {
|
|
|
234
236
|
codeSnapshot: identity.codeSnapshot,
|
|
235
237
|
requirementsSnapshot: identity.requirementsSnapshot,
|
|
236
238
|
headSha: identity.headSha,
|
|
239
|
+
...(progress.worktree
|
|
240
|
+
? { workspace: progress.worktree.workspace, branch: progress.worktree.branch }
|
|
241
|
+
: {}),
|
|
237
242
|
inventory,
|
|
238
243
|
report,
|
|
239
244
|
requestedAt: new Date().toISOString(),
|
|
@@ -242,8 +247,10 @@ export class ReviewService extends Service {
|
|
|
242
247
|
});
|
|
243
248
|
attempt.reviewAssignments = assignments;
|
|
244
249
|
const confirmedIdentity = this.gitEvidence.contentIdentity(feature, this.ticketRepository.requireTicket(this.ticketRepository.readTickets(feature.workspace), ticketId), progress.baseSha, progress.checks);
|
|
250
|
+
this.gitEvidence.assertTicketCodeCommitted(progress);
|
|
245
251
|
if (confirmedIdentity.snapshot !== identity.snapshot ||
|
|
246
|
-
|
|
252
|
+
(progress.worktree && confirmedIdentity.headSha !== identity.headSha) ||
|
|
253
|
+
JSON.stringify(this.gitEvidence.changedPaths(progress.baseSha, ticketWorkspace)) !==
|
|
247
254
|
JSON.stringify(inventory)) {
|
|
248
255
|
delete attempt.reviewAssignments;
|
|
249
256
|
throw new Error("Ticket content changed while freezing corrective review; retry with a fresh inventory.");
|
|
@@ -266,6 +273,16 @@ export class ReviewService extends Service {
|
|
|
266
273
|
existing = [];
|
|
267
274
|
}
|
|
268
275
|
}
|
|
276
|
+
if (progress.assignment.action !== "correct" &&
|
|
277
|
+
existing.length > 0 &&
|
|
278
|
+
existing.every((assignment) => assignment.status === "submitted") &&
|
|
279
|
+
existing.some((assignment) => assignment.snapshot !== identity.snapshot ||
|
|
280
|
+
assignment.headSha !== identity.headSha ||
|
|
281
|
+
JSON.stringify(assignment.inventory) !== JSON.stringify(inventory))) {
|
|
282
|
+
progress.reviewHistory = [...(progress.reviewHistory ?? []), ...existing];
|
|
283
|
+
delete progress.reviewAssignments;
|
|
284
|
+
existing = [];
|
|
285
|
+
}
|
|
269
286
|
if (existing.length > 0) {
|
|
270
287
|
if (existing.length !== requiredReviewers.length ||
|
|
271
288
|
existing.some((assignment, index) => assignment.agent !== requiredReviewers[index]?.agent ||
|
|
@@ -310,6 +327,9 @@ export class ReviewService extends Service {
|
|
|
310
327
|
codeSnapshot: identity.codeSnapshot,
|
|
311
328
|
requirementsSnapshot: identity.requirementsSnapshot,
|
|
312
329
|
headSha: identity.headSha,
|
|
330
|
+
...(progress.worktree
|
|
331
|
+
? { workspace: progress.worktree.workspace, branch: progress.worktree.branch }
|
|
332
|
+
: {}),
|
|
313
333
|
inventory,
|
|
314
334
|
report,
|
|
315
335
|
requestedAt: new Date().toISOString(),
|
|
@@ -318,8 +338,10 @@ export class ReviewService extends Service {
|
|
|
318
338
|
});
|
|
319
339
|
const confirmedTicket = this.ticketRepository.requireTicket(this.ticketRepository.readTickets(feature.workspace), ticketId);
|
|
320
340
|
const confirmedIdentity = this.gitEvidence.contentIdentity(feature, confirmedTicket, progress.baseSha, progress.checks);
|
|
321
|
-
|
|
341
|
+
this.gitEvidence.assertTicketCodeCommitted(progress);
|
|
342
|
+
const confirmedCurrentInventory = this.gitEvidence.changedPaths(progress.baseSha, ticketWorkspace);
|
|
322
343
|
if (confirmedIdentity.snapshot !== identity.snapshot ||
|
|
344
|
+
(progress.worktree && confirmedIdentity.headSha !== identity.headSha) ||
|
|
323
345
|
JSON.stringify(confirmedCurrentInventory) !== JSON.stringify(inventory)) {
|
|
324
346
|
throw new Error("Ticket content changed while freezing review; retry with a fresh inventory.");
|
|
325
347
|
}
|
|
@@ -362,6 +384,20 @@ export class ReviewService extends Service {
|
|
|
362
384
|
if (incomplete.length > 0) {
|
|
363
385
|
throw new Error(`Feature review requires every ticket closed: ${incomplete.join(", ")}.`);
|
|
364
386
|
}
|
|
387
|
+
const unintegrated = ledger.tickets
|
|
388
|
+
.filter((progress) => progress.execution !== "integrated" || !progress.integration)
|
|
389
|
+
.map((progress) => progress.id);
|
|
390
|
+
if (unintegrated.length > 0) {
|
|
391
|
+
throw new Error(`Feature review requires every ticket integrated: ${unintegrated.join(", ")}.`);
|
|
392
|
+
}
|
|
393
|
+
const unvalidated = ledger.tickets
|
|
394
|
+
.filter((progress) => !progress.integration?.mergeSha ||
|
|
395
|
+
progress.integration.postMerge?.status !== "passed" ||
|
|
396
|
+
progress.integration.postMerge.mergeSha !== progress.integration.mergeSha)
|
|
397
|
+
.map((progress) => progress.id);
|
|
398
|
+
if (unvalidated.length > 0) {
|
|
399
|
+
throw new Error(`Feature review requires passing ticket post-merge validation: ${unvalidated.join(", ")}.`);
|
|
400
|
+
}
|
|
365
401
|
if (tickets.length !== ledger.tickets.length) {
|
|
366
402
|
throw new Error("Feature review requires every ticket document to be present.");
|
|
367
403
|
}
|
|
@@ -213,6 +213,7 @@ export class SubmissionService extends Service {
|
|
|
213
213
|
}
|
|
214
214
|
if (!progress.baseSha)
|
|
215
215
|
throw new Error("Review assignment has no recorded Git baseline.");
|
|
216
|
+
this.gitEvidence.assertTicketCodeCommitted(progress);
|
|
216
217
|
const identity = this.gitEvidence.contentIdentity(feature, ticket, progress.baseSha, progress.checks);
|
|
217
218
|
const checkBlockers = this.checkPolicy.reviewCheckBlockers(feature, ticket, progress, identity);
|
|
218
219
|
if (checkBlockers.length > 0)
|
|
@@ -255,9 +256,11 @@ export class SubmissionService extends Service {
|
|
|
255
256
|
}
|
|
256
257
|
const confirmedTicket = this.ticketRepository.requireTicket(this.ticketRepository.readTickets(feature.workspace), options.ticket);
|
|
257
258
|
const confirmedIdentity = this.gitEvidence.contentIdentity(feature, confirmedTicket, progress.baseSha, progress.checks);
|
|
258
|
-
|
|
259
|
+
this.gitEvidence.assertTicketCodeCommitted(progress);
|
|
260
|
+
const confirmedInventory = this.gitEvidence.changedPaths(progress.baseSha, this.gitEvidence.ticketWorkspace(progress));
|
|
259
261
|
const confirmedReportDigest = this.reportEvidence.readReportDigest(feature, reportPath);
|
|
260
262
|
if (confirmedIdentity.snapshot !== identity.snapshot ||
|
|
263
|
+
(progress.worktree && confirmedIdentity.headSha !== identity.headSha) ||
|
|
261
264
|
JSON.stringify(confirmedInventory) !== JSON.stringify(assignment.inventory) ||
|
|
262
265
|
confirmedReportDigest !== reportSha256) {
|
|
263
266
|
throw new Error("Review inputs changed while submitting; retry against the frozen assignment.");
|