@use-the-fork/spindle 0.0.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/build/Assets/Blueprints/Catalog.js +19 -9
  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 +9 -8
  5. package/build/Assets/Blueprints/skills/author-instruction/SKILL.md +9 -8
  6. package/build/Assets/Blueprints/skills/author-skill/SKILL.md +9 -8
  7. package/build/Assets/Blueprints/skills/grill/SKILL.md +3 -3
  8. package/build/Assets/Blueprints/skills/int-subagent-driven-dev/SKILL.md +16 -12
  9. package/build/Assets/Blueprints/skills/int-subagent-driven-dev/assets/implementer-prompt.md +2 -2
  10. package/build/Assets/Blueprints/skills/int-subagent-driven-dev/references/corrective-rounds.md +1 -1
  11. package/build/Assets/Blueprints/skills/int-subagent-driven-dev/references/example-workflow.md +14 -12
  12. package/build/Assets/Blueprints/skills/int-subagent-driven-dev/references/full-spec.md +43 -23
  13. package/build/Assets/Blueprints/skills/int-subagent-driven-dev/references/review-inputs.md +7 -7
  14. package/build/Assets/Blueprints/skills/int-testing-agent-guidance/SKILL.md +2 -2
  15. package/build/Assets/Blueprints/skills/int-writing-skills/SKILL.md +2 -2
  16. package/build/Assets/Blueprints/skills/int-writing-skills/references/testing-skills.md +2 -2
  17. package/build/Assets/Blueprints/skills/{asd-ste100 → ref-asd-ste100}/SKILL.md +1 -1
  18. package/build/Assets/Blueprints/skills/ref-php-laravel-saloon-api-integrations/SKILL.md +38 -0
  19. package/build/Assets/Blueprints/skills/ref-php-laravel-saloon-api-integrations/references/concurrency-and-pools.md +73 -0
  20. package/build/Assets/Blueprints/skills/ref-php-laravel-saloon-api-integrations/references/connectors.md +99 -0
  21. package/build/Assets/Blueprints/skills/ref-php-laravel-saloon-api-integrations/references/data-transfer-objects.md +104 -0
  22. package/build/Assets/Blueprints/skills/ref-php-laravel-saloon-api-integrations/references/handling-failures.md +95 -0
  23. package/build/Assets/Blueprints/skills/ref-php-laravel-saloon-api-integrations/references/requests.md +111 -0
  24. package/build/Assets/Blueprints/skills/ref-php-laravel-saloon-api-integrations/references/responses.md +56 -0
  25. package/build/Assets/Blueprints/skills/ref-php-laravel-saloon-api-integrations/references/sending-requests.md +42 -0
  26. package/build/Assets/Blueprints/skills/ref-php-laravel-saloon-api-integrations/references/testing.md +119 -0
  27. package/build/Assets/Blueprints/skills/{author-design → sdd-author-design}/SKILL.md +12 -4
  28. package/build/Assets/Blueprints/skills/{grill-design → sdd-grill-design}/SKILL.md +1 -1
  29. package/build/Assets/Blueprints/skills/{implement-spec → sdd-implement-spec}/SKILL.md +6 -8
  30. package/build/Assets/Blueprints/skills/{to-spec → sdd-to-spec}/SKILL.md +3 -3
  31. package/build/Assets/Blueprints/skills/{to-tickets → sdd-to-tickets}/SKILL.md +3 -3
  32. package/build/Assets/Blueprints/skills/{setup-project → spindle-project-setup}/SKILL.md +4 -4
  33. package/build/Assets/Blueprints/skills/{update-tooling → spindle-tooling-update}/SKILL.md +5 -5
  34. package/build/Assets/Templates/project/copilot-instructions.md +3 -9
  35. package/build/Assets/Templates/project/ticket-tracker.md +14 -6
  36. package/build/BlueprintManagement/Commands/Prompts.js +1 -1
  37. package/build/SubagentDrivenDevelopment/Commands/ChecksRecordCommand.js +27 -5
  38. package/build/SubagentDrivenDevelopment/Commands/ChecksRequireCommand.js +31 -7
  39. package/build/SubagentDrivenDevelopment/Commands/CorrectCommand.js +0 -1
  40. package/build/SubagentDrivenDevelopment/Commands/FinishCommand.js +2 -1
  41. package/build/SubagentDrivenDevelopment/Commands/ImplementCommand.js +2 -0
  42. package/build/SubagentDrivenDevelopment/Commands/{ClaimCommand.js → IntegrateCommand.js} +8 -7
  43. package/build/SubagentDrivenDevelopment/Commands/ReviewCommand.js +9 -9
  44. package/build/SubagentDrivenDevelopment/Commands/StartCommand.js +23 -0
  45. package/build/SubagentDrivenDevelopment/Commands/StatusCommand.js +5 -0
  46. package/build/SubagentDrivenDevelopment/Commands/groups.js +3 -2
  47. package/build/SubagentDrivenDevelopment/Evidence/GitEvidence.js +188 -37
  48. package/build/SubagentDrivenDevelopment/Models/Workflow.js +0 -2
  49. package/build/SubagentDrivenDevelopment/Persistence/AssignmentSchema.js +4 -0
  50. package/build/SubagentDrivenDevelopment/Persistence/CheckSchema.js +4 -13
  51. package/build/SubagentDrivenDevelopment/Persistence/LedgerRepository.js +66 -5
  52. package/build/SubagentDrivenDevelopment/Persistence/ProgressSchema.js +54 -4
  53. package/build/SubagentDrivenDevelopment/Persistence/ReviewSchema.js +4 -0
  54. package/build/SubagentDrivenDevelopment/Policies/AssignmentPolicy.js +0 -3
  55. package/build/SubagentDrivenDevelopment/Policies/CheckPolicy.js +6 -3
  56. package/build/SubagentDrivenDevelopment/Policies/ReadinessPolicy.js +16 -6
  57. package/build/SubagentDrivenDevelopment/Policies/ReviewPolicy.js +16 -2
  58. package/build/SubagentDrivenDevelopment/Policies/WorkflowPolicy.js +8 -8
  59. package/build/SubagentDrivenDevelopment/Services/AssignmentService.js +73 -51
  60. package/build/SubagentDrivenDevelopment/Services/ChecksService.js +376 -21
  61. package/build/SubagentDrivenDevelopment/Services/CorrectionService.js +83 -14
  62. package/build/SubagentDrivenDevelopment/Services/DeliveryCorrectionService.js +3 -7
  63. package/build/SubagentDrivenDevelopment/Services/DeliveryService.js +43 -38
  64. package/build/SubagentDrivenDevelopment/Services/FeatureCreationService.js +1 -1
  65. package/build/SubagentDrivenDevelopment/Services/FeatureFinalizationService.js +1202 -0
  66. package/build/SubagentDrivenDevelopment/Services/FeatureInspectionService.js +41 -49
  67. package/build/SubagentDrivenDevelopment/Services/FeatureWorktreeService.js +544 -0
  68. package/build/SubagentDrivenDevelopment/Services/ReadinessService.js +2 -2
  69. package/build/SubagentDrivenDevelopment/Services/ReviewService.js +39 -3
  70. package/build/SubagentDrivenDevelopment/Services/SubmissionService.js +4 -1
  71. package/build/SubagentDrivenDevelopment/Services/TicketIntegrationService.js +621 -0
  72. package/build/SubagentDrivenDevelopment/Services/TicketService.js +3 -0
  73. package/build/SubagentDrivenDevelopment/Services/TicketWorktreeService.js +317 -0
  74. package/build/SubagentDrivenDevelopment/Services/WorkflowRecordsCommitter.js +336 -0
  75. package/build/SubagentDrivenDevelopment/SubagentDrivenDevelopmentServiceProvider.js +18 -8
  76. package/build/TemplateRendering/Computed/ComputedVariableProviders.js +2 -0
  77. package/build/TemplateRendering/Computed/Providers/AuthoringFlowchartProvider.js +26 -0
  78. package/package.json +2 -2
  79. package/build/SubagentDrivenDevelopment/Services/IntegrationService.js +0 -258
  80. /package/build/Assets/Blueprints/skills/{asd-ste100 → ref-asd-ste100}/CREDITS.MD +0 -0
  81. /package/build/Assets/Blueprints/skills/{asd-ste100 → ref-asd-ste100}/examples/before-after.md +0 -0
  82. /package/build/Assets/Blueprints/skills/{asd-ste100 → ref-asd-ste100}/references/writing-rules.md +0 -0
