@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
@@ -4,14 +4,14 @@
4
4
  "version": 1,
5
5
  "metadata": {
6
6
  "component": {
7
- "bom-ref": "pkg:cargo/planu-core@5.3.2",
7
+ "bom-ref": "pkg:cargo/planu-core@5.3.3",
8
8
  "type": "library",
9
9
  "name": "planu-core",
10
- "version": "5.3.2",
10
+ "version": "5.3.3",
11
11
  "hashes": [
12
12
  {
13
13
  "alg": "SHA-256",
14
- "content": "d44f03ecaed34427ca6ed682ce695f432d25eac3366fc5e779cfa78fe39d504b"
14
+ "content": "0e4804010d87a4089e8fd59af5d0b61ba6a572ff20f9f55b1caa904af861f030"
15
15
  }
16
16
  ]
17
17
  }
@@ -1106,7 +1106,7 @@
1106
1106
  "dependsOn": []
1107
1107
  },
1108
1108
  {
1109
- "ref": "pkg:cargo/planu-core@5.3.2",
1109
+ "ref": "pkg:cargo/planu-core@5.3.3",
1110
1110
  "dependsOn": [
1111
1111
  "pkg:cargo/core-foundation@0.10.1",
1112
1112
  "pkg:cargo/hmac@0.12.1",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
- "cliVersion": "5.3.2",
4
- "engineVersion": "5.3.2",
5
- "buildId": "1-5.3.2-aarch64-pc-windows-msvc",
3
+ "cliVersion": "5.3.3",
4
+ "engineVersion": "5.3.3",
5
+ "buildId": "1-5.3.3-aarch64-pc-windows-msvc",
6
6
  "protocolAbi": 1,
7
7
  "capabilityAbi": 2,
8
8
  "nodeApiAbi": 4,
@@ -16,15 +16,15 @@
16
16
  "platform": "win32",
17
17
  "arch": "arm64",
18
18
  "libc": null,
19
- "artifactSha256": "9a10e393e1b0aad7856de5973ea8efb018513bcb4e2c47ddb013be9bda7e3bda",
19
+ "artifactSha256": "5f4ce40b9f76cfaa1fdc25ba8ce765cc3fd7fc0b38bbe10ce8a5b3ba5b0dcc1a",
20
20
  "sbom": {
21
21
  "format": "CycloneDX-1.5",
22
22
  "path": "planu-core.win32-arm64-msvc.node.sbom.json",
23
- "sha256": "38f38493f6013f50daff7808dcbbab81133b141bce61cba430e7a07fe9a89fc2"
23
+ "sha256": "f054e6818584024d2f3acb36f2ebfa044546d72d203ecf06b758bfb4c2a8dcfc"
24
24
  },
25
25
  "provenance": {
26
26
  "builder": "scripts/build-rust-local.sh",
27
- "sourceCommit": "30226657c1e66411b6ff233c2ea82e47573d533a",
27
+ "sourceCommit": "30eda7a2ba11647e835b0079c526bed77f189c8e",
28
28
  "sourceTreeDirty": false,
29
29
  "sourceDateEpoch": 1,
30
30
  "rustToolchain": "rustc 1.95.0 (59807616e 2026-04-14)",
@@ -33,6 +33,6 @@
33
33
  "signature": {
34
34
  "algorithm": "Ed25519",
35
35
  "keyId": "e2ee765cb5ad9fbdc433ece332bce26d746d5e350b3acde0721a4c8c6337ff7b",
36
- "value": "ktHCgTSn2Br8NhGf6Pjz5fCP38a9sGluOqs617pwRXymMiUBu2GNjvyp7mLqk2gY/L8KAbzvF6FHoqEVDzb9AA=="
36
+ "value": "i9Ts/DMWmNUhyq+YNId6aNr4Y+5GGr8CPIe0fXqtBLcXoqGlxgxq83PMgvnFX0fG0pa8MI+Fv7fbamFQ4/SWBg=="
37
37
  }
38
38
  }
@@ -4,14 +4,14 @@
4
4
  "version": 1,
5
5
  "metadata": {
6
6
  "component": {
7
- "bom-ref": "pkg:cargo/planu-core@5.3.2",
7
+ "bom-ref": "pkg:cargo/planu-core@5.3.3",
8
8
  "type": "library",
9
9
  "name": "planu-core",
10
- "version": "5.3.2",
10
+ "version": "5.3.3",
11
11
  "hashes": [
12
12
  {
13
13
  "alg": "SHA-256",
14
- "content": "9a10e393e1b0aad7856de5973ea8efb018513bcb4e2c47ddb013be9bda7e3bda"
14
+ "content": "5f4ce40b9f76cfaa1fdc25ba8ce765cc3fd7fc0b38bbe10ce8a5b3ba5b0dcc1a"
15
15
  }
16
16
  ]
17
17
  }
@@ -1106,7 +1106,7 @@
1106
1106
  "dependsOn": []
1107
1107
  },
1108
1108
  {
1109
- "ref": "pkg:cargo/planu-core@5.3.2",
1109
+ "ref": "pkg:cargo/planu-core@5.3.3",
1110
1110
  "dependsOn": [
1111
1111
  "pkg:cargo/core-foundation@0.10.1",
1112
1112
  "pkg:cargo/hmac@0.12.1",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
- "cliVersion": "5.3.2",
4
- "engineVersion": "5.3.2",
5
- "buildId": "1-5.3.2-x86_64-pc-windows-msvc",
3
+ "cliVersion": "5.3.3",
4
+ "engineVersion": "5.3.3",
5
+ "buildId": "1-5.3.3-x86_64-pc-windows-msvc",
6
6
  "protocolAbi": 1,
7
7
  "capabilityAbi": 2,
8
8
  "nodeApiAbi": 4,
@@ -16,15 +16,15 @@
16
16
  "platform": "win32",
17
17
  "arch": "x64",
18
18
  "libc": null,
19
- "artifactSha256": "4a41383ea29c9dd64a668143e206b4af1859ba6f35b79fdeb7ea8f812113811c",
19
+ "artifactSha256": "5da0cd500b980f5a7295886298ea9176905bc38190fee2a7eae3359ec59513e9",
20
20
  "sbom": {
21
21
  "format": "CycloneDX-1.5",
22
22
  "path": "planu-core.win32-x64-msvc.node.sbom.json",
23
- "sha256": "f96dbf4fd6d9cab1aa9b90057325829de42e018dfa2bf4220c78c9bbfab0f9c5"
23
+ "sha256": "a8f35671f2c924ba92ed04e0a9513262c9005451c04423c11e13a9d8371164bc"
24
24
  },
25
25
  "provenance": {
26
26
  "builder": "scripts/build-rust-local.sh",
27
- "sourceCommit": "30226657c1e66411b6ff233c2ea82e47573d533a",
27
+ "sourceCommit": "30eda7a2ba11647e835b0079c526bed77f189c8e",
28
28
  "sourceTreeDirty": false,
29
29
  "sourceDateEpoch": 1,
30
30
  "rustToolchain": "rustc 1.95.0 (59807616e 2026-04-14)",
@@ -33,6 +33,6 @@
33
33
  "signature": {
34
34
  "algorithm": "Ed25519",
35
35
  "keyId": "e2ee765cb5ad9fbdc433ece332bce26d746d5e350b3acde0721a4c8c6337ff7b",
36
- "value": "vZ49hewESnK5idVecQPyBFUD+GPcbfa0ihzAKkxuqBSsyVEWNFGaGU4c/rzvXD6D3SKnApFEM3vZ9D2uCfSjDA=="
36
+ "value": "yN+zsORULiFYmazE/LZm1vp2+Nd3NpEqE/1IKcjW3OHz/Nqk9jN1Hj1CR78aDGcJHiMSMobSiBdCviN9dvM4CA=="
37
37
  }
38
38
  }
@@ -4,14 +4,14 @@
4
4
  "version": 1,
5
5
  "metadata": {
6
6
  "component": {
7
- "bom-ref": "pkg:cargo/planu-core@5.3.2",
7
+ "bom-ref": "pkg:cargo/planu-core@5.3.3",
8
8
  "type": "library",
9
9
  "name": "planu-core",
10
- "version": "5.3.2",
10
+ "version": "5.3.3",
11
11
  "hashes": [
12
12
  {
13
13
  "alg": "SHA-256",
14
- "content": "4a41383ea29c9dd64a668143e206b4af1859ba6f35b79fdeb7ea8f812113811c"
14
+ "content": "5da0cd500b980f5a7295886298ea9176905bc38190fee2a7eae3359ec59513e9"
15
15
  }
16
16
  ]
17
17
  }
@@ -1106,7 +1106,7 @@
1106
1106
  "dependsOn": []
1107
1107
  },
1108
1108
  {
1109
- "ref": "pkg:cargo/planu-core@5.3.2",
1109
+ "ref": "pkg:cargo/planu-core@5.3.3",
1110
1110
  "dependsOn": [
1111
1111
  "pkg:cargo/core-foundation@0.10.1",
1112
1112
  "pkg:cargo/hmac@0.12.1",
@@ -100,6 +100,20 @@ const runtimePolicySchema = z
100
100
  minimumHeartbeatMs: z.number().int().positive(),
101
101
  })
102
102
  .strict(),
103
+ outbox: z
104
+ .object({
105
+ sliceRecordBudget: z.number().int().positive(),
106
+ sliceTimeBudgetMs: z.number().int().positive(),
107
+ backoffBaseMs: z.number().int().positive(),
108
+ backoffCapMs: z.number().int().positive(),
109
+ maxAttempts: z.number().int().positive(),
110
+ diagnosticMaxLength: z.number().int().positive(),
111
+ continuationDelayMs: z.number().int().positive(),
112
+ })
113
+ .strict()
114
+ .refine((value) => value.backoffBaseMs <= value.backoffCapMs, {
115
+ message: 'backoffBaseMs must not exceed backoffCapMs',
116
+ }),
103
117
  })
104
118
  .strict();
105
119
  const require = createRequire(import.meta.url);
package/dist/index.js CHANGED
@@ -126,7 +126,9 @@ async function setupPostHandshake(handshakeGate, officialToolNames) {
126
126
  })();
127
127
  // SPEC-334: Sync planu/ YAML frontmatter status → data/ store (non-blocking)
128
128
  scheduleStartupStateRecovery(startupSync, recoverPendingPostCommitTasksAtStartup);
129
- await startSpecCreatedOutboxConsumer().catch((err) => {
129
+ // SPEC-1271: bounded, fair, durable spec.created outbox recovery — fire-and-forget so a
130
+ // large or poisoned backlog never delays post-handshake completion (mirrors serve.ts).
131
+ void startSpecCreatedOutboxConsumer().catch((err) => {
130
132
  console.error('[Planu] Startup spec.created outbox recovery failed:', err);
131
133
  });
132
134
  try {
@@ -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') {
@@ -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.