@wrongstack/sdd 1.0.19 → 1.0.26

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/dist/index.js CHANGED
@@ -739,9 +739,9 @@ var SpecDrivenDev = class {
739
739
  };
740
740
 
741
741
  // src/spec-store.ts
742
+ import { randomUUID } from "node:crypto";
742
743
  import * as fsp from "node:fs/promises";
743
744
  import * as path from "node:path";
744
- import { randomUUID } from "node:crypto";
745
745
  import { atomicWrite, ensureDir } from "@wrongstack/core/utils";
746
746
  var SpecStore = class {
747
747
  baseDir;
@@ -833,7 +833,7 @@ var SpecStore = class {
833
833
  const dir = path.resolve(this.baseDir);
834
834
  const resolved = path.resolve(dir, `${id}.json`);
835
835
  const rel = path.relative(dir, resolved);
836
- if (rel.startsWith("..") || path.isAbsolute(rel) || rel.includes(path.sep)) {
836
+ if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel) || rel.includes(path.sep)) {
837
837
  throw new Error(`Invalid spec id: ${JSON.stringify(id)}`);
838
838
  }
839
839
  return resolved;
@@ -970,7 +970,7 @@ var TaskGraphStore = class {
970
970
  const dir = path2.resolve(this.baseDir);
971
971
  const resolved = path2.resolve(dir, `${id}.json`);
972
972
  const rel = path2.relative(dir, resolved);
973
- if (rel.startsWith("..") || path2.isAbsolute(rel) || rel.includes(path2.sep)) {
973
+ if (rel === ".." || rel.startsWith(`..${path2.sep}`) || path2.isAbsolute(rel) || rel.includes(path2.sep)) {
974
974
  throw new Error(`Invalid task-graph id: ${JSON.stringify(id)}`);
975
975
  }
976
976
  return resolved;
@@ -1358,14 +1358,16 @@ var SddBoardStore = class {
1358
1358
  });
1359
1359
  }
1360
1360
  async removeFromIndex(runId) {
1361
- const current = await this.readIndex();
1362
- const index = {
1363
- version: 1,
1364
- entries: current.entries.filter((entry) => entry.runId !== runId).map((entry) => ({ ...entry }))
1365
- };
1366
- await atomicWrite3(this.indexPath, JSON.stringify(index, null, 2), { mode: 384 });
1367
- this.cachedIndex = index;
1368
- this.cachedIndexSignature = await this.indexSignature();
1361
+ await withFileLock(this.indexPath, async () => {
1362
+ const current = await this.readIndex();
1363
+ const index = {
1364
+ version: 1,
1365
+ entries: current.entries.filter((entry) => entry.runId !== runId).map((entry) => ({ ...entry }))
1366
+ };
1367
+ await atomicWrite3(this.indexPath, JSON.stringify(index, null, 2), { mode: 384 });
1368
+ this.cachedIndex = index;
1369
+ this.cachedIndexSignature = await this.indexSignature();
1370
+ });
1369
1371
  }
1370
1372
  async indexSignature() {
1371
1373
  try {
@@ -2011,6 +2013,7 @@ var AISpecBuilder = class {
2011
2013
  store;
2012
2014
  minQuestions;
2013
2015
  maxQuestions;
2016
+ projectContext;
2014
2017
  sessionPath;
2015
2018
  sessionPersistence;
2016
2019
  pendingSessionWrite = Promise.resolve();
@@ -2018,6 +2021,7 @@ var AISpecBuilder = class {
2018
2021
  this.store = opts.store;
2019
2022
  this.minQuestions = opts.minQuestions ?? 2;
2020
2023
  this.maxQuestions = opts.maxQuestions ?? 10;
2024
+ this.projectContext = opts.projectContext ?? "";
2021
2025
  this.sessionPath = opts.sessionPath;
2022
2026
  this.sessionPersistence = opts.sessionPersistence;
2023
2027
  this.session = {
@@ -2025,7 +2029,7 @@ var AISpecBuilder = class {
2025
2029
  phase: "questioning",
2026
2030
  title: "",
2027
2031
  userIntent: "",
2028
- projectContext: opts.projectContext ?? "",
2032
+ projectContext: this.projectContext,
2029
2033
  answers: [],
2030
2034
  questionCount: 0,
2031
2035
  approved: false,
@@ -2105,10 +2109,11 @@ var AISpecBuilder = class {
2105
2109
  void this.saveSession();
2106
2110
  }
2107
2111
  // ── Session Lifecycle ─────────────────────────────────────────────────────
2108
- /** Start a new session with a title and optional intent. */
2109
- startSession(title, intent) {
2112
+ /** Start a new session with a title, optional intent, and per-session context. */
2113
+ startSession(title, intent, projectContext) {
2110
2114
  this.session.title = title;
2111
2115
  this.session.userIntent = intent ?? "";
2116
+ this.session.projectContext = projectContext ?? this.projectContext;
2112
2117
  this.session.phase = "questioning";
2113
2118
  this.session.updatedAt = Date.now();
2114
2119
  this.autoSave();
@@ -2308,6 +2313,7 @@ var AISpecBuilder = class {
2308
2313
  this.session.phase = "questioning";
2309
2314
  this.session.title = "";
2310
2315
  this.session.userIntent = "";
2316
+ this.session.projectContext = this.projectContext;
2311
2317
  this.session.answers = [];
2312
2318
  this.session.questionCount = 0;
2313
2319
  this.session.spec = void 0;
@@ -2500,9 +2506,9 @@ var SddInterviewDriver = class {
2500
2506
  });
2501
2507
  }
2502
2508
  /** Begin a fresh interview. Returns the first AI prompt (a question kickoff). */
2503
- start(title, intent) {
2509
+ start(title, intent, projectContext) {
2504
2510
  this.builder.resetForNewInterview();
2505
- this.builder.startSession(title, intent);
2511
+ this.builder.startSession(title, intent, projectContext);
2506
2512
  this.tracker = null;
2507
2513
  this.graph = null;
2508
2514
  this.resumedFromDisk = false;
@@ -2816,6 +2822,68 @@ import {
2816
2822
  writeKanbanWorkflowState
2817
2823
  } from "@wrongstack/kanban";
2818
2824
 
2825
+ // src/sdd-task-recovery.ts
2826
+ function computeDeadlockChains(host) {
2827
+ const tracker = host.opts.tracker;
2828
+ const chains = [];
2829
+ for (const node of tracker.getAllNodes()) {
2830
+ if (node.status === "completed" || node.status === "failed") continue;
2831
+ const blockedBy = tracker.getBlockers(node.id).filter((id) => tracker.getNode(id)?.status !== "completed");
2832
+ if (blockedBy.length > 0) chains.push({ blocked: node.id, blockedBy });
2833
+ }
2834
+ return chains;
2835
+ }
2836
+ function recoverFailedBlockers(host) {
2837
+ const tracker = host.opts.tracker;
2838
+ let recovered = false;
2839
+ for (const node of tracker.getAllNodes({ status: ["failed"] })) {
2840
+ const blocksIncomplete = tracker.getDependents(node.id).some((d) => {
2841
+ const s = tracker.getNode(d)?.status;
2842
+ return s !== "completed" && s !== "failed";
2843
+ });
2844
+ if (blocksIncomplete) {
2845
+ host.retryMap.delete(node.id);
2846
+ host.persistRetries(node.id, 0);
2847
+ tracker.updateNodeStatus(node.id, "pending", "deadlock recovery");
2848
+ recovered = true;
2849
+ }
2850
+ }
2851
+ return recovered;
2852
+ }
2853
+ function requeueFailedTasks(host, reason = "retry failed sweep") {
2854
+ const tracker = host.opts.tracker;
2855
+ let n = 0;
2856
+ for (const node of tracker.getAllNodes({ status: ["failed"] })) {
2857
+ if (host.cancelledTasks.has(node.id) || node.metadata?.cancelled) continue;
2858
+ host.retryMap.delete(node.id);
2859
+ host.persistRetries(node.id, 0);
2860
+ tracker.updateNodeStatus(node.id, "pending", reason);
2861
+ host.emit("sdd.task.retrying", {
2862
+ runId: host.runId,
2863
+ taskId: node.id,
2864
+ attempt: 0,
2865
+ maxRetries: host.maxRetries
2866
+ });
2867
+ n++;
2868
+ }
2869
+ return n;
2870
+ }
2871
+ function restoreRetryMap(host) {
2872
+ host.retryMap.clear();
2873
+ for (const node of host.opts.tracker.getAllNodes()) {
2874
+ const r = node.metadata?.retries;
2875
+ if (typeof r === "number" && r > 0) host.retryMap.set(node.id, r);
2876
+ }
2877
+ }
2878
+ function resetOrphans(tracker) {
2879
+ let n = 0;
2880
+ for (const node of tracker.getAllNodes({ status: ["in_progress"] })) {
2881
+ tracker.updateNodeStatus(node.id, "pending", "resume: orphaned in_progress");
2882
+ n++;
2883
+ }
2884
+ return n;
2885
+ }
2886
+
2819
2887
  // src/sdd-parallel-run.ts
2820
2888
  import { randomUUID as randomUUID3 } from "node:crypto";
2821
2889
  import {
@@ -3357,10 +3425,10 @@ var SddParallelRun = class {
3357
3425
  /** Type-safe emit on the optional EventBus (no-op when unwired). */
3358
3426
  emit(event, payload) {
3359
3427
  const sessionId = this.currentSessionId();
3360
- this.events?.emit(
3361
- event,
3362
- { ...payload, sessionId }
3363
- );
3428
+ this.events?.emit(event, {
3429
+ ...payload,
3430
+ sessionId
3431
+ });
3364
3432
  }
3365
3433
  currentSessionId() {
3366
3434
  const value = typeof this.sessionIdSource === "function" ? this.sessionIdSource() : this.sessionIdSource;
@@ -3686,32 +3754,11 @@ var SddParallelRun = class {
3686
3754
  * included since they're the usual deadlock cause once retries are exhausted.
3687
3755
  */
3688
3756
  computeDeadlockChains() {
3689
- const tracker = this.opts.tracker;
3690
- const chains = [];
3691
- for (const node of tracker.getAllNodes()) {
3692
- if (node.status === "completed" || node.status === "failed") continue;
3693
- const blockedBy = tracker.getBlockers(node.id).filter((id) => tracker.getNode(id)?.status !== "completed");
3694
- if (blockedBy.length > 0) chains.push({ blocked: node.id, blockedBy });
3695
- }
3696
- return chains;
3757
+ return computeDeadlockChains(this.sddTaskRecoveryHost());
3697
3758
  }
3698
3759
  /** Requeue failed tasks that block an incomplete dependent. Returns true if any. */
3699
3760
  recoverFailedBlockers() {
3700
- const tracker = this.opts.tracker;
3701
- let recovered = false;
3702
- for (const node of tracker.getAllNodes({ status: ["failed"] })) {
3703
- const blocksIncomplete = tracker.getDependents(node.id).some((d) => {
3704
- const s = tracker.getNode(d)?.status;
3705
- return s !== "completed" && s !== "failed";
3706
- });
3707
- if (blocksIncomplete) {
3708
- this.retryMap.delete(node.id);
3709
- this.persistRetries(node.id, 0);
3710
- tracker.updateNodeStatus(node.id, "pending", "deadlock recovery");
3711
- recovered = true;
3712
- }
3713
- }
3714
- return recovered;
3761
+ return recoverFailedBlockers(this.sddTaskRecoveryHost());
3715
3762
  }
3716
3763
  /**
3717
3764
  * Requeue every terminal-failed task that the user did NOT cancel, giving each
@@ -3719,22 +3766,7 @@ var SddParallelRun = class {
3719
3766
  * the manual "retry all failed" control. Returns the number requeued.
3720
3767
  */
3721
3768
  requeueFailedTasks(reason = "retry failed sweep") {
3722
- const tracker = this.opts.tracker;
3723
- let n = 0;
3724
- for (const node of tracker.getAllNodes({ status: ["failed"] })) {
3725
- if (this.cancelledTasks.has(node.id) || node.metadata?.cancelled) continue;
3726
- this.retryMap.delete(node.id);
3727
- this.persistRetries(node.id, 0);
3728
- tracker.updateNodeStatus(node.id, "pending", reason);
3729
- this.emit("sdd.task.retrying", {
3730
- runId: this.runId,
3731
- taskId: node.id,
3732
- attempt: 0,
3733
- maxRetries: this.maxRetries
3734
- });
3735
- n++;
3736
- }
3737
- return n;
3769
+ return requeueFailedTasks(this.sddTaskRecoveryHost(), reason);
3738
3770
  }
3739
3771
  /**
3740
3772
  * Manually requeue all failed tasks to `pending` (board "Retry all failed").
@@ -3753,11 +3785,7 @@ var SddParallelRun = class {
3753
3785
  }
3754
3786
  /** Restore per-task retry counts persisted in node metadata (resume support). */
3755
3787
  restoreRetryMap() {
3756
- this.retryMap.clear();
3757
- for (const node of this.opts.tracker.getAllNodes()) {
3758
- const r = node.metadata?.retries;
3759
- if (typeof r === "number" && r > 0) this.retryMap.set(node.id, r);
3760
- }
3788
+ restoreRetryMap(this.sddTaskRecoveryHost());
3761
3789
  }
3762
3790
  /**
3763
3791
  * Reset orphaned `in_progress` tasks (no agent runs them after a crash) back
@@ -3765,12 +3793,7 @@ var SddParallelRun = class {
3765
3793
  * from a reloaded graph. Static so callers don't need a run instance.
3766
3794
  */
3767
3795
  static resetOrphans(tracker) {
3768
- let n = 0;
3769
- for (const node of tracker.getAllNodes({ status: ["in_progress"] })) {
3770
- tracker.updateNodeStatus(node.id, "pending", "resume: orphaned in_progress");
3771
- n++;
3772
- }
3773
- return n;
3796
+ return resetOrphans(tracker);
3774
3797
  }
3775
3798
  /** Clean teardown after a stop: reset interrupted tasks + release worktrees. */
3776
3799
  async teardown() {
@@ -4021,6 +4044,24 @@ var SddParallelRun = class {
4021
4044
  deadlocked: isDeadlocked
4022
4045
  };
4023
4046
  }
4047
+ sddTaskRecoveryHost() {
4048
+ const self = this;
4049
+ return {
4050
+ get opts() {
4051
+ return self.opts;
4052
+ },
4053
+ get retryMap() {
4054
+ return self.retryMap;
4055
+ },
4056
+ persistRetries: (...args) => this.persistRetries(...args),
4057
+ get cancelledTasks() {
4058
+ return self.cancelledTasks;
4059
+ },
4060
+ emit: (...args) => this.emit(...args),
4061
+ runId: this.runId,
4062
+ maxRetries: this.maxRetries
4063
+ };
4064
+ }
4024
4065
  };
4025
4066
 
4026
4067
  // src/start-sdd-run.ts
@@ -4493,10 +4534,13 @@ function createKanbanSddSessionPersistence(projectRoot, legacySessionPath) {
4493
4534
  await pending;
4494
4535
  },
4495
4536
  async delete() {
4496
- await writeChain;
4497
- await deleteKanbanWorkflowState2(projectRoot, SDD_SESSION_WORKFLOW_ID);
4498
- revision = 0;
4499
- if (legacySessionPath) await fsp5.unlink(legacySessionPath).catch(() => void 0);
4537
+ const pending = writeChain.then(async () => {
4538
+ await deleteKanbanWorkflowState2(projectRoot, SDD_SESSION_WORKFLOW_ID);
4539
+ revision = 0;
4540
+ if (legacySessionPath) await fsp5.unlink(legacySessionPath).catch(() => void 0);
4541
+ });
4542
+ writeChain = pending.catch(() => void 0);
4543
+ await pending;
4500
4544
  }
4501
4545
  };
4502
4546
  }
@@ -4581,7 +4625,7 @@ function intakeToInterviewKickoff(record) {
4581
4625
  }
4582
4626
  function startInterviewFromIntake(driver, record) {
4583
4627
  const kickoff = intakeToInterviewKickoff(record);
4584
- return driver.start(kickoff.title, kickoff.intent);
4628
+ return driver.start(kickoff.title, kickoff.intent, kickoff.projectContext);
4585
4629
  }
4586
4630
 
4587
4631
  // src/spec-templates.ts
@@ -5051,7 +5095,9 @@ function computeCriticalPath(graph, _topoOrder, blockedByMap) {
5051
5095
  }
5052
5096
  function computeParallelGroups(graph, blockedByMap) {
5053
5097
  const groups = [];
5054
- const assigned = /* @__PURE__ */ new Set();
5098
+ const assigned = new Set(
5099
+ Array.from(graph.nodes.values()).filter((n) => n.status === "completed").map((n) => n.id)
5100
+ );
5055
5101
  const nodes = Array.from(graph.nodes.values()).filter((n) => n.status !== "completed");
5056
5102
  const remaining = new Set(nodes.map((n) => n.id));
5057
5103
  while (remaining.size > 0) {
@@ -5314,6 +5360,8 @@ var AutoExecutor = class {
5314
5360
  this.opts.onTaskComplete?.(task, execResult);
5315
5361
  } else if (execResult.retry) {
5316
5362
  retried++;
5363
+ this.opts.tracker.updateNodeStatus(task.id, "failed", execResult.error);
5364
+ failed++;
5317
5365
  } else {
5318
5366
  this.opts.tracker.updateNodeStatus(task.id, "failed", execResult.error);
5319
5367
  failed++;
@@ -25,9 +25,9 @@ export interface IntakeInterviewKickoff {
25
25
  export declare function intakeToInterviewKickoff(record: RequirementIntakeRecord): IntakeInterviewKickoff;
26
26
  /**
27
27
  * Start a spec-builder interview from an intake record. Returns the first AI
28
- * prompt (a question kickoff), ready to feed the agent loop. The driver should
29
- * be constructed with `projectContext: kickoff.projectContext` so the
30
- * questioning prompt already knows the collected facts.
28
+ * prompt (a question kickoff), ready to feed the agent loop. Collected intake
29
+ * facts are passed as this interview's project context so the questioning
30
+ * prompt already knows them.
31
31
  */
32
32
  export declare function startInterviewFromIntake(driver: Pick<SddInterviewDriver, 'start'>, record: RequirementIntakeRecord): string;
33
33
  //# sourceMappingURL=intake-kickoff.d.ts.map
@@ -78,7 +78,7 @@ export declare class SddInterviewDriver {
78
78
  private resumedFromDisk;
79
79
  constructor(opts: SddInterviewDriverOptions);
80
80
  /** Begin a fresh interview. Returns the first AI prompt (a question kickoff). */
81
- start(title: string, intent?: string): string;
81
+ start(title: string, intent?: string, projectContext?: string): string;
82
82
  /**
83
83
  * Resume a previously-persisted interview. Re-hydrates the task
84
84
  * graph too when one was already produced. Returns true if a session loaded.
@@ -1,22 +1,3 @@
1
- /**
2
- * SddParallelRun
3
- *
4
- * Drives a TaskGraph through ParallelEternalEngine's infrastructure
5
- * (DefaultMultiAgentCoordinator + AgentSubagentRunner) but powered by
6
- * SddTaskDecomposer — producing dependency-aware waves instead of
7
- * goal-driven iterations.
8
- *
9
- * One-shot: completes when all tasks are done OR a deadlock is detected.
10
- * Does NOT loop — each run() call is a discrete execution.
11
- *
12
- * Usage:
13
- * ```
14
- * const run = new SddParallelRun({ tracker, graph, agent, projectRoot });
15
- * await run.run({ onWave });
16
- * // or with progress callback:
17
- * await run.run({ onProgress: (p) => console.log(renderProgress(p)) });
18
- * ```
19
- */
20
1
  import type { TaskTracker } from '@wrongstack/core/tasking';
21
2
  import type { TaskNode } from '@wrongstack/core/types';
22
3
  import type { RunResult, SddParallelRunOptions, SddSubtaskSpec, TaskOutcome, WaveResult } from './sdd-parallel-run-types.js';
@@ -270,5 +251,6 @@ export declare class SddParallelRun {
270
251
  /** Persist a task's retry count into node metadata (survives crash → resume). */
271
252
  private persistRetries;
272
253
  private buildProgress;
254
+ private sddTaskRecoveryHost;
273
255
  }
274
256
  //# sourceMappingURL=sdd-parallel-run.d.ts.map
@@ -0,0 +1,20 @@
1
+ import type { TaskTracker } from '@wrongstack/core/tasking';
2
+ import type { SddParallelRunOptions } from './sdd-parallel-run-types.js';
3
+ export interface SddTaskRecoveryHost {
4
+ opts: SddParallelRunOptions;
5
+ retryMap: Map<string, number>;
6
+ persistRetries: (taskId: string, retries: number) => void;
7
+ cancelledTasks: Set<string>;
8
+ emit: <K extends keyof import('@wrongstack/core/kernel').EventMap>(event: K, payload: import('@wrongstack/core/kernel').EventMap[K]) => void;
9
+ runId: string;
10
+ maxRetries: number;
11
+ }
12
+ export declare function computeDeadlockChains(host: SddTaskRecoveryHost): Array<{
13
+ blocked: string;
14
+ blockedBy: string[];
15
+ }>;
16
+ export declare function recoverFailedBlockers(host: SddTaskRecoveryHost): boolean;
17
+ export declare function requeueFailedTasks(host: SddTaskRecoveryHost, reason?: string): number;
18
+ export declare function restoreRetryMap(host: SddTaskRecoveryHost): void;
19
+ export declare function resetOrphans(tracker: TaskTracker): number;
20
+ //# sourceMappingURL=sdd-task-recovery.d.ts.map
@@ -25,6 +25,7 @@ export declare class AISpecBuilder {
25
25
  private readonly store;
26
26
  private readonly minQuestions;
27
27
  private readonly maxQuestions;
28
+ private readonly projectContext;
28
29
  private readonly sessionPath?;
29
30
  private readonly sessionPersistence?;
30
31
  private pendingSessionWrite;
@@ -37,8 +38,8 @@ export declare class AISpecBuilder {
37
38
  deleteSession(): Promise<void>;
38
39
  /** Auto-save helper. saveSession() already handles best-effort persistence. */
39
40
  private autoSave;
40
- /** Start a new session with a title and optional intent. */
41
- startSession(title: string, intent?: string): void;
41
+ /** Start a new session with a title, optional intent, and per-session context. */
42
+ startSession(title: string, intent?: string, projectContext?: string): void;
42
43
  /** Get current session state (readonly). */
43
44
  getSession(): Readonly<AISpecSession>;
44
45
  /** Get the current phase. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrongstack/sdd",
3
- "version": "1.0.19",
3
+ "version": "1.0.26",
4
4
  "license": "MIT",
5
5
  "description": "WrongStack Spec-Driven Development engine — standalone package extracted from @wrongstack/core. Task graph generation, tracking, execution, lifecycle management, and AI-driven spec building for SDD workflows.",
6
6
  "repository": {
@@ -27,10 +27,10 @@
27
27
  "!dist/**/*.map"
28
28
  ],
29
29
  "dependencies": {
30
- "@wrongstack/core": "1.0.19",
31
- "@wrongstack/kanban": "1.0.19",
32
- "@wrongstack/primitives": "1.0.19",
33
- "@wrongstack/requirement-intake": "1.0.19"
30
+ "@wrongstack/core": "1.0.26",
31
+ "@wrongstack/kanban": "1.0.26",
32
+ "@wrongstack/primitives": "1.0.26",
33
+ "@wrongstack/requirement-intake": "1.0.26"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@types/node": "^26.5.1",