@planu/cli 5.3.2 → 5.3.4

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 (58) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/config/runtime-policy.json +9 -0
  3. package/dist/engine/autopilot/action-registry.js +20 -2
  4. package/dist/engine/autopilot/complete-loop.js +4 -1
  5. package/dist/engine/constitution/sdd-rules-registry.js +8 -4
  6. package/dist/engine/drift/violation-resolver.js +30 -8
  7. package/dist/engine/evidence-gates/evidence-skeletons.js +9 -4
  8. package/dist/engine/execution/outbox-worker.d.ts +8 -0
  9. package/dist/engine/execution/outbox-worker.js +102 -31
  10. package/dist/engine/handoff-packager.js +10 -3
  11. package/dist/engine/implementation-contract/common.d.ts +13 -1
  12. package/dist/engine/implementation-contract/common.js +19 -3
  13. package/dist/engine/implementation-contract/evaluator.js +116 -35
  14. package/dist/engine/implementation-contract/renderer.js +62 -31
  15. package/dist/engine/planu-core.darwin-arm64.node.manifest.json +7 -7
  16. package/dist/engine/planu-core.darwin-arm64.node.sbom.json +4 -4
  17. package/dist/engine/planu-core.darwin-x64.node.manifest.json +7 -7
  18. package/dist/engine/planu-core.darwin-x64.node.sbom.json +4 -4
  19. package/dist/engine/planu-core.linux-arm64-gnu.node.manifest.json +7 -7
  20. package/dist/engine/planu-core.linux-arm64-gnu.node.sbom.json +4 -4
  21. package/dist/engine/planu-core.linux-arm64-musl.node.manifest.json +7 -7
  22. package/dist/engine/planu-core.linux-arm64-musl.node.sbom.json +4 -4
  23. package/dist/engine/planu-core.linux-x64-gnu.node.manifest.json +7 -7
  24. package/dist/engine/planu-core.linux-x64-gnu.node.sbom.json +4 -4
  25. package/dist/engine/planu-core.linux-x64-musl.node.manifest.json +7 -7
  26. package/dist/engine/planu-core.linux-x64-musl.node.sbom.json +4 -4
  27. package/dist/engine/planu-core.win32-arm64-msvc.node.manifest.json +7 -7
  28. package/dist/engine/planu-core.win32-arm64-msvc.node.sbom.json +4 -4
  29. package/dist/engine/planu-core.win32-x64-msvc.node.manifest.json +7 -7
  30. package/dist/engine/planu-core.win32-x64-msvc.node.sbom.json +4 -4
  31. package/dist/engine/runtime-policy.js +14 -0
  32. package/dist/engine/spec-format/unified-spec-builder.js +32 -8
  33. package/dist/engine/spec-generator/fallback-generator.js +5 -3
  34. package/dist/engine/spec-quality/generic-output-gate.d.ts +7 -1
  35. package/dist/engine/spec-quality/generic-output-gate.js +199 -8
  36. package/dist/engine/spec-quality-scorer.js +9 -5
  37. package/dist/engine/workers/handlers/auto-drift.js +22 -8
  38. package/dist/index.js +3 -1
  39. package/dist/storage/runtime-db.d.ts +23 -1
  40. package/dist/storage/runtime-db.js +79 -9
  41. package/dist/tools/create-spec/post-creation.js +7 -2
  42. package/dist/tools/create-spec/spec-created-outbox-consumer.d.ts +2 -0
  43. package/dist/tools/create-spec/spec-created-outbox-consumer.js +94 -6
  44. package/dist/tools/create-spec-helpers.d.ts +2 -2
  45. package/dist/tools/create-spec-helpers.js +2 -2
  46. package/dist/tools/create-spec.js +5 -0
  47. package/dist/tools/git/branch-ops.d.ts +7 -3
  48. package/dist/tools/git/branch-ops.js +68 -18
  49. package/dist/tools/update-status/evidence-gate.js +10 -17
  50. package/dist/tools/update-status/transition-guard.js +26 -14
  51. package/dist/tools/update-status-actions.js +4 -2
  52. package/dist/types/git.d.ts +4 -0
  53. package/dist/types/outbox-worker.d.ts +2 -0
  54. package/dist/types/runtime-policy.d.ts +9 -0
  55. package/dist/types/spec-quality.d.ts +6 -1
  56. package/package.json +9 -9
  57. package/planu-native.json +1 -1
  58. package/planu-plugin.json +1 -1
