@planu/cli 5.3.2 → 5.3.3

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 (39) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/config/runtime-policy.json +9 -0
  3. package/dist/engine/autopilot/complete-loop.js +4 -1
  4. package/dist/engine/execution/outbox-worker.d.ts +8 -0
  5. package/dist/engine/execution/outbox-worker.js +102 -31
  6. package/dist/engine/planu-core.darwin-arm64.node.manifest.json +7 -7
  7. package/dist/engine/planu-core.darwin-arm64.node.sbom.json +4 -4
  8. package/dist/engine/planu-core.darwin-x64.node.manifest.json +7 -7
  9. package/dist/engine/planu-core.darwin-x64.node.sbom.json +4 -4
  10. package/dist/engine/planu-core.linux-arm64-gnu.node.manifest.json +7 -7
  11. package/dist/engine/planu-core.linux-arm64-gnu.node.sbom.json +4 -4
  12. package/dist/engine/planu-core.linux-arm64-musl.node.manifest.json +7 -7
  13. package/dist/engine/planu-core.linux-arm64-musl.node.sbom.json +4 -4
  14. package/dist/engine/planu-core.linux-x64-gnu.node.manifest.json +7 -7
  15. package/dist/engine/planu-core.linux-x64-gnu.node.sbom.json +4 -4
  16. package/dist/engine/planu-core.linux-x64-musl.node.manifest.json +7 -7
  17. package/dist/engine/planu-core.linux-x64-musl.node.sbom.json +4 -4
  18. package/dist/engine/planu-core.win32-arm64-msvc.node.manifest.json +7 -7
  19. package/dist/engine/planu-core.win32-arm64-msvc.node.sbom.json +4 -4
  20. package/dist/engine/planu-core.win32-x64-msvc.node.manifest.json +7 -7
  21. package/dist/engine/planu-core.win32-x64-msvc.node.sbom.json +4 -4
  22. package/dist/engine/runtime-policy.js +14 -0
  23. package/dist/index.js +3 -1
  24. package/dist/storage/runtime-db.d.ts +23 -1
  25. package/dist/storage/runtime-db.js +79 -9
  26. package/dist/tools/create-spec/post-creation.js +7 -2
  27. package/dist/tools/create-spec/spec-created-outbox-consumer.d.ts +2 -0
  28. package/dist/tools/create-spec/spec-created-outbox-consumer.js +94 -6
  29. package/dist/tools/create-spec-helpers.d.ts +2 -2
  30. package/dist/tools/create-spec-helpers.js +2 -2
  31. package/dist/tools/git/branch-ops.d.ts +7 -3
  32. package/dist/tools/git/branch-ops.js +68 -18
  33. package/dist/tools/update-status-actions.js +4 -2
  34. package/dist/types/git.d.ts +4 -0
  35. package/dist/types/outbox-worker.d.ts +2 -0
  36. package/dist/types/runtime-policy.d.ts +9 -0
  37. package/package.json +9 -9
  38. package/planu-native.json +1 -1
  39. package/planu-plugin.json +1 -1
@@ -19,10 +19,11 @@ function randomSuffix() {
19
19
  .toString(16)
20
20
  .padStart(8, '0');
21
21
  }
