@use-the-fork/spindle 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/build/Assets/Blueprints/Catalog.js +7 -2
  2. package/build/Assets/Blueprints/agents/sdd-implementer.agent.md +6 -6
  3. package/build/Assets/Blueprints/agents/sdd-orchestrator.agent.md +4 -7
  4. package/build/Assets/Blueprints/skills/author-agent/SKILL.md +2 -2
  5. package/build/Assets/Blueprints/skills/author-instruction/SKILL.md +2 -2
  6. package/build/Assets/Blueprints/skills/author-skill/SKILL.md +2 -2
  7. package/build/Assets/Blueprints/skills/int-subagent-driven-dev/SKILL.md +13 -9
  8. package/build/Assets/Blueprints/skills/int-subagent-driven-dev/assets/implementer-prompt.md +2 -2
  9. package/build/Assets/Blueprints/skills/int-subagent-driven-dev/references/example-workflow.md +11 -9
  10. package/build/Assets/Blueprints/skills/int-subagent-driven-dev/references/full-spec.md +33 -13
  11. package/build/Assets/Blueprints/skills/int-subagent-driven-dev/references/review-inputs.md +7 -7
  12. package/build/Assets/Blueprints/skills/{asd-ste100 → ref-asd-ste100}/SKILL.md +1 -1
  13. package/build/Assets/Blueprints/skills/sdd-author-design/SKILL.md +2 -2
  14. package/build/Assets/Blueprints/skills/sdd-implement-spec/SKILL.md +4 -6
  15. package/build/Assets/Templates/project/ticket-tracker.md +10 -2
  16. package/build/SubagentDrivenDevelopment/Commands/ChecksRecordCommand.js +27 -5
  17. package/build/SubagentDrivenDevelopment/Commands/ChecksRequireCommand.js +31 -7
  18. package/build/SubagentDrivenDevelopment/Commands/CorrectCommand.js +0 -1
  19. package/build/SubagentDrivenDevelopment/Commands/FinishCommand.js +2 -1
  20. package/build/SubagentDrivenDevelopment/Commands/ImplementCommand.js +2 -0
  21. package/build/SubagentDrivenDevelopment/Commands/{ClaimCommand.js → IntegrateCommand.js} +8 -7
  22. package/build/SubagentDrivenDevelopment/Commands/ReviewCommand.js +9 -9
  23. package/build/SubagentDrivenDevelopment/Commands/StartCommand.js +23 -0
  24. package/build/SubagentDrivenDevelopment/Commands/StatusCommand.js +5 -0
  25. package/build/SubagentDrivenDevelopment/Commands/groups.js +3 -2
  26. package/build/SubagentDrivenDevelopment/Evidence/GitEvidence.js +188 -37
  27. package/build/SubagentDrivenDevelopment/Models/Workflow.js +0 -2
  28. package/build/SubagentDrivenDevelopment/Persistence/AssignmentSchema.js +4 -0
  29. package/build/SubagentDrivenDevelopment/Persistence/CheckSchema.js +4 -13
  30. package/build/SubagentDrivenDevelopment/Persistence/LedgerRepository.js +66 -5
  31. package/build/SubagentDrivenDevelopment/Persistence/ProgressSchema.js +54 -4
  32. package/build/SubagentDrivenDevelopment/Persistence/ReviewSchema.js +4 -0
  33. package/build/SubagentDrivenDevelopment/Policies/AssignmentPolicy.js +0 -3
  34. package/build/SubagentDrivenDevelopment/Policies/CheckPolicy.js +6 -3
  35. package/build/SubagentDrivenDevelopment/Policies/ReadinessPolicy.js +16 -6
  36. package/build/SubagentDrivenDevelopment/Policies/ReviewPolicy.js +16 -2
  37. package/build/SubagentDrivenDevelopment/Policies/WorkflowPolicy.js +8 -8
  38. package/build/SubagentDrivenDevelopment/Services/AssignmentService.js +73 -51
  39. package/build/SubagentDrivenDevelopment/Services/ChecksService.js +376 -21
  40. package/build/SubagentDrivenDevelopment/Services/CorrectionService.js +83 -14
  41. package/build/SubagentDrivenDevelopment/Services/DeliveryCorrectionService.js +3 -7
  42. package/build/SubagentDrivenDevelopment/Services/DeliveryService.js +43 -38
  43. package/build/SubagentDrivenDevelopment/Services/FeatureCreationService.js +1 -1
  44. package/build/SubagentDrivenDevelopment/Services/FeatureFinalizationService.js +1202 -0
  45. package/build/SubagentDrivenDevelopment/Services/FeatureInspectionService.js +41 -49
  46. package/build/SubagentDrivenDevelopment/Services/FeatureWorktreeService.js +544 -0
  47. package/build/SubagentDrivenDevelopment/Services/ReadinessService.js +2 -2
  48. package/build/SubagentDrivenDevelopment/Services/ReviewService.js +39 -3
  49. package/build/SubagentDrivenDevelopment/Services/SubmissionService.js +4 -1
  50. package/build/SubagentDrivenDevelopment/Services/TicketIntegrationService.js +621 -0
  51. package/build/SubagentDrivenDevelopment/Services/TicketService.js +3 -0
  52. package/build/SubagentDrivenDevelopment/Services/TicketWorktreeService.js +317 -0
  53. package/build/SubagentDrivenDevelopment/Services/WorkflowRecordsCommitter.js +336 -0
  54. package/build/SubagentDrivenDevelopment/SubagentDrivenDevelopmentServiceProvider.js +18 -8
  55. package/build/TemplateRendering/Computed/Providers/AuthoringFlowchartProvider.js +1 -1
  56. package/package.json +2 -2
  57. package/build/SubagentDrivenDevelopment/Services/IntegrationService.js +0 -258
  58. /package/build/Assets/Blueprints/skills/{asd-ste100 → ref-asd-ste100}/CREDITS.MD +0 -0
  59. /package/build/Assets/Blueprints/skills/{asd-ste100 → ref-asd-ste100}/examples/before-after.md +0 -0
  60. /package/build/Assets/Blueprints/skills/{asd-ste100 → ref-asd-ste100}/references/writing-rules.md +0 -0
@@ -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
- JSON.stringify(this.gitEvidence.changedPaths(progress.baseSha)) !==
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
- const confirmedCurrentInventory = this.gitEvidence.changedPaths(progress.baseSha);
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
- const confirmedInventory = this.gitEvidence.changedPaths(progress.baseSha);
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.");