@@ -167,8 +167,30 @@ export declare class RuntimeDatabase implements Disposable {
167
167
  readonly pendingOnly?: boolean;
168
168
  readonly limit?: number;
169
169
  readonly topics?: readonly string[];
170
+ /**
171
+ * When set, applies fence-based eligibility filtering (excludes dead-lettered
172
+ * records and records whose durable `nextAttemptAt` is still in the future)
173
+ * BEFORE the LIMIT clause, so ineligible records cannot head-of-line block
174
+ * an eligible record behind them in the same scope.
175
+ */
176
+ readonly eligibleAt?: string;
177
+ /** Only rows with `id > afterId`, for paginating past ineligible head records. */
178
+ readonly afterId?: number;
170
179
  }): readonly RuntimeOutboxRecord[];
171
- listPendingOutboxScopes(): readonly RuntimeOutboxScope[];
180
+ /**
181
+ * Counts pending records that would be eligible for delivery right now, without
182
+ * materializing them. Used to report an exact `skipped` count when a bounded
183
+ * slice stops early (budget or time), rather than inferring it from the LIMIT.
184
+ */
185
+ countEligibleOutbox(projectId: string, workspaceId: string, options: {
186
+ readonly topics?: readonly string[];
187
+ readonly eligibleAt: string;
188
+ readonly afterId?: number;
189
+ }): number;
190
+ listPendingOutboxScopes(options?: {
191
+ readonly topics?: readonly string[];
192
+ readonly eligibleAt?: string;
193
+ }): readonly RuntimeOutboxScope[];
172
194
  markOutboxDelivered(projectId: string, workspaceId: string, id: number, deliveredAt?: string): boolean;