22
- export async function handleCreateBranch(projectId, specId, config, resolvedProjectPath) {
22
+ export async function handleCreateBranch(projectId, specId, config, resolvedProjectPath, options) {
23
23
  if (!specId) {
24
24
  return compactError('❌ Error: specId is required for create-branch action');
25
25
  }
26
+ const mutateCheckout = options?.mutateCheckout ?? true;
26
27
  const suppliedPath = resolvedProjectPath?.trim();
27
28
  const projectPath = suppliedPath && suppliedPath.length > 0 ? suppliedPath : await resolveProjectPath(projectId);
28
29
  const spec = await specStore.getSpec(projectId, specId);
@@ -40,11 +41,18 @@ export async function handleCreateBranch(projectId, specId, config, resolvedProj
40
41
  try {
41
42
  const { stdout } = await git(projectPath, ['branch', '--list', branchName]);
42
43
  if (stdout.trim().length > 0) {
43
- await git(projectPath, ['checkout', branchName]);
44
+ if (mutateCheckout) {
45
+ await git(projectPath, ['checkout', branchName]);
46
+ }
47
+ // Record the branch even when it already exists — otherwise callers keep re-entering
48
+ // this path and a background checkout is retried forever.
49
+ await specStore.updateSpec(projectId, specId, { gitBranch: branchName });
44
50
  const result = {
45
51
  action: 'create-branch',
46
52
  branchName,
47
- message: `Switched to existing branch '${branchName}'`,
53
+ message: mutateCheckout
54
+ ? `Switched to existing branch '${branchName}'`
55
+ : `Branch '${branchName}' already exists (checkout left untouched)`,
48
56
  };
49
57
  return compactResult(formatKeyValue(result));
50
58
  }
@@ -52,7 +60,14 @@ export async function handleCreateBranch(projectId, specId, config, resolvedProj
52
60
  catch {
53
61
  // branch list failed — proceed with creation
54
62
  }
55
- await git(projectPath, ['checkout', '-b', branchName]);
63
+ if (mutateCheckout) {
64
+ await git(projectPath, ['checkout', '-b', branchName]);
65
+ }
66
+ else {
67
+ // SPEC-1396: automatic caller — cut the branch from the resolved base without moving HEAD.
68
+ const base = await resolveBaseBranch(projectPath, mergeConfig(config).baseBranch);
69
+ await git(projectPath, base ? ['branch', branchName, base] : ['branch', branchName]);
70
+ }
56
71
  await specStore.updateSpec(projectId, specId, { gitBranch: branchName });
57
72
  const result = {
58
73
  action: 'create-branch',
@@ -117,14 +132,19 @@ async function hasDirtyState(projectPath) {
117
132
  * Auto-setup: detect git workflow, sync base branch, create feature branch.
118
133
  * Combines detectGitFlowType + resolveBaseBranch + fetch/pull + create-branch
119
134
  * into a single atomic operation for spec creation.
135
+ *
136
+ * SPEC-1396: `mutateCheckout: false` creates the branch ref without touching HEAD
137
+ * or the working tree. Automatic callers (outbox recovery, cascades) MUST use it —
138
+ * a background checkout silently moves the shared checkout onto a foreign branch.
120
139
  */
121
- export async function handleAutoSetup(projectId, specId, config) {
140
+ export async function handleAutoSetup(projectId, specId, config, options) {
122
141
  if (!specId) {
123
142
  return compactError('❌ Error: specId is required for auto-setup action');
124
143
  }
144
+ const mutateCheckout = options?.mutateCheckout ?? true;
125
145
  const projectPath = await resolveProjectPath(projectId);
126
146
  // Check for dirty state — abort if uncommitted changes exist
127
- if (await hasDirtyState(projectPath)) {
147
+ if (mutateCheckout && (await hasDirtyState(projectPath))) {
128
148
  return compactError('❌ Error: Working tree has uncommitted changes. Commit or stash them before auto-setup.\nSuggestion: git stash or git commit your changes first');
129
149
  }
130
150
  const spec = await specStore.getSpec(projectId, specId);
@@ -139,16 +159,39 @@ export async function handleAutoSetup(projectId, specId, config) {
139
159
  if (!baseBranch) {
140
160
  return compactError('❌ Error: No base branch found (develop/main/master). Is this a git repository?');
141
161
  }
142
- // 3. Switch to base branch and sync with remote
143
- await git(projectPath, ['checkout', baseBranch]);
162
+ // 3. Sync base branch with remote (switching only when the caller owns the checkout)
144
163
  let syncedBase = false;
145
- try {
146
- await git(projectPath, ['fetch', 'origin', baseBranch]);
147
- await git(projectPath, ['pull', '--ff-only', 'origin', baseBranch]);
148
- syncedBase = true;
164
+ let baseRef = baseBranch;
165
+ if (mutateCheckout) {
166
+ await git(projectPath, ['checkout', baseBranch]);
167
+ try {
168
+ await git(projectPath, ['fetch', 'origin', baseBranch]);
169
+ await git(projectPath, ['pull', '--ff-only', 'origin', baseBranch]);
170
+ syncedBase = true;
171
+ }
172
+ catch {
173
+ // No remote or fetch failed — continue with local base (offline-friendly)
174
+ }
149
175
  }
150
- catch {
151
- // No remote or fetch failed — continue with local base (offline-friendly)
176
+ else {
177
+ try {
178
+ // Fast-forwards the local base ref without touching HEAD or the working tree.
179
+ await git(projectPath, ['fetch', 'origin', `${baseBranch}:${baseBranch}`]);
180
+ syncedBase = true;
181
+ }
182
+ catch {
183
+ // git refuses the refspec when the base is the branch currently checked out. Fetch the
184
+ // remote-tracking ref instead and cut the feature branch from it.
185
+ try {
186
+ await git(projectPath, ['fetch', 'origin', baseBranch]);
187
+ await git(projectPath, ['rev-parse', '--verify', `origin/${baseBranch}`]);
188
+ baseRef = `origin/${baseBranch}`;
189
+ syncedBase = true;
190
+ }
191
+ catch {
192
+ // No remote or offline — use the local base as-is.
193
+ }
194
+ }
152
195
  }
153
196
  // 4. Create feature branch from the updated base
154
197
  const prefixes = mergedConfig.branchPrefix ?? DEFAULT_BRANCH_PREFIXES;
@@ -160,14 +203,19 @@ export async function handleAutoSetup(projectId, specId, config) {
160
203
  try {
161
204
  const { stdout } = await git(projectPath, ['branch', '--list', branchName]);
162
205
  if (stdout.trim().length > 0) {
163
- await git(projectPath, ['checkout', branchName]);
206
+ if (mutateCheckout) {
207
+ await git(projectPath, ['checkout', branchName]);
208
+ }
209
+ await specStore.updateSpec(projectId, specId, { gitBranch: branchName });
164
210
  const result = {
165
211
  action: 'auto-setup',
166
212
  gitFlowType,
167
213
  baseBranch,
168
214
  newBranch: branchName,
169
215
  syncedBase,
170
- message: `Switched to existing branch '${branchName}' (${gitFlowType} workflow detected)`,
216
+ message: mutateCheckout
217
+ ? `Switched to existing branch '${branchName}' (${gitFlowType} workflow detected)`
218
+ : `Branch '${branchName}' already exists (checkout left untouched)`,
171
219
  };
172
220
  return compactResult(formatKeyValue(result));
173
221
  }
@@ -175,7 +223,7 @@ export async function handleAutoSetup(projectId, specId, config) {
175
223
  catch {
176
224
  // branch list failed — proceed with creation
177
225
  }
178
- await git(projectPath, ['checkout', '-b', branchName]);
226
+ await git(projectPath, mutateCheckout ? ['checkout', '-b', branchName] : ['branch', branchName, baseRef]);
179
227
  await specStore.updateSpec(projectId, specId, { gitBranch: branchName });
180
228
  const result = {
181
229
  action: 'auto-setup',
@@ -183,7 +231,9 @@ export async function handleAutoSetup(projectId, specId, config) {
183
231
  baseBranch,
184
232
  newBranch: branchName,
185
233
  syncedBase,
186
- message: `Created branch '${branchName}' from '${baseBranch}' (${gitFlowType} workflow detected)`,
234
+ message: mutateCheckout
235
+ ? `Created branch '${branchName}' from '${baseBranch}' (${gitFlowType} workflow detected)`
236
+ : `Created branch '${branchName}' from '${baseBranch}' without switching the checkout`,
187
237
  };
188
238
  return compactResult(formatKeyValue(result));
189
239
  }
@@ -54,11 +54,13 @@ export async function runImplementingSideEffects(projectId, specId, transitionPr
54
54
  let branchResult;
55
55
  try {
56
56
  const { handleCreateBranch } = await import('./git/branch-ops.js');
57
- branchResult = await withAudit(projectPath, 'update_status(implementing)', 'handleCreateBranch', () => handleCreateBranch(projectId, specId, undefined, projectPath), (r) => ({ isError: r.isError }));
57
+ branchResult = await withAudit(projectPath, 'update_status(implementing)', 'handleCreateBranch', () => handleCreateBranch(projectId, specId, undefined, projectPath, { mutateCheckout: false }), (r) => ({ isError: r.isError }));
58
58
  }
59
59
  catch {
60
60
  const { handleCreateBranch } = await import('./git/branch-ops.js');
61
- branchResult = await handleCreateBranch(projectId, specId, undefined, projectPath);
61
+ branchResult = await handleCreateBranch(projectId, specId, undefined, projectPath, {
62
+ mutateCheckout: false,
63
+ });
62
64
  }
63
65
  if (!branchResult.isError) {
64
66
  try {
@@ -144,6 +144,10 @@ export interface GitSetupResult {
144
144
  data: Record<string, unknown>;
145
145
  branch: string;
146
146
  }
147
+ /** SPEC-1396: automatic callers set `mutateCheckout: false` so the shared checkout never moves. */
148
+ export interface GitAutoSetupOptions {
149
+ mutateCheckout?: boolean;
150
+ }
147
151
  export interface AutoSetupResult {
148
152
  action: 'auto-setup';
149
153
  gitFlowType: GitFlowType;
@@ -12,6 +12,8 @@ export interface OutboxFenceRecord {
12
12
  readonly state: 'leased' | 'failed' | 'dead-letter' | 'delivered';
13
13
  readonly attempts?: number;
14
14
  readonly lastError?: string;
15
+ /** Durable retry deadline (ISO timestamp). Only set for `failed` fences; optional for schemaVersion 1 compatibility. */
16
+ readonly nextAttemptAt?: string;
15
17
  }
16
18
  export declare const SPEC_CREATED_POST_COMMIT_TASKS: readonly ['git-setup', 'analysis', 'quality-score', 'duplicate-detection', 'complexity-advice', 'prior-decisions', 'suggestions', 'challenge-readiness'];
17
19
  export type SpecCreatedPostCommitTask = (typeof SPEC_CREATED_POST_COMMIT_TASKS)[number];
@@ -47,5 +47,14 @@ export interface RuntimePolicy {
47
47
  maximumRememberedTransitions: number;
48
48
  minimumHeartbeatMs: number;
49
49
  };
50
+ outbox: {
51
+ sliceRecordBudget: number;
52
+ sliceTimeBudgetMs: number;
53
+ backoffBaseMs: number;
54
+ backoffCapMs: number;
55
+ maxAttempts: number;
56
+ diagnosticMaxLength: number;
57
+ continuationDelayMs: number;
58
+ };
50
59
  }
51
60
  //# sourceMappingURL=runtime-policy.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "5.3.2",
3
+ "version": "5.3.3",
4
4
  "description": "Planu — MCP Server for Spec Driven Development with native Rust acceleration for hot paths. Cross-platform (Linux/macOS/Windows, x64/arm64, glibc/musl).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -35,14 +35,14 @@
35
35
  "packageName": "@planu/core"
36
36
  },
37
37
  "optionalDependencies": {
38
- "@planu/core-darwin-arm64": "5.3.2",
39
- "@planu/core-darwin-x64": "5.3.2",
40
- "@planu/core-linux-arm64-gnu": "5.3.2",
41
- "@planu/core-linux-arm64-musl": "5.3.2",
42
- "@planu/core-linux-x64-gnu": "5.3.2",
43
- "@planu/core-linux-x64-musl": "5.3.2",
44
- "@planu/core-win32-arm64-msvc": "5.3.2",
45
- "@planu/core-win32-x64-msvc": "5.3.2"
38
+ "@planu/core-darwin-arm64": "5.3.3",
39
+ "@planu/core-darwin-x64": "5.3.3",
40
+ "@planu/core-linux-arm64-gnu": "5.3.3",
41
+ "@planu/core-linux-arm64-musl": "5.3.3",
42
+ "@planu/core-linux-x64-gnu": "5.3.3",
43
+ "@planu/core-linux-x64-musl": "5.3.3",
44
+ "@planu/core-win32-arm64-msvc": "5.3.3",
45
+ "@planu/core-win32-x64-msvc": "5.3.3"
46
46
  },
47
47
  "engines": {
48
48
  "node": ">=24.0.0"
package/planu-native.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dev.planu.native",
3
3
  "displayName": "Planu Native Lightweight Surface",
4
- "version": "5.3.2",
4
+ "version": "5.3.3",
5
5
  "packageName": "@planu/cli",
6
6
  "modes": {
7
7
  "lightweight": {
package/planu-plugin.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dev.planu.cli",
3
3
  "displayName": "Planu — Spec Driven Development",
4
4
  "description": "Manage software specs, estimations, and autonomous SDD workflows. Language-agnostic MCP server for Claude Code.",
5
- "version": "5.3.2",
5
+ "version": "5.3.3",
6
6
  "icon": "assets/plugin/icon.svg",
7
7
  "command": ["npx", "@planu/cli@latest"],
8
8
  "packageName": "@planu/cli",