@@ -5,16 +5,19 @@ import { ReviewService } from "../Services/ReviewService.js";
5
5
  export class ReviewCommand extends Command {
6
6
  static register(root, createApp) {
7
7
  reviewSchema(root).action(async (action, options) => {
8
+ if (options.scope !== undefined && options.scope !== "delivery") {
9
+ throw new Error("--scope must be 'delivery'.");
10
+ }
11
+ if (Boolean(options.ticket) === (options.scope === "delivery")) {
12
+ throw new Error("review needs exactly one of --ticket or --scope delivery.");
13
+ }
8
14
  if (action === "require") {
9
15
  if (options.type && options.agent && options.type !== options.agent) {
10
16
  throw new Error("--type and --agent must identify the same review agent.");
11
17
  }
12
18
  const reviewType = options.type ?? options.agent;
13
- if (options.scope !== "delivery") {
14
- throw new Error("Feature review requirements need --scope delivery.");
15
- }
16
19
  if (!options.spec || !reviewType || !options.details || !options.author) {
17
- throw new Error("review require needs --spec, --scope delivery, --type, --details, and --author.");
20
+ throw new Error("review require needs --spec, --type, --details, and --author.");
18
21
  }
19
22
  if (options.inventory)
20
23
  throw new Error("--inventory is not valid with 'review require'.");
@@ -33,11 +36,8 @@ export class ReviewCommand extends Command {
33
36
  if (options.type || options.agent || options.details) {
34
37
  throw new Error("--type, --agent, and --details are only valid with 'review require'.");
35
38
  }
36
- if (!options.spec ||
37
- options.scope !== "delivery" ||
38
- !options.inventory ||
39
- !options.author) {
40
- throw new Error("Feature review requires --spec, --scope delivery, --inventory, and --author.");
39
+ if (!options.spec || !options.inventory || !options.author) {
40
+ throw new Error("review requires --spec, --inventory, and --author.");
41
41
  }
42
42
  const command = await (await createApp()).make(ReviewCommand);
43
43
  console.log(JSON.stringify(await command.run({
@@ -0,0 +1,23 @@
1
+ import { Command } from "../../Commands/Command.js";
2
+ import { CommandOptions } from "../../Commands/CommandOptions.js";
3
+ import { group } from "./groups.js";
4
+ import { FeatureWorktreeService } from "../Services/FeatureWorktreeService.js";
5
+ export class StartCommand extends Command {
6
+ static register(root, createApp) {
7
+ group(root)
8
+ .command("start")
9
+ .description("Commit feature records and create a feature branch and worktree.")
10
+ .requiredOption("--spec <path>", "Feature spec path", CommandOptions.once("--spec"))
11
+ .requiredOption("--workspace <directory>", "Existing absolute external directory for the new worktree", CommandOptions.once("--workspace"))
12
+ .requiredOption("--confirm-records", "Confirm feature records were inspected for sensitive content")
13
+ .allowExcessArguments(false)
14
+ .action(async (options) => {
15
+ const command = await (await createApp()).make(StartCommand);
16
+ console.log(JSON.stringify(await command.run(options), null, 2));
17
+ });
18
+ }
19
+ async run(options) {
20
+ this.assertApplicationBooted();
21
+ return (await this.app.make(FeatureWorktreeService)).start(options);
22
+ }
23
+ }
@@ -1,6 +1,7 @@
1
1
  import { Command } from "../../Commands/Command.js";
2
2
  import { CommandOptions } from "../../Commands/CommandOptions.js";
3
3
  import { group } from "./groups.js";
4
+ import { FeatureFinalizationService } from "../Services/FeatureFinalizationService.js";
4
5
  import { FeatureInspectionService } from "../Services/FeatureInspectionService.js";
5
6
  export class StatusCommand extends Command {
6
7
  static register(root, createApp) {
@@ -16,6 +17,10 @@ export class StatusCommand extends Command {
16
17
  }
17
18
  async run(options) {
18
19
  this.assertApplicationBooted();
20
+ const finalization = await this.app.make(FeatureFinalizationService);
21
+ const recorded = finalization.recordedFeature(options.spec);
22
+ if (recorded)
23
+ return finalization.status(recorded.feature);
19
24
  return (await this.app.make(FeatureInspectionService)).status(options);
20
25
  }
21
26
  }
@@ -23,10 +23,11 @@ export function reviewSchema(root) {
23
23
  return existing;
24
24
  return program
25
25
  .command("review")
26
- .description("Require a specialist or freeze the feature inventory for review.")
26
+ .description("Require a specialist or freeze a ticket or feature inventory for review.")
27
27
  .argument("[action]", "Use 'require' to pin a justified specialist reviewer")
28
28
  .option("--spec <path>", "Feature spec path", CommandOptions.once("--spec"))
29
- .option("--scope <scope>", "Workflow scope: delivery")
29
+ .option("--ticket <id>", "Ticket ID", CommandOptions.once("--ticket"))
30
+ .option("--scope <scope>", "Workflow scope: delivery", CommandOptions.once("--scope"))
30
31
  .option("--inventory <json>", "Controller-confirmed Git-derived inventory as a JSON string array", CommandOptions.once("--inventory"))
31
32
  .option("--author <author>", "Controller author", CommandOptions.once("--author"))
32
33
  .option("--type <name>", "Review-eligible Blueprint agent key to require", CommandOptions.once("--type"))
@@ -70,9 +70,9 @@ export class GitEvidence extends Service {
70
70
  }
71
71
  return paths;
72
72
  }
73
- worktreeContentDigest(paths) {
73
+ worktreeContentDigest(paths, workspace) {
74
74
  const records = paths.map((path) => {
75
- const location = join(this.app.workspaceRoot, path);
75
+ const location = join(workspace ?? this.app.workspaceRoot, path);
76
76
  const entry = lstatIfPresent(location);
77
77
  if (!entry)
78
78
  return [path, "deleted", ""];
@@ -132,15 +132,17 @@ export class GitEvidence extends Service {
132
132
  });
133
133
  return createHash("sha256").update(JSON.stringify(records)).digest("hex");
134
134
  }
135
- cleanGitBaseline(feature) {
135
+ cleanDispatchBaseline(feature, allowPendingRecords = false) {
136
136
  const root = realpathSync(this.app.workspaceRoot);
137
+ if (allowPendingRecords)
138
+ return this.pendingDispatchBaseline(feature, root);
137
139
  const changes = this.gitChangeSummary(feature);
138
140
  if (changes) {
139
141
  const deliveryRecords = this.deliveryRecordPaths(feature);
140
142
  if (deliveryRecords.length > 0) {
141
- throw new Error(`Cannot claim while uncommitted delivery records remain; the caller must commit them first:\n${deliveryRecords.join("\n")}`);
143
+ throw new Error(`Cannot dispatch while uncommitted delivery records remain; inspect and confirm them before committing:\n${deliveryRecords.join("\n")}`);
142
144
  }
143
- throw new Error(`Cannot claim from a dirty checkout; commit or otherwise resolve staged, unstaged, and untracked changes first:\n${changes}`);
145
+ throw new Error(`Cannot dispatch from a dirty checkout; inspect and resolve staged, unstaged, and unrelated changes first:\n${changes}`);
144
146
  }
145
147
  const revision = this.git.run(["rev-parse", "--verify", "HEAD"], {
146
148
  cwd: root,
@@ -154,10 +156,90 @@ export class GitEvidence extends Service {
154
156
  }
155
157
  return revision.stdout.trim();
156
158
  }
157
- contentIdentity(feature, ticket, baseSha = this.headSha(), checks, coveredPaths) {
158
- const root = realpathSync(this.app.workspaceRoot);
159
- const headSha = this.headSha();
160
- const changedPaths = this.changedPaths(baseSha);
159
+ pendingDispatchBaseline(feature, root) {
160
+ for (const variable of [
161
+ "GIT_DIR",
162
+ "GIT_WORK_TREE",
163
+ "GIT_COMMON_DIR",
164
+ "GIT_INDEX_FILE",
165
+ "GIT_OBJECT_DIRECTORY",
166
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
167
+ "GIT_NAMESPACE",
168
+ ]) {
169
+ if (process.env[variable])
170
+ throw new Error(`Unset ${variable} before ticket dispatch.`);
171
+ }
172
+ const ledger = this.ledgerRepository.readLedger(feature);
173
+ const pending = ledger.tickets
174
+ .map((ticket) => ticket.integration ?? ticket.integrationHistory?.at(-1))
175
+ .filter((integration) => integration?.postMerge?.status === "pending");
176
+ const head = this.headSha(root);
177
+ if (!pending.length || pending.some((integration) => integration?.mergeSha !== head)) {
178
+ throw new Error("Pending post-merge validation does not match the exact feature HEAD.");
179
+ }
180
+ const gitPath = (name) => resolve(root, this.gitBuffer(["rev-parse", "--git-path", name], root).toString("utf8").trim());
181
+ for (const operation of [
182
+ "MERGE_HEAD",
183
+ "CHERRY_PICK_HEAD",
184
+ "REVERT_HEAD",
185
+ "REBASE_HEAD",
186
+ "rebase-merge",
187
+ "rebase-apply",
188
+ "sequencer",
189
+ ]) {
190
+ if (lstatIfPresent(gitPath(operation))) {
191
+ throw new Error(`Existing Git operation ${operation} was preserved; inspect it before dispatch.`);
192
+ }
193
+ }
194
+ const common = realpathSync(this.gitBuffer(["rev-parse", "--path-format=absolute", "--git-common-dir"], root)
195
+ .toString("utf8")
196
+ .trim());
197
+ if (lstatIfPresent(join(common, "spindle-record-commit", `${feature.feature}.json`))) {
198
+ throw new Error("Pending workflow-record commit journal must be resolved before dispatch.");
199
+ }
200
+ const status = this.gitBuffer([
201
+ "status",
202
+ "--porcelain=v1",
203
+ "-z",
204
+ "--untracked-files=all",
205
+ "--ignore-submodules=none",
206
+ "--",
207
+ ".",
208
+ ], root)
209
+ .toString("utf8")
210
+ .split("\0")
211
+ .filter(Boolean);
212
+ const allowed = this.workflowDeliveryRecordPaths(feature, ledger);
213
+ const lock = `.scratch/${feature.feature}/.sdd-tickets.lock`;
214
+ for (const entry of status) {
215
+ const state = entry.slice(0, 2);
216
+ const path = entry.slice(3);
217
+ if (state[0] !== " " && state !== "??") {
218
+ throw new Error(`Staged data was preserved; inspect it before dispatch: ${path}.`);
219
+ }
220
+ if (path === lock)
221
+ continue;
222
+ if (!allowed.has(path)) {
223
+ throw new Error(`Unrelated or non-canonical changes were preserved: ${path}.`);
224
+ }
225
+ const file = lstatIfPresent(join(root, path));
226
+ if (!file?.isFile() || file.isSymbolicLink()) {
227
+ throw new Error(`Canonical workflow record is missing or unsafe: ${path}.`);
228
+ }
229
+ }
230
+ if (this.headSha(root) !== head)
231
+ throw new Error("Feature HEAD changed during ticket dispatch.");
232
+ return head;
233
+ }
234
+ contentIdentity(feature, ticket, baseSha, checks, coveredPaths, workspace) {
235
+ const progress = this.ledgerRepository
236
+ .readLedger(feature)
237
+ .tickets.find((item) => item.id === ticket.id);
238
+ const root = workspace ??
239
+ (progress ? this.ticketWorkspace(progress) : undefined) ??
240
+ realpathSync(this.app.workspaceRoot);
241
+ const headSha = this.headSha(root);
242
+ const changedPaths = this.changedPaths(baseSha ?? headSha, root);
161
243
  const paths = new Set(coveredPaths ?? changedPaths);
162
244
  if (coveredPaths) {
163
245
  for (const path of paths) {
@@ -183,7 +265,7 @@ export class GitEvidence extends Service {
183
265
  for (const path of [...paths].sort()) {
184
266
  field("record", "path");
185
267
  field("path", path);
186
- field("index-stage", this.gitBuffer(["ls-files", "--stage", "-z", "--", `:(literal)${path}`]));
268
+ field("index-stage", this.gitBuffer(["ls-files", "--stage", "-z", "--", `:(literal)${path}`], root));
187
269
  const indexed = this.git.run(["show", `:${path}`], {
188
270
  cwd: root,
189
271
  encoding: "buffer",
@@ -227,8 +309,16 @@ export class GitEvidence extends Service {
227
309
  .update(JSON.stringify(requirementParts))
228
310
  .digest("hex");
229
311
  const snapshot = createHash("sha256")
230
- .update(JSON.stringify([codeSnapshot, requirementsSnapshot]))
312
+ .update(JSON.stringify(progress?.worktree && root === progress.worktree.workspace
313
+ ? [codeSnapshot, requirementsSnapshot, headSha]
314
+ : [codeSnapshot, requirementsSnapshot]))
231
315
  .digest("hex");
316
+ if (this.headSha(root) !== headSha) {
317
+ throw new Error("Git HEAD changed while computing content evidence.");
318
+ }
319
+ if (progress?.worktree && root === progress.worktree.workspace) {
320
+ this.ticketWorkspace(progress);
321
+ }
232
322
  return { snapshot, codeSnapshot, requirementsSnapshot, headSha };
233
323
  }
234
324
  deliveryIdentity(feature, ledger, tickets) {
@@ -239,7 +329,7 @@ export class GitEvidence extends Service {
239
329
  if (!delivery.baseSha) {
240
330
  throw new Error("Aggregate evidence requires the first ticket's recorded Git baseline.");
241
331
  }
242
- const codeIdentity = this.contentIdentity(feature, tickets[0], delivery.baseSha);
332
+ const codeIdentity = this.contentIdentity(feature, tickets[0], delivery.baseSha, undefined, undefined, realpathSync(this.app.workspaceRoot));
243
333
  const requirementsSnapshot = createHash("sha256")
244
334
  .update(JSON.stringify([
245
335
  readFileSync(feature.spec, "utf8"),
@@ -266,25 +356,25 @@ export class GitEvidence extends Service {
266
356
  headSha: codeIdentity.headSha,
267
357
  };
268
358
  }
269
- changedPaths(baseSha) {
359
+ changedPaths(baseSha, workspace) {
270
360
  const paths = new Set();
271
361
  for (const args of [
272
- ["diff", "--name-only", "-z", baseSha, "--", "."],
273
- ["diff", "--cached", "--name-only", "-z", baseSha, "--", "."],
362
+ ["diff", "--name-only", "--no-renames", "-z", baseSha, "--", "."],
363
+ ["diff", "--cached", "--name-only", "--no-renames", "-z", baseSha, "--", "."],
274
364
  ["ls-files", "--others", "--exclude-standard", "-z"],
275
365
  ]) {
276
- const output = this.gitBuffer(args).toString("utf8");
366
+ const output = this.gitBuffer(args, workspace).toString("utf8");
277
367
  for (const path of output.split("\0")) {
278
368
  if (path && path !== ".scratch" && !path.startsWith(".scratch/"))
279
369
  paths.add(path);
280
370
  }
281
371
  }
282
- this.assertStableSubmodules(baseSha, paths);
372
+ this.assertStableSubmodules(baseSha, paths, workspace);
283
373
  return [...paths].sort();
284
374
  }
285
- assertStableSubmodules(baseSha, changedPaths) {
375
+ assertStableSubmodules(baseSha, changedPaths, workspace) {
286
376
  const gitlinks = new Map();
287
- for (const record of this.gitBuffer(["ls-files", "--stage", "-z"])
377
+ for (const record of this.gitBuffer(["ls-files", "--stage", "-z"], workspace)
288
378
  .toString("utf8")
289
379
  .split("\0")) {
290
380
  const [metadata, path] = record.split("\t", 2);
@@ -295,7 +385,7 @@ export class GitEvidence extends Service {
295
385
  gitlinks.set(path, sha);
296
386
  }
297
387
  const knownPaths = new Set(gitlinks.keys());
298
- for (const record of this.gitBuffer(["ls-tree", "-r", "-z", baseSha])
388
+ for (const record of this.gitBuffer(["ls-tree", "-r", "-z", baseSha], workspace)
299
389
  .toString("utf8")
300
390
  .split("\0")) {
301
391
  const [metadata, path] = record.split("\t", 2);
@@ -307,7 +397,7 @@ export class GitEvidence extends Service {
307
397
  }
308
398
  }
309
399
  for (const path of knownPaths) {
310
- const entry = lstatIfPresent(join(this.app.workspaceRoot, path));
400
+ const entry = lstatIfPresent(join(workspace ?? this.app.workspaceRoot, path));
311
401
  if (!entry) {
312
402
  throw new Error(`Cannot compute code identity for submodule '${path}'; its worktree is missing or uninitialized.`);
313
403
  }
@@ -321,21 +411,14 @@ export class GitEvidence extends Service {
321
411
  continue;
322
412
  let revision;
323
413
  try {
324
- revision = this.gitBuffer(["-C", path, "rev-parse", "--verify", "HEAD"])
414
+ revision = this.gitBuffer(["-C", path, "rev-parse", "--verify", "HEAD"], workspace)
325
415
  .toString("utf8")
326
416
  .trim();
327
417
  }
328
418
  catch {
329
419
  throw new Error(`Cannot compute code identity for submodule '${path}'; its worktree is missing or uninitialized.`);
330
420
  }
331
- const status = this.gitBuffer([
332
- "-C",
333
- path,
334
- "status",
335
- "--porcelain",
336
- "--untracked-files=all",
337
- "--ignore-submodules=none",
338
- ]).toString("utf8");
421
+ const status = this.gitBuffer(["-C", path, "status", "--porcelain", "--untracked-files=all", "--ignore-submodules=none"], workspace).toString("utf8");
339
422
  if (revision !== gitlinks.get(path) || status.length > 0) {
340
423
  throw new Error(`Cannot compute code identity for changed submodule '${path}'; submodule changes are unsupported.`);
341
424
  }
@@ -364,9 +447,75 @@ export class GitEvidence extends Service {
364
447
  }
365
448
  return JSON.stringify([...sections.entries()].sort(([left], [right]) => left.localeCompare(right)));
366
449
  }
367
- headSha() {
450
+ ticketWorkspace(progress) {
451
+ if (!progress.worktree)
452
+ return undefined;
453
+ const recorded = progress.worktree;
454
+ const entry = lstatIfPresent(recorded.workspace);
455
+ if (!entry?.isDirectory() ||
456
+ entry.isSymbolicLink() ||
457
+ realpathSync(recorded.workspace) !== recorded.workspace) {
458
+ throw new Error(`Ticket ${progress.id} worktree is missing or unsafe: ${recorded.workspace}.`);
459
+ }
460
+ const canonical = realpathSync(this.app.workspaceRoot);
461
+ const common = this.gitBuffer(["rev-parse", "--path-format=absolute", "--git-common-dir"])
462
+ .toString("utf8")
463
+ .trim();
464
+ const ticketCommon = this.gitBuffer(["rev-parse", "--path-format=absolute", "--git-common-dir"], recorded.workspace)
465
+ .toString("utf8")
466
+ .trim();
467
+ const branch = this.gitBuffer(["symbolic-ref", "--quiet", "--short", "HEAD"], recorded.workspace)
468
+ .toString("utf8")
469
+ .trim();
470
+ const top = this.gitBuffer(["rev-parse", "--show-toplevel"], recorded.workspace)
471
+ .toString("utf8")
472
+ .trim();
473
+ const registrations = this.gitBuffer(["worktree", "list", "--porcelain", "-z"])
474
+ .toString("utf8")
475
+ .split("\0");
476
+ if (isPathInside(canonical, recorded.workspace) ||
477
+ realpathSync(common) !== realpathSync(ticketCommon) ||
478
+ realpathSync(top) !== recorded.workspace ||
479
+ !registrations.includes(`worktree ${recorded.workspace}`) ||
480
+ branch !== recorded.branch) {
481
+ throw new Error(`Ticket ${progress.id} worktree no longer matches its recorded repository and branch.`);
482
+ }
483
+ const ancestor = this.git.run(["merge-base", "--is-ancestor", recorded.baseSha, "HEAD"], {
484
+ cwd: recorded.workspace,
485
+ encoding: "utf8",
486
+ });
487
+ if (ancestor.error)
488
+ throw ancestor.error;
489
+ if (ancestor.status !== 0)
490
+ throw new Error(`Ticket ${progress.id} no longer descends from its recorded baseline.`);
491
+ return recorded.workspace;
492
+ }
493
+ assertTicketCodeCommitted(progress) {
494
+ const workspace = this.ticketWorkspace(progress);
495
+ if (!workspace)
496
+ return;
497
+ const scratchDiff = this.gitBuffer(["diff", "--name-only", "--no-renames", "-z", progress.baseSha, "HEAD", "--", ".scratch"], workspace);
498
+ const scratchStatus = this.gitBuffer(["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ".scratch"], workspace);
499
+ if (scratchDiff.length || scratchStatus.length) {
500
+ throw new Error("Ticket worktree must not modify .scratch; use canonical feature records.");
501
+ }
502
+ const changed = new Set();
503
+ for (const args of [
504
+ ["diff", "--name-only", "--no-renames", "-z", "HEAD", "--", "."],
505
+ ["diff", "--cached", "--name-only", "--no-renames", "-z", "HEAD", "--", "."],
506
+ ["ls-files", "--others", "--exclude-standard", "-z"],
507
+ ]) {
508
+ for (const path of this.gitBuffer(args, workspace).toString("utf8").split("\0")) {
509
+ if (path && path !== ".scratch" && !path.startsWith(".scratch/"))
510
+ changed.add(path);
511
+ }
512
+ }
513
+ if (changed.size > 0)
514
+ throw new Error(`Commit ticket code before review: ${[...changed].sort().join(", ")}.`);
515
+ }
516
+ headSha(workspace) {
368
517
  const revision = this.git.run(["rev-parse", "--verify", "HEAD"], {
369
- cwd: realpathSync(this.app.workspaceRoot),
518
+ cwd: workspace ?? realpathSync(this.app.workspaceRoot),
370
519
  encoding: "utf8",
371
520
  env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
372
521
  });
@@ -377,9 +526,9 @@ export class GitEvidence extends Service {
377
526
  }
378
527
  return revision.stdout.trim();
379
528
  }
380
- gitBuffer(args) {
529
+ gitBuffer(args, workspace) {
381
530
  const result = this.git.run(args, {
382
- cwd: realpathSync(this.app.workspaceRoot),
531
+ cwd: workspace ?? realpathSync(this.app.workspaceRoot),
383
532
  encoding: "buffer",
384
533
  env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
385
534
  });
@@ -411,7 +560,7 @@ export class GitEvidence extends Service {
411
560
  if (status.error)
412
561
  throw status.error;
413
562
  if (status.status !== 0) {
414
- throw new Error(`git status failed while checking claim readiness: ${status.stderr.trim()}`);
563
+ throw new Error(`git status failed while checking dispatch readiness: ${status.stderr.trim()}`);
415
564
  }
416
565
  return status.stdout.trim();
417
566
  }
@@ -427,10 +576,12 @@ export class GitEvidence extends Service {
427
576
  return false;
428
577
  }
429
578
  try {
579
+ const workspace = this.ticketWorkspace(progress);
430
580
  return (progress.integration.contentDigest === accepted.contentDigest &&
431
581
  JSON.stringify(progress.integration.files) === JSON.stringify(accepted.files) &&
432
- JSON.stringify(this.changedPaths(progress.baseSha)) === JSON.stringify(accepted.files) &&
433
- this.worktreeContentDigest(accepted.files) === accepted.contentDigest);
582
+ JSON.stringify(this.changedPaths(progress.baseSha, workspace)) ===
583
+ JSON.stringify(accepted.files) &&
584
+ this.worktreeContentDigest(accepted.files, workspace) === accepted.contentDigest);
434
585
  }
435
586
  catch {
436
587
  return false;
@@ -5,11 +5,9 @@ export const TICKET_CATEGORIES = ["bug", "enhancement"];
5
5
  export const TRACKER_STATUSES = ["open", "in-progress", "closed"];
6
6
  export const EXECUTION_STATES = [
7
7
  "unstarted",
8
- "claimed",
9
8
  "implementing",
10
9
  "reviewing",
11
10
  "changes-requested",
12
- "accepted",
13
11
  "integrated",
14
12
  "complete",
15
13
  "blocked",
@@ -13,6 +13,10 @@ export function isImplementationAssignment(value) {
13
13
  !value.controller.trim() ||
14
14
  typeof value.report !== "string" ||
15
15
  !isAbsolute(value.report) ||
16
+ (Object.hasOwn(value, "workspace") &&
17
+ (typeof value.workspace !== "string" || !isAbsolute(value.workspace))) ||
18
+ (Object.hasOwn(value, "branch") &&
19
+ (typeof value.branch !== "string" || !value.branch.trim())) ||
16
20
  typeof value.requestedAt !== "string" ||
17
21
  !Number.isFinite(Date.parse(value.requestedAt)) ||
18
22
  !["pending", "submitted", "unavailable"].includes(String(value.status)) ||
@@ -12,7 +12,7 @@ export function isCheckRequirement(value) {
12
12
  return false;
13
13
  }
14
14
  const removed = Object.hasOwn(value, "removedAt");
15
- return (!removed ||
15
+ return ((!removed && !Object.hasOwn(value, "removedBy") && !Object.hasOwn(value, "removalDetails")) ||
16
16
  (typeof value.removedAt === "string" &&
17
17
  Number.isFinite(Date.parse(value.removedAt)) &&
18
18
  typeof value.removedBy === "string" &&
@@ -24,7 +24,7 @@ export function isCheckRecord(value) {
24
24
  if (isRecord(value) &&
25
25
  typeof value.command === "string" &&
26
26
  !!value.command.trim() &&
27
- ["passed", "failed", "unavailable"].includes(String(value.result)) &&
27
+ (value.result === "passed" || value.result === "failed" || value.result === "unavailable") &&
28
28
  typeof value.author === "string" &&
29
29
  !!value.author.trim() &&
30
30
  typeof value.snapshot === "string" &&
@@ -37,6 +37,8 @@ export function isCheckRecord(value) {
37
37
  /^[0-9a-f]{40,64}$/i.test(value.headSha) &&
38
38
  typeof value.recordedAt === "string" &&
39
39
  Number.isFinite(Date.parse(value.recordedAt)) &&
40
+ (!Object.hasOwn(value, "details") ||
41
+ (typeof value.details === "string" && !!value.details.trim())) &&
40
42
  (value.result === "passed" || (typeof value.details === "string" && !!value.details.trim()))) {
41
43
  const hasScope = Object.hasOwn(value, "coveredPaths") || Object.hasOwn(value, "coverageSnapshot");
42
44
  return (!hasScope ||
@@ -49,17 +51,6 @@ export function isCheckRecord(value) {
49
51
  }
50
52
  return false;
51
53
  }
52
- export function isCheckGateInvalidation(value) {
53
- return (isRecord(value) &&
54
- typeof value.at === "string" &&
55
- Number.isFinite(Date.parse(value.at)) &&
56
- typeof value.author === "string" &&
57
- !!value.author.trim() &&
58
- typeof value.command === "string" &&
59
- !!value.command.trim() &&
60
- ["accepted", "integrated"].includes(String(value.from)) &&
61
- value.to === "reviewing");
62
- }
63
54
  export function isSafeCoveragePath(value) {
64
55
  return (typeof value === "string" &&
65
56
  !!value &&
@@ -1,15 +1,47 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { isAbsolute, join } from "node:path";
3
3
  import { blueprintCatalog } from "../../BlueprintManagement/Catalog/BlueprintCatalog.js";
4
4
  import { Service } from "../../Foundation/Service.js";
5
5
  import { FEATURE_DIRECTORY, EXECUTION_STATES, TICKET_CATEGORIES, TRACKER_STATUSES, } from "../Models/Workflow.js";
6
6
  import { isImplementationAssignment, isCorrectionAttempt, isCorrectionAdjudication, hasMatchingCorrectionAttempt, } from "./AssignmentSchema.js";
7
- import { isCheckRequirement, isCheckRecord, isCheckGateInvalidation, } from "./CheckSchema.js";
7
+ import { isCheckRequirement, isCheckRecord, } from "./CheckSchema.js";
8
8
  import { isReviewFinding, isRequirementsDecision, } from "./FindingSchema.js";
9
9
  import { isDeliveryProgress, isAcceptedEvidence, isIntegrationRecord, } from "./ProgressSchema.js";
10
10
  import { isReviewAssignment, isSpecialistReviewRequirement, } from "./ReviewSchema.js";
11
11
  import { Records } from "../../Support/Records.js";
12
12
  const isRecord = Records.isRecord;
13
+ function matchingCorrectionChecks(current, previous) {
14
+ const expected = previous.postMerge?.checks;
15
+ const checks = current.postMerge?.checks;
16
+ return (!!expected &&
17
+ !!checks &&
18
+ JSON.stringify(expected.required.map((item) => item.command)) ===
19
+ JSON.stringify(checks.required.map((item) => item.command)));
20
+ }
21
+ function matchingCorrectionHistory(history) {
22
+ const first = history[0];
23
+ return !!first && history.every((integration) => matchingCorrectionChecks(integration, first));
24
+ }
25
+ function validBaselineHistory(value, baseSha) {
26
+ if (!Array.isArray(value) ||
27
+ value.length === 0 ||
28
+ !value.every((change) => isRecord(change) &&
29
+ typeof change.from === "string" &&
30
+ /^[0-9a-f]{40,64}$/i.test(change.from) &&
31
+ typeof change.to === "string" &&
32
+ /^[0-9a-f]{40,64}$/i.test(change.to) &&
33
+ change.from !== change.to &&
34
+ typeof change.ticketHead === "string" &&
35
+ /^[0-9a-f]{40,64}$/i.test(change.ticketHead) &&
36
+ typeof change.at === "string" &&
37
+ Number.isFinite(Date.parse(change.at)) &&
38
+ typeof change.author === "string" &&
39
+ !!change.author.trim() &&
40
+ Object.keys(change).every((key) => ["from", "to", "ticketHead", "at", "author"].includes(key))))
41
+ return false;
42
+ return (value.every((change, index) => index === 0 || value[index - 1].to === change.from) &&
43
+ value.at(-1).to === baseSha);
44
+ }
13
45
  export class LedgerRepository extends Service {
14
46
  constructor(app) {
15
47
  super(app);
@@ -81,6 +113,16 @@ export class LedgerRepository extends Service {
81
113
  !!ticket.owner.trim() &&
82
114
  typeof ticket.baseSha === "string" &&
83
115
  /^[0-9a-f]{40,64}$/i.test(ticket.baseSha))) &&
116
+ (!Object.hasOwn(ticket, "baselineHistory") ||
117
+ validBaselineHistory(ticket.baselineHistory, ticket.baseSha)) &&
118
+ (!Object.hasOwn(ticket, "worktree") ||
119
+ (isRecord(ticket.worktree) &&
120
+ typeof ticket.worktree.workspace === "string" &&
121
+ isAbsolute(ticket.worktree.workspace) &&
122
+ ticket.worktree.branch === `tickets/${feature}/${ticket.id}` &&
123
+ typeof ticket.worktree.baseSha === "string" &&
124
+ ticket.worktree.baseSha === ticket.baseSha &&
125
+ /^[0-9a-f]{40,64}$/i.test(ticket.worktree.baseSha))) &&
84
126
  (!Object.hasOwn(ticket, "acceptedEvidence") ||
85
127
  isAcceptedEvidence(ticket.acceptedEvidence)) &&
86
128
  (!Object.hasOwn(ticket, "integration") ||
@@ -90,6 +132,23 @@ export class LedgerRepository extends Service {
90
132
  JSON.stringify(ticket.integration.files) ===
91
133
  JSON.stringify(ticket.acceptedEvidence.files) &&
92
134
  ticket.integration.contentDigest === ticket.acceptedEvidence.contentDigest)) &&
135
+ (!Object.hasOwn(ticket, "integrationHistory") ||
136
+ (Array.isArray(ticket.integrationHistory) &&
137
+ ticket.integrationHistory.length > 0 &&
138
+ ticket.integrationHistory.every(isIntegrationRecord) &&
139
+ ticket.integrationHistory.every((integration) => {
140
+ const postMerge = integration.postMerge;
141
+ const records = postMerge?.checks?.records ?? [];
142
+ return (postMerge?.status === "pending" &&
143
+ records.some((record, index) => record.result === "failed" &&
144
+ !records.slice(index + 1).some((later) => later.command === record.command)));
145
+ }) &&
146
+ new Set(ticket.integrationHistory.map((integration) => integration.mergeSha)).size ===
147
+ ticket.integrationHistory.length &&
148
+ (!isIntegrationRecord(ticket.integration) ||
149
+ (!new Set(ticket.integrationHistory.map((integration) => integration.mergeSha)).has(ticket.integration.mergeSha) &&
150
+ matchingCorrectionChecks(ticket.integration, ticket.integrationHistory[ticket.integrationHistory.length - 1]))) &&
151
+ matchingCorrectionHistory(ticket.integrationHistory))) &&
93
152
  (!Object.hasOwn(ticket, "reviewRequirements") ||
94
153
  (Array.isArray(ticket.reviewRequirements) &&
95
154
  ticket.reviewRequirements.every(isSpecialistReviewRequirement) &&
@@ -101,11 +160,13 @@ export class LedgerRepository extends Service {
101
160
  ticket.checks.required.every(isCheckRequirement) &&
102
161
  Array.isArray(ticket.checks.records) &&
103
162
  ticket.checks.records.every(isCheckRecord) &&
104
- (!Object.hasOwn(ticket.checks, "invalidations") ||
105
- (Array.isArray(ticket.checks.invalidations) &&
106
- ticket.checks.invalidations.every(isCheckGateInvalidation))))) &&
163
+ !Object.hasOwn(ticket.checks, "invalidations"))) &&
107
164
  (!Object.hasOwn(ticket, "assignment") ||
108
165
  (isImplementationAssignment(ticket.assignment) &&
166
+ (!ticket.worktree ||
167
+ (isRecord(ticket.worktree) &&
168
+ ticket.assignment.workspace === ticket.worktree.workspace &&
169
+ ticket.assignment.branch === ticket.worktree.branch)) &&
109
170
  ticket.assignment.report ===
110
171
  join(this.app.workspaceRoot, ".scratch", feature, "reports", ticket.assignment.action === "implement"
111
172
  ? `${ticket.id}-implementation.md`