173
195
  appendAudit(input: {
174
196
  readonly projectId: string;
@@ -6,6 +6,36 @@ import { parseValidationReceiptPendingCheckpoint, } from '../types/durable-valid
6
6
  const CURRENT_SCHEMA_VERSION = 4;
7
7
  const DEFAULT_BUSY_TIMEOUT_MS = 5_000;
8
8
  const DEFAULT_MAX_CONNECTIONS = 4;
9
+ /**
10
+ * Joins each outbox row to its fence record (stored as a `mutation` runtime record
11
+ * keyed `outbox-fence:<id>`) so eligibility can be evaluated in SQL before LIMIT.
12
+ */
13
+ const OUTBOX_FENCE_JOIN = `
14
+ LEFT JOIN runtime_records fence
15
+ ON fence.project_id = o.project_id AND fence.workspace_id = o.workspace_id
16
+ AND fence.kind = 'mutation' AND fence.record_key = 'outbox-fence:' || o.id`;
17
+ /**
18
+ * Excludes dead-lettered records and records whose durable `nextAttemptAt` retry
19
+ * deadline is still in the future. Records with no fence, or a fence without
20
+ * `nextAttemptAt` (schemaVersion 1 legacy), remain eligible.
21
+ *
22
+ * A fence with invalid JSON, or a `nextAttemptAt` that is not ISO-8601-shaped,
23
+ * is deliberately left ELIGIBLE rather than filtered: a non-ISO string compares
24
+ * lexicographically against the `?` bound and can hide the record from every
25
+ * future slice, so malformed fences must surface to claim()/parseFence as a
26
+ * `Corrupt` failure instead of vanishing silently.
27
+ */
28
+ const OUTBOX_ELIGIBILITY_CLAUSE = `
29
+ AND (fence.value_json IS NULL
30
+ OR NOT json_valid(fence.value_json)
31
+ OR json_extract(fence.value_json, '$.state') IS NULL
32
+ OR json_extract(fence.value_json, '$.state') != 'dead-letter')
33
+ AND (fence.value_json IS NULL
34
+ OR NOT json_valid(fence.value_json)
35
+ OR json_extract(fence.value_json, '$.nextAttemptAt') IS NULL
36
+ OR json_extract(fence.value_json, '$.nextAttemptAt')
37
+ NOT GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'
38
+ OR json_extract(fence.value_json, '$.nextAttemptAt') <= ?)`;
9
39
  const openConnections = new Map();
10
40
  let integrityCheckedOpens = 0;
11
41
  let integrityBypassedOpens = 0;
@@ -746,20 +776,60 @@ export class RuntimeDatabase {
746
776
  requireIdentifier(topic, 'outbox topic');
747
777
  }
748
778
  const topicsClause = topics.length > 0 ? ` AND topic IN (${topics.map(() => '?').join(', ')})` : '';
779
+ const afterClause = options.afterId !== undefined ? ' AND o.id > ?' : '';
780
+ const afterParams = options.afterId !== undefined ? [options.afterId] : [];
781
+ const eligibilityJoin = options.eligibleAt !== undefined ? OUTBOX_FENCE_JOIN : '';
782
+ const eligibilityClause = options.eligibleAt !== undefined ? OUTBOX_ELIGIBILITY_CLAUSE : '';
783
+ const eligibilityParams = options.eligibleAt !== undefined ? [options.eligibleAt] : [];
749
784
  const rows = this.database
750
- .prepare(`SELECT id, project_id, workspace_id, topic, payload_json, created_at, delivered_at
751
- FROM outbox
752
- WHERE project_id = ? AND workspace_id = ?${pendingClause}${topicsClause}
753
- ORDER BY id LIMIT ?`)
754
- .all(projectId, workspaceId, ...topics, limit);
785
+ .prepare(`SELECT o.id AS id, o.project_id AS project_id, o.workspace_id AS workspace_id,
786
+ o.topic AS topic, o.payload_json AS payload_json,
787
+ o.created_at AS created_at, o.delivered_at AS delivered_at
788
+ FROM outbox o${eligibilityJoin}
789
+ WHERE o.project_id = ? AND o.workspace_id = ?${pendingClause}${topicsClause}${afterClause}${eligibilityClause}
790
+ ORDER BY o.id LIMIT ?`)
791
+ .all(projectId, workspaceId, ...topics, ...afterParams, ...eligibilityParams, limit);
755
792
  return rows.map(mapOutboxRow);
756
793
  }
757
- listPendingOutboxScopes() {
794
+ /**
795
+ * Counts pending records that would be eligible for delivery right now, without
796
+ * materializing them. Used to report an exact `skipped` count when a bounded
797
+ * slice stops early (budget or time), rather than inferring it from the LIMIT.
798
+ */
799
+ countEligibleOutbox(projectId, workspaceId, options) {
800
+ this.assertOpen();
801
+ requireIdentifier(projectId, 'projectId');
802
+ requireIdentifier(workspaceId, 'workspaceId');
803
+ const topics = options.topics ?? [];
804
+ for (const topic of topics) {
805
+ requireIdentifier(topic, 'outbox topic');
806
+ }
807
+ const topicsClause = topics.length > 0 ? ` AND o.topic IN (${topics.map(() => '?').join(', ')})` : '';
808
+ const afterClause = options.afterId !== undefined ? ' AND o.id > ?' : '';
809
+ const afterParams = options.afterId !== undefined ? [options.afterId] : [];
810
+ const row = this.database
811
+ .prepare(`SELECT COUNT(*) AS count
812
+ FROM outbox o${OUTBOX_FENCE_JOIN}
813
+ WHERE o.project_id = ? AND o.workspace_id = ? AND o.delivered_at IS NULL${topicsClause}${afterClause}${OUTBOX_ELIGIBILITY_CLAUSE}`)
814
+ .get(projectId, workspaceId, ...topics, ...afterParams, options.eligibleAt);
815
+ return row.count;
816
+ }
817
+ listPendingOutboxScopes(options = {}) {
758
818
  this.assertOpen();
819
+ const topics = options.topics ?? [];
820
+ for (const topic of topics) {
821
+ requireIdentifier(topic, 'outbox topic');
822
+ }
823
+ const topicsClause = topics.length > 0 ? ` AND o.topic IN (${topics.map(() => '?').join(', ')})` : '';
824
+ const eligibilityJoin = options.eligibleAt !== undefined ? OUTBOX_FENCE_JOIN : '';
825
+ const eligibilityClause = options.eligibleAt !== undefined ? OUTBOX_ELIGIBILITY_CLAUSE : '';
826
+ const eligibilityParams = options.eligibleAt !== undefined ? [options.eligibleAt] : [];
759
827
  return this.database
760
- .prepare(`SELECT DISTINCT project_id AS projectId, workspace_id AS workspaceId
761
- FROM outbox WHERE delivered_at IS NULL ORDER BY project_id, workspace_id`)
762
- .all();
828
+ .prepare(`SELECT DISTINCT o.project_id AS projectId, o.workspace_id AS workspaceId
829
+ FROM outbox o${eligibilityJoin}
830
+ WHERE o.delivered_at IS NULL${topicsClause}${eligibilityClause}
831
+ ORDER BY o.project_id, o.workspace_id`)
832
+ .all(...topics, ...eligibilityParams);
763
833
  }
764
834
  markOutboxDelivered(projectId, workspaceId, id, deliveredAt = new Date().toISOString()) {
765
835
  this.assertOpen();
@@ -8,6 +8,8 @@ import { incrementSpecCount } from '../../engine/autopilot/state-updater.js';
8
8
  import { join } from 'node:path';
9
9
  import { hashProjectPath, projectDataDir } from '../../storage/base-store.js';
10
10
  import { appendAutopilotLogEntry } from '../../storage/autopilot-log-store.js';
11
+ import { getRuntimePolicy } from '../../engine/runtime-policy.js';
12
+ import { redactText } from '../../security/redactor.js';
11
13
  import { analyzeContextPreflight, buildTokenWasteReport, loadTokenWastePolicy, recommendRelevantTools, toolsFromPolicyGroups, } from '../../engine/token-optimizer/index.js';
12
14
  import { atomicWriteFile } from '../../engine/safety/atomic-write-file.js';
13
15
  const ASYNC_ANALYSIS_HOOK = 'create-spec-async-analysis';
@@ -52,7 +54,9 @@ export async function buildInitialTokenWasteMetadata(specId, projectPath, descri
52
54
  }
53
55
  /** Auto-setup git branch (non-blocking). Returns branch info or undefined. */
54
56
  export async function setupGitBranch(projectId, specId) {
55
- const result = await tryAutoSetupGit(projectId, specId);
57
+ // SPEC-1396: runs from durable outbox delivery (including startup recovery) — never
58
+ // move the shared checkout, or replaying a backlog walks HEAD through every spec branch.
59
+ const result = await tryAutoSetupGit(projectId, specId, { mutateCheckout: false });
56
60
  /* v8 ignore start -- requires real git repo */
57
61
  if (result) {
58
62
  return { branch: result.branch, data: result.data };
@@ -254,7 +258,8 @@ export async function runAutopilotAnalysis(specId, projectPath, description) {
254
258
  });
255
259
  }
256
260
  catch (err) {
257
- const error = err instanceof Error ? err.message : String(err);
261
+ const raw = err instanceof Error ? err.message : String(err);
262
+ const error = redactText(raw).slice(0, getRuntimePolicy().outbox.diagnosticMaxLength);
258
263
  await appendAutopilotLogEntry(projectId, {
259
264
  specId,
260
265
  hookName: ASYNC_ANALYSIS_HOOK,
@@ -11,4 +11,6 @@ export declare class SpecCreatedOutboxConsumer {
11
11
  }
12
12
  export declare function drainSpecCreatedOutbox(databasePath?: string): Promise<void>;
13
13
  export declare function startSpecCreatedOutboxConsumer(): Promise<void>;
14
+ /** Test-only: clear scheduler state so fixtures do not leak timers across tests. */
15
+ export declare function resetSpecCreatedOutboxSchedulerForTests(): void;
14
16
  //# sourceMappingURL=spec-created-outbox-consumer.d.ts.map
@@ -6,6 +6,13 @@ import { specStore } from '../../storage/index.js';
6
6
  import { runAutopilotAnalysis, setupGitBranch } from './post-creation.js';
7
7
  import { SPEC_CREATED_POST_COMMIT_TASKS, } from '../../types/outbox-worker.js';
8
8
  import { TransactionalOutboxWorker } from '../../engine/execution/outbox-worker.js';
9
+ import { getRuntimePolicy } from '../../engine/runtime-policy.js';
10
+ import { redactText } from '../../security/redactor.js';
11
+ const SPEC_CREATED_TOPICS = ['spec.created'];
12
+ /** Redact and bound a raw thrown diagnostic before it reaches durable storage. */
13
+ function sanitizeDiagnostic(message) {
14
+ return redactText(message).slice(0, getRuntimePolicy().outbox.diagnosticMaxLength);
15
+ }
9
16
  export { SPEC_CREATED_POST_COMMIT_TASKS } from '../../types/outbox-worker.js';
10
17
  const RESPONSE_ONLY_REASON = 'Advisory task is intentionally terminal because it has no durable post-commit side effect.';
11
18
  function isObject(value) {
@@ -123,6 +130,7 @@ export class SpecCreatedOutboxConsumer {
123
130
  });
124
131
  }
125
132
  catch (error) {
133
+ const raw = error instanceof Error ? error.message : String(error);
126
134
  this.database.appendAudit({
127
135
  projectId: payload.projectId,
128
136
  workspaceId: 'default',
@@ -130,7 +138,7 @@ export class SpecCreatedOutboxConsumer {
130
138
  payload: {
131
139
  deliveryKey: context.deliveryKey,
132
140
  task,
133
- error: error instanceof Error ? error.message : String(error),
141
+ error: sanitizeDiagnostic(raw),
134
142
  },
135
143
  });
136
144
  throw error;
@@ -140,6 +148,8 @@ export class SpecCreatedOutboxConsumer {
140
148
  }
141
149
  let interval;
142
150
  let activeDrain;
151
+ let continuationTimer;
152
+ let rotationCursor = 0;
143
153
  export function drainSpecCreatedOutbox(databasePath = resolveStorageLayout().runtimeDatabase) {
144
154
  if (activeDrain) {
145
155
  return activeDrain;
@@ -147,11 +157,7 @@ export function drainSpecCreatedOutbox(databasePath = resolveStorageLayout().run
147
157
  activeDrain = (async () => {
148
158
  const database = new RuntimeDatabase({ path: databasePath });
149
159
  try {
150
- const consumer = new SpecCreatedOutboxConsumer(database);
151
- for (const scope of database.listPendingOutboxScopes()) {
152
- const worker = new TransactionalOutboxWorker(database, scope.projectId, scope.workspaceId, `spec-created-${process.pid}-${randomUUID()}`);
153
- await worker.drain((context) => consumer.deliver(context), { topics: ['spec.created'] });
154
- }
160
+ await drainBoundedSlice(database, databasePath);
155
161
  }
156
162
  finally {
157
163
  database.close();
@@ -161,6 +167,75 @@ export function drainSpecCreatedOutbox(databasePath = resolveStorageLayout().run
161
167
  });
162
168
  return activeDrain;
163
169
  }
170
+ /**
171
+ * Processes one bounded, fair slice of the spec.created backlog: round-robins
172
+ * pending scopes one eligible record at a time (SQL already excludes dead-lettered
173
+ * and not-yet-due records, so a poison record cannot head-of-line block a healthy
174
+ * scope) until the record or elapsed-time budget is exhausted. If work remains,
175
+ * schedules a single unref()'d continuation instead of blocking the caller.
176
+ */
177
+ async function drainBoundedSlice(database, databasePath) {
178
+ const policy = getRuntimePolicy().outbox;
179
+ const consumer = new SpecCreatedOutboxConsumer(database);
180
+ const discovered = database.listPendingOutboxScopes({
181
+ topics: [...SPEC_CREATED_TOPICS],
182
+ eligibleAt: new Date().toISOString(),
183
+ });
184
+ if (discovered.length === 0) {
185
+ return;
186
+ }
187
+ const offset = rotationCursor % discovered.length;
188
+ const activeScopes = [...discovered.slice(offset), ...discovered.slice(0, offset)];
189
+ const startedAtMs = Date.now();
190
+ let recordBudget = policy.sliceRecordBudget;
191
+ let turnsTaken = 0;
192
+ let index = 0;
193
+ while (activeScopes.length > 0 && recordBudget > 0) {
194
+ if (Date.now() - startedAtMs > policy.sliceTimeBudgetMs) {
195
+ break;
196
+ }
197
+ const position = index % activeScopes.length;
198
+ const scope = activeScopes[position];
199
+ if (!scope) {
200
+ break;
201
+ }
202
+ const worker = new TransactionalOutboxWorker(database, scope.projectId, scope.workspaceId, `spec-created-${process.pid}-${randomUUID()}`);
203
+ const result = await worker.drain((context) => consumer.deliver(context), {
204
+ topics: [...SPEC_CREATED_TOPICS],
205
+ limit: 1,
206
+ });
207
+ turnsTaken += 1;
208
+ // Only actually-processed records consume the pass budget; ineligible-only
209
+ // turns (foreign lease, self-healed delivered fence) are free.
210
+ recordBudget -= result.delivered + result.failed;
211
+ if (result.exhausted) {
212
+ // No eligible record remains in this scope right now — drop it from this
213
+ // pass's rotation instead of spinning on it. A failed-but-not-exhausted
214
+ // scope stays in rotation so a poison record cannot block healthy work
215
+ // behind it.
216
+ activeScopes.splice(position, 1);
217
+ continue;
218
+ }
219
+ index += 1;
220
+ }
221
+ rotationCursor += turnsTaken;
222
+ if (activeScopes.length > 0) {
223
+ scheduleContinuation(databasePath);
224
+ }
225
+ }
226
+ function scheduleContinuation(databasePath) {
227
+ if (continuationTimer) {
228
+ return;
229
+ }
230
+ const policy = getRuntimePolicy().outbox;
231
+ continuationTimer = setTimeout(() => {
232
+ continuationTimer = undefined;
233
+ void drainSpecCreatedOutbox(databasePath).catch((error) => {
234
+ console.error('[Planu] spec.created outbox continuation failed:', error);
235
+ });
236
+ }, policy.continuationDelayMs);
237
+ continuationTimer.unref();
238
+ }
164
239
  export async function startSpecCreatedOutboxConsumer() {
165
240
  await drainSpecCreatedOutbox();
166
241
  if (interval) {
@@ -173,4 +248,17 @@ export async function startSpecCreatedOutboxConsumer() {
173
248
  }, 30_000);
174
249
  interval.unref();
175
250
  }
251
+ /** Test-only: clear scheduler state so fixtures do not leak timers across tests. */
252
+ export function resetSpecCreatedOutboxSchedulerForTests() {
253
+ if (interval) {
254
+ clearInterval(interval);
255
+ interval = undefined;
256
+ }
257
+ if (continuationTimer) {
258
+ clearTimeout(continuationTimer);
259
+ continuationTimer = undefined;
260
+ }
261
+ activeDrain = undefined;
262
+ rotationCursor = 0;
263
+ }
176
264
  //# sourceMappingURL=spec-created-outbox-consumer.js.map
@@ -1,9 +1,9 @@
1
- import type { GitSetupResult } from '../types/index.js';
1
+ import type { GitSetupResult, GitAutoSetupOptions } from '../types/index.js';
2
2
  export declare function slugify(text: string): string;
3
3
  export declare function generateSpecId(existingSpecs: {
4
4
  id: string;
5
5
  }[]): string;
6
6
  export declare function generateBranchName(specId: string, slug: string, type: string): string;
7
7
  /** Try auto-setup git branch. Returns result or undefined on failure. */
8
- export declare function tryAutoSetupGit(projectId: string, specId: string): Promise<GitSetupResult | undefined>;
8
+ export declare function tryAutoSetupGit(projectId: string, specId: string, options?: GitAutoSetupOptions): Promise<GitSetupResult | undefined>;
9
9
  //# sourceMappingURL=create-spec-helpers.d.ts.map
@@ -29,10 +29,10 @@ export function generateBranchName(specId, slug, type) {
29
29
  return `${BRANCH_PREFIXES[type] ?? 'feat'}/${specId.toLowerCase()}-${slug}`;
30
30
  }
31
31
  /** Try auto-setup git branch. Returns result or undefined on failure. */
32
- export async function tryAutoSetupGit(projectId, specId) {
32
+ export async function tryAutoSetupGit(projectId, specId, options) {
33
33
  /* v8 ignore start -- requires real git repo with branches */
34
34
  try {
35
- const r = await handleAutoSetup(projectId, specId);
35
+ const r = await handleAutoSetup(projectId, specId, undefined, options);
36
36
  if (!r.isError && r.content[0]?.type === 'text') {
37
37
  const data = JSON.parse(r.content[0].text);
38
38
  if (typeof data.newBranch !== 'string') {
@@ -1030,6 +1030,11 @@ async function prepareCreateSpecCandidate(initialParams, server) {
1030
1030
  },
1031
1031
  }));
1032
1032
  spec.generation = generatedSpec.generation;
1033
+ // SPEC-1406 (DEFECT 4, round 2): FallbackGenerator no longer emits an unconditional
1034
+ // warning, so qualityWarnings only needs the non-empty guard now — the redundant
1035
+ // constant-string filter was removed from create-spec.ts, fallback-generator.ts and
1036
+ // opus-generator.ts (dead code, reverted) so the literal doesn't have to stay
1037
+ // byte-identical across three files.
1033
1038
  spec.qualityWarnings =
1034
1039
  generatedSpec.qualityWarnings.length > 0 ? generatedSpec.qualityWarnings : undefined;
1035
1040
  const baseCriteria = extractCriteria(generatedSpec.specBody).map((criterion) => criterion.text);
@@ -1,12 +1,16 @@
1
- import type { ToolResult, GitConfig } from '../../types/index.js';
2
- export declare function handleCreateBranch(projectId: string, specId: string | undefined, config?: GitConfig, resolvedProjectPath?: string): Promise<ToolResult>;
1
+ import type { ToolResult, GitConfig, GitAutoSetupOptions } from '../../types/index.js';
2
+ export declare function handleCreateBranch(projectId: string, specId: string | undefined, config?: GitConfig, resolvedProjectPath?: string, options?: GitAutoSetupOptions): Promise<ToolResult>;
3
3
  export declare function handleCheckBranch(projectId: string, _specId: string | undefined, config?: GitConfig): Promise<ToolResult>;
4
4
  /**
5
5
  * Auto-setup: detect git workflow, sync base branch, create feature branch.
6
6
  * Combines detectGitFlowType + resolveBaseBranch + fetch/pull + create-branch
7
7
  * into a single atomic operation for spec creation.
8
+ *
9
+ * SPEC-1396: `mutateCheckout: false` creates the branch ref without touching HEAD
10
+ * or the working tree. Automatic callers (outbox recovery, cascades) MUST use it —
11
+ * a background checkout silently moves the shared checkout onto a foreign branch.
8
12
  */
9
- export declare function handleAutoSetup(projectId: string, specId: string | undefined, config?: GitConfig): Promise<ToolResult>;
13
+ export declare function handleAutoSetup(projectId: string, specId: string | undefined, config?: GitConfig, options?: GitAutoSetupOptions): Promise<ToolResult>;
10
14
  /**
11
15
  * Returns a structured worktree-start payload with commands/paths so the caller
12
16
  * knows exactly how to spin up an isolated Claude Code session.
@@ -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
  }
@@ -68,7 +68,7 @@ import { captureValidationFreshnessLease } from '../../engine/validation/validat
68
68
  import { computeDurableValidationBindings, toValidationReceiptBindings, } from '../../engine/validation/durable-validation.js';
69
69
  import { checkReconciliationFreshness } from '../../engine/evidence-gates/reconciliation-freshness.js';
70
70
  import { extractCanonicalFileOwnership } from '../../engine/handoff-packager.js';
71
- import { generateDiscoverySkeleton, generateTaskPlanSkeleton, writeEvidenceSkeleton, } from '../../engine/evidence-gates/evidence-skeletons.js';
71
+ import { generateTaskPlanSkeleton, writeEvidenceSkeleton, } from '../../engine/evidence-gates/evidence-skeletons.js';
72
72
  import { extractSection } from '../../engine/spec-format/markdown-sections.js';
73
73
  const CANONICAL_SCOPE_PATH = /(?:src|tests|scripts|website)\/[\w/.@-]+\.\w+/g;
74
74
  /** SPEC-1356: repo-relative paths mentioned in the spec's Problem/Technical prose also count
@@ -187,28 +187,21 @@ function isArtifactAbsent(artifacts, label) {
187
187
  return !artifacts.invalidArtifacts.some((entry) => entry.startsWith(label));
188
188
  }
189
189
  /**
190
- * SPEC-1356: self-heal missing (not malformed) discovery/task-plan handoff evidence by
191
- * generating a schema-valid skeleton and persisting it, instead of blocking the transition
192
- * on a manual authoring step. Malformed existing artifacts still fail the gate as before.
190
+ * SPEC-1356: self-heal missing (not malformed) task-plan handoff evidence by generating a
191
+ * schema-valid skeleton and persisting it, instead of blocking the transition on a manual
192
+ * authoring step. Malformed existing artifacts still fail the gate as before.
193
+ *
194
+ * SPEC-1406 (DEFECT 3): discovery evidence for `approved` is intentionally NOT self-healed —
195
+ * writing a skeleton here made the blocking discovery gate self-certify by fabricating the
196
+ * evidence it was supposed to require. `checkLifecycleEvidenceGate` (lifecycle-gate.ts) reports
197
+ * `discovery_missing` with the specific missing fields named when discovery is absent, and that
198
+ * is the intended, honest outcome.
193
199
  */
194
200
  async function autofillMissingEvidenceSkeletons(args) {
195
201
  if (args.spec.scope === 'trivial') {
196
202
  return args.artifacts;
197
203
  }
198
204
  let artifacts = args.artifacts;
199
- if (args.transition === 'approved' &&
200
- !artifacts.discovery &&
201
- isArtifactAbsent(artifacts, 'Discovery evidence')) {
202
- const skeleton = generateDiscoverySkeleton({ spec: args.spec, body: args.body });
203
- await writeEvidenceSkeleton({
204
- projectId: args.projectId,
205
- specId: args.specId,
206
- filename: 'discovery.json',
207
- artifact: skeleton,
208
- });
209
- console.warn('[planu:evidence-skeleton]', { specId: args.specId, filename: 'discovery.json' });
210
- artifacts = { ...artifacts, discovery: skeleton };
211
- }
212
205
  if (args.transition === 'implementing' &&
213
206
  !artifacts.taskPlan &&
214
207
  isArtifactAbsent(artifacts, 'Task plan evidence')) {