gsd-lite 0.7.8 → 0.7.9

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.
@@ -13,7 +13,7 @@
13
13
  "name": "gsd",
14
14
  "source": "./",
15
15
  "description": "AI orchestration tool — GSD management shell + Superpowers quality core. 5 commands, 4 agents, 5 workflows, MCP server, context monitoring.",
16
- "version": "0.7.8",
16
+ "version": "0.7.9",
17
17
  "keywords": [
18
18
  "orchestration",
19
19
  "mcp",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd",
3
- "version": "0.7.8",
3
+ "version": "0.7.9",
4
4
  "description": "AI orchestration tool for Claude Code — GSD management shell + Superpowers quality core",
5
5
  "author": {
6
6
  "name": "sdsrss",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-lite",
3
- "version": "0.7.8",
3
+ "version": "0.7.9",
4
4
  "description": "AI orchestration tool for Claude Code — GSD management shell + Superpowers quality core",
5
5
  "type": "module",
6
6
  "bin": {
package/src/schema.js CHANGED
@@ -761,16 +761,28 @@ export function createInitialState({ project, phases }) {
761
761
  // the public API guard is in init() which rejects phases.length === 0.
762
762
  // Validate task names and uniqueness before creating state
763
763
  const seenIds = new Set();
764
+ // Track task IDs per phase so task-kind dependencies can be checked against the
765
+ // same phase only — selectRunnableTask resolves task deps within phase.todo, so a
766
+ // cross-phase task dep silently deadlocks at runtime. patchPlan.add_dependency
767
+ // rejects the same thing; keep init consistent.
768
+ const phaseTaskIds = phases.map(() => new Set());
764
769
  for (const [pi, p] of phases.entries()) {
765
770
  for (const [ti, t] of (p.tasks || []).entries()) {
766
771
  if (!t.name || typeof t.name !== 'string') {
767
772
  return { error: true, message: `Phase ${pi + 1} task ${ti + 1}: name is required (got ${JSON.stringify(t.name)})` };
768
773
  }
774
+ // Guard explicit index: must be a positive integer or it yields malformed task IDs
775
+ // (e.g. "1.0", "1.1.5", "1.x") that break downstream id parsing.
776
+ if (t.index !== undefined && t.index !== null
777
+ && (!Number.isInteger(t.index) || t.index < 1)) {
778
+ return { error: true, message: `Phase ${pi + 1} task ${ti + 1}: index must be a positive integer (got ${JSON.stringify(t.index)})` };
779
+ }
769
780
  const id = `${pi + 1}.${t.index ?? (ti + 1)}`;
770
781
  if (seenIds.has(id)) {
771
782
  return { error: true, message: `Duplicate task ID: ${id} in phase ${pi + 1}` };
772
783
  }
773
784
  seenIds.add(id);
785
+ phaseTaskIds[pi].add(id);
774
786
  }
775
787
  }
776
788
 
@@ -792,8 +804,12 @@ export function createInitialState({ project, phases }) {
792
804
  if (dep.gate && !validGates.includes(dep.gate)) {
793
805
  return { error: true, message: `Task ${taskId}: requires entry gate must be one of ${validGates.join(', ')} (got "${dep.gate}")` };
794
806
  }
795
- if (dep.kind === 'task' && !seenIds.has(String(dep.id))) {
796
- return { error: true, message: `Task ${taskId}: requires references non-existent task "${dep.id}" (valid IDs: ${[...seenIds].join(', ')})` };
807
+ if (dep.kind === 'task' && !phaseTaskIds[pi].has(String(dep.id))) {
808
+ if (seenIds.has(String(dep.id))) {
809
+ const targetPhase = String(dep.id).split('.')[0];
810
+ return { error: true, message: `Task ${taskId}: requires references task "${dep.id}" in a different phase (cross-phase task dependencies are not supported — use a phase dependency {kind: "phase", id: ${targetPhase}} for cross-phase ordering)` };
811
+ }
812
+ return { error: true, message: `Task ${taskId}: requires references non-existent task "${dep.id}" (same-phase IDs: ${[...phaseTaskIds[pi]].join(', ') || 'none'})` };
797
813
  }
798
814
  if (dep.kind === 'phase') {
799
815
  const phaseId = Number(dep.id);
package/src/server.js CHANGED
@@ -259,6 +259,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
259
259
  }));
260
260
 
261
261
  async function dispatchToolCall(name, args) {
262
+ // Normalize missing args (MCP clients may omit `arguments`, sending null/undefined)
263
+ // so every tool receives an object and returns a clean validation error rather than
264
+ // leaking a raw destructuring TypeError.
265
+ args = args || {};
262
266
  let result;
263
267
  switch (name) {
264
268
  case 'health': {
@@ -220,7 +220,11 @@ async function resumeExecutingTask(state, basePath) {
220
220
  const reviewPassed = phase.phase_review?.status === 'accepted'
221
221
  || phase.phase_handoff?.required_reviews_passed === true
222
222
  || allAccepted;
223
- if (allAccepted && reviewPassed) {
223
+ // Zero-task phase (empty milestone) is complete-able once its vacuous review passed —
224
+ // otherwise it can never satisfy the all-tasks-accepted condition and stalls.
225
+ const emptyPhaseDone = phase.todo.length === 0
226
+ && (phase.phase_review?.status === 'accepted' || phase.phase_handoff?.required_reviews_passed === true);
227
+ if ((allAccepted && reviewPassed) || emptyPhaseDone) {
224
228
  // Auto-advance phase lifecycle to 'reviewing' if currently 'active'
225
229
  // (mirrors trigger_review path at line 480-482)
226
230
  if (phase.lifecycle === 'active') {
@@ -60,7 +60,7 @@ export async function computePlanHashes(basePath) {
60
60
  /**
61
61
  * Initialize a new GSD project: creates .gsd/, state.json, plan.md, phases/
62
62
  */
63
- export async function init({ project, phases, research, force = false, basePath = process.cwd() }) {
63
+ export async function init({ project, phases, research, force = false, basePath = process.cwd() } = {}) {
64
64
  if (!project || typeof project !== 'string') {
65
65
  return { error: true, code: ERROR_CODES.INVALID_INPUT, message: 'project must be a non-empty string' };
66
66
  }
@@ -808,6 +808,12 @@ function _applyPatchOp(state, op) {
808
808
  const { phase_id, task } = op;
809
809
  if (typeof phase_id !== 'number') return { error: true, message: 'phase_id must be a number' };
810
810
  if (!task || typeof task.name !== 'string' || task.name.length === 0) return { error: true, message: 'task.name must be a non-empty string' };
811
+ // Guard task.index: a non-positive-integer index produces malformed IDs like
812
+ // "1.0"/"1.1.5"/"1.x"; "1.x" then poisons Math.max() and cascades to "1.NaN".
813
+ if (task.index !== undefined && task.index !== null
814
+ && (!Number.isInteger(task.index) || task.index < 1)) {
815
+ return { error: true, message: `task.index must be a positive integer (got ${JSON.stringify(task.index)})` };
816
+ }
811
817
 
812
818
  const phase = state.phases.find(p => p.id === phase_id);
813
819
  if (!phase) return { error: true, message: `Phase ${phase_id} not found` };
@@ -903,6 +909,11 @@ function _applyPatchOp(state, op) {
903
909
  const existing = new Set(taskMap.keys());
904
910
  const ordered = new Set(order);
905
911
 
912
+ // Reject duplicate IDs explicitly — otherwise a set-size match can slip a
913
+ // duplicate into todo and surface later as a misleading "circular dependency" error.
914
+ if (ordered.size !== order.length) {
915
+ return { error: true, message: `order contains duplicate task IDs for phase ${phase_id}` };
916
+ }
906
917
  // Must contain exactly the same task IDs
907
918
  if (ordered.size !== existing.size || ![...ordered].every(id => existing.has(id))) {
908
919
  return { error: true, message: `order must contain exactly the same task IDs as phase ${phase_id}` };
@@ -36,9 +36,14 @@ export function selectRunnableTask(phase, state, { maxRetry = DEFAULT_MAX_RETRY
36
36
  if (!phase || !Array.isArray(phase.todo)) {
37
37
  return { error: true, message: 'Phase todo must be an array' };
38
38
  }
39
- // D-4: Zero-task phase — immediately trigger review so phase can advance
39
+ // D-4: Zero-task phase — trigger review once so the phase can advance, but only
40
+ // until that (vacuous) review passes. Returning trigger_review unconditionally
41
+ // makes the resume loop oscillate forever (review accepts → resume → review again)
42
+ // because an empty phase never reaches the normal all-accepted completion path.
40
43
  if (phase.todo.length === 0) {
41
- return { mode: 'trigger_review' };
44
+ const reviewPassed = phase.phase_review?.status === 'accepted'
45
+ || phase.phase_handoff?.required_reviews_passed === true;
46
+ return reviewPassed ? { task: undefined, diagnostics: [] } : { mode: 'trigger_review' };
42
47
  }
43
48
 
44
49
  const runnableTasks = [];