@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
package/CHANGELOG.md CHANGED
@@ -1,3 +1,40 @@
1
+ ## [5.3.4] - 2026-08-06
2
+
3
+ ### Bug Fixes
4
+ - fix(deps): override js-yaml to patched versions for CVE-2026-59870
5
+ - fix(spec-format): keep rendered criteria BDD-executable after canonical parsing
6
+ - fix(spec-format): resolve rendered criteria through the canonical parser (SPEC-1410)
7
+ - fix(spec-quality): render and count every acceptance criterion (SPEC-1410)
8
+ - fix(planu): point SPEC-1405 verification map at the real test filename
9
+ - fix(spec-quality): stop shipping template filler and self-certifying spec sections (SPEC-1406)
10
+ - fix(drift): make follow-up spec id deterministic per parent spec (SPEC-1405)
11
+ - fix(drift): emit one drift event per project instead of one per spec (SPEC-1405)
12
+
13
+ ### Chores
14
+ - chore(planu): SPEC-1405 and SPEC-1406 done with debate evidence
15
+ - chore(planu): SPEC-1405 and SPEC-1406 implementing with packaged handoffs
16
+ - chore(planu): approve SPEC-1405 and SPEC-1406 with debate evidence, file SPEC-1410..1411
17
+ - chore(planu): file SPEC-1401..1409 from deep flow audit
18
+ - chore(planu): SPEC-1396 done with debate evidence, file SPEC-1397..1400
19
+
20
+
21
+ ## [5.3.3] - 2026-08-06
22
+
23
+ ### Bug Fixes
24
+ - fix(release): tolerate empty ff-behind mirror list under set -u
25
+ - fix(outbox): return a typed failure from delivery attempts for the reliability gate
26
+ - fix(git): close SPEC-1396 debate findings and record review evidence
27
+ - fix(git): never move the shared checkout from automatic paths (SPEC-1396)
28
+ - fix(outbox): address SPEC-1271 debate review findings
29
+ - fix(outbox): bounded fair durable spec.created recovery without blocking startup (SPEC-1271)
30
+
31
+ ### Chores
32
+ - chore(planu): file SPEC-1396 git-safety dogfood bug
33
+ - chore(planu): session checkpoint after SPEC-1271 cycle
34
+ - chore(planu): SPEC-1271 done with debate evidence, file SPEC-1395
35
+ - chore(planu): SPEC-1271 implementing with structured work plan, file SPEC-1394
36
+
37
+
1
38
  ## [5.3.2] - 2026-08-06
2
39
 
3
40
  ### Bug Fixes
@@ -51,5 +51,14 @@
51
51
  "leaseMultiplier": 3,
52
52
  "maximumRememberedTransitions": 1000,
53
53
  "minimumHeartbeatMs": 1000
54
+ },
55
+ "outbox": {
56
+ "sliceRecordBudget": 25,
57
+ "sliceTimeBudgetMs": 5000,
58
+ "backoffBaseMs": 30000,
59
+ "backoffCapMs": 1800000,
60
+ "maxAttempts": 3,
61
+ "diagnosticMaxLength": 500,
62
+ "continuationDelayMs": 250
54
63
  }
55
64
  }
@@ -219,7 +219,17 @@ const ACTION_HANDLERS = {
219
219
  resolve_drift_violations: wrapHandler('resolve_drift_violations', async (ctx) => {
220
220
  const { resolveDriftViolations } = await import('../drift/violation-resolver.js');
221
221
  const result = (await resolveDriftViolations(ctx.projectPath, ctx.projectId));
222
- const resolved = safeCount(result, 'resolvedCount');
222
+ // DriftResolutionResult exposes `resolved` as an array (see types/health.ts);
223
+ // safeCount() returns its length. There is no `resolvedCount` field.
224
+ const resolved = safeCount(result, 'resolved');
225
+ const manualReview = safeCount(result, 'manualReview');
226
+ if (manualReview > 0) {
227
+ return {
228
+ success: false,
229
+ summary: `Resolved ${resolved} drift violations, ${manualReview} need manual review`,
230
+ durationMs: 0,
231
+ };
232
+ }
223
233
  return {
224
234
  success: true,
225
235
  summary: `Resolved ${resolved} drift violations`,
@@ -228,13 +238,21 @@ const ACTION_HANDLERS = {
228
238
  }),
229
239
  log_lesson: wrapHandler('log_lesson', async (ctx) => {
230
240
  const { addLesson } = await import('../../storage/lessons-store.js');
241
+ // drift:detected events (auto-drift.ts) are project-wide and carry no
242
+ // ctx.specId; fall back to the payload's specIds so the lesson stays
243
+ // attributed to the drifted specs instead of going generic.
244
+ const payloadSpecIds = ctx.payload?.specIds;
245
+ const attributedSpecIds = Array.isArray(payloadSpecIds)
246
+ ? payloadSpecIds.filter((id) => typeof id === 'string')
247
+ : [];
248
+ const attribution = ctx.specId ?? (attributedSpecIds.length > 0 ? attributedSpecIds.join(', ') : undefined);
231
249
  const lesson = await addLesson(ctx.projectPath, {
232
250
  projectId: ctx.projectId,
233
251
  specId: ctx.specId,
234
252
  category: 'process',
235
253
  severity: 'low',
236
254
  title: 'Drift detected',
237
- description: `Spec drift was detected${ctx.specId !== undefined ? ` for ${ctx.specId}` : ''}.`,
255
+ description: `Spec drift was detected${attribution !== undefined ? ` for ${attribution}` : ''}.`,
238
256
  prevention: 'Review spec file alignment with implementation after each commit.',
239
257
  tags: ['drift', 'autopilot'],
240
258
  });
@@ -47,7 +47,10 @@ async function runWithProgress(projectPath, projectId, specId, action, fn) {
47
47
  */
48
48
  async function doAutoCreateBranch(projectId, specId) {
49
49
  const { handleCreateBranch } = await import('../../tools/git/branch-ops.js');
50
- const result = await handleCreateBranch(projectId, specId);
50
+ // SPEC-1396: cascade side effect — create the ref, never move the shared checkout.
51
+ const result = await handleCreateBranch(projectId, specId, undefined, undefined, {
52
+ mutateCheckout: false,
53
+ });
51
54
  if (result.isError === true) {
52
55
  /* v8 ignore next */
53
56
  const msg = result.content[0]?.type === 'text' ? result.content[0].text : 'branch creation failed';
@@ -1,4 +1,4 @@
1
- import { checkGenericSpecOutput } from '../spec-quality/generic-output-gate.js';
1
+ import { checkGenericSpecOutput, STRUCTURAL_INTERPOLATION_KIND, } from '../spec-quality/generic-output-gate.js';
2
2
  import { checkGroundedSpecContract } from '../spec-grounding/contract.js';
3
3
  export const DEFAULT_SDD_CONSTITUTION_RULES = [
4
4
  {
@@ -81,7 +81,11 @@ export function evaluateSddConstitutionRules(args) {
81
81
  }
82
82
  if (rules.some((rule) => rule.id === 'sdd.no-generic-output')) {
83
83
  const generic = checkGenericSpecOutput(args.content);
84
- violations.push(...generic.issues.map((issue) => violation('sdd.no-generic-output', issue.reason, [issue.phrase], rules)));
84
+ violations.push(...generic.issues.map((issue) => violation('sdd.no-generic-output', issue.reason, [issue.phrase], rules,
85
+ // SPEC-1406: structural-interpolation findings are advisory here — the blocking
86
+ // enforcement point for this new detector is create_spec, not the constitution
87
+ // gate, so ~1100 already-persisted specs stay transitionable.
88
+ issue.kind === STRUCTURAL_INTERPOLATION_KIND ? 'advisory' : undefined)));
85
89
  }
86
90
  return violations;
87
91
  }
@@ -92,11 +96,11 @@ export function renderSddConstitutionRulesForHost() {
92
96
  ...enabled.map((rule) => `- ${rule.id} (${rule.level}): ${rule.description} Next action: ${rule.nextAction}`),
93
97
  ].join('\n');
94
98
  }
95
- function violation(ruleId, message, evidence, rules) {
99
+ function violation(ruleId, message, evidence, rules, levelOverride) {
96
100
  const rule = rules.find((candidate) => candidate.id === ruleId);
97
101
  return {
98
102
  ruleId,
99
- level: rule?.level ?? 'blocking',
103
+ level: levelOverride ?? rule?.level ?? 'blocking',
100
104
  message,
101
105
  evidence,
102
106
  nextAction: rule?.nextAction ?? 'Fix the violated SDD constitution rule.',
@@ -1,8 +1,9 @@
1
1
  // engine/drift/violation-resolver.ts — Semi-automatic drift violation resolution (SPEC-408)
2
- import { mkdir, writeFile, access } from 'node:fs/promises';
2
+ import { mkdir, access } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { specStore } from '../../storage/index.js';
5
5
  import { DriftCacheStore } from '../../storage/drift-cache-store.js';
6
+ import { atomicWriteFile } from '../safety/atomic-write-file.js';
6
7
  // ---------------------------------------------------------------------------
7
8
  // Helpers
8
9
  // ---------------------------------------------------------------------------
@@ -16,10 +17,22 @@ async function specDirExists(specDir) {
16
17
  }
17
18
  }
18
19
  async function createFollowUpSpec(projectPath, parentSpecId, reason, dryRun) {
19
- const timestamp = Date.now();
20
- const specId = `SPEC-followup-${parentSpecId.toLowerCase()}-${timestamp}`;
20
+ // SPEC-1405 (AC2): the id is deterministic per parent spec, not timestamp-keyed.
21
+ // A timestamped id made every drift pass create a brand-new follow-up directory,
22
+ // so repeated triggers spammed duplicates instead of converging on one record.
23
+ const specId = `SPEC-followup-${parentSpecId.toLowerCase()}`;
21
24
  if (!dryRun) {
22
25
  const specDir = join(projectPath, 'planu', 'specs', specId);
26
+ const specPath = join(specDir, 'spec.md');
27
+ // Already created by an earlier pass — leave it alone. Rewriting would clobber
28
+ // any edits made to the follow-up spec since it was raised.
29
+ try {
30
+ await access(specPath);
31
+ return specId;
32
+ }
33
+ catch {
34
+ // Not present yet — fall through and create it.
35
+ }
23
36
  await mkdir(specDir, { recursive: true });
24
37
  const now = new Date().toISOString();
25
38
  const specContent = [
@@ -42,7 +55,7 @@ async function createFollowUpSpec(projectPath, parentSpecId, reason, dryRun) {
42
55
  '- [ ] Run detect_drift to confirm score returns to 0',
43
56
  '',
44
57
  ].join('\n');
45
- await writeFile(join(specDir, 'spec.md'), specContent, 'utf-8');
58
+ await atomicWriteFile(specPath, specContent);
46
59
  }
47
60
  return specId;
48
61
  }
@@ -88,12 +101,21 @@ export async function resolveDriftViolations(projectPath, projectId, dryRun = fa
88
101
  continue;
89
102
  }
90
103
  if (spec.status === 'done') {
91
- // Code diverged from a completed spec — create follow-up
104
+ // Code diverged from a completed spec — create follow-up.
105
+ // The write is atomic (tmp + fsync + rename); if it throws (e.g. the
106
+ // target spec.md is frozen), count the violation as unresolved instead
107
+ // of letting the error escape into the fire-and-forget autopilot handler.
92
108
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
93
109
  const reason = `Drift detected (score: ${entry.driftScore.toFixed(2)}) in files: ${(entry.driftedFiles ?? []).join(', ') || 'unknown'}`;
94
- const followUpId = await createFollowUpSpec(projectPath, spec.id, reason, dryRun);
95
- followUpSpecsCreated.push(followUpId);
96
- resolved.push(buildDoneButDivergedResolution(spec.id));
110
+ try {
111
+ const followUpId = await createFollowUpSpec(projectPath, spec.id, reason, dryRun);
112
+ followUpSpecsCreated.push(followUpId);
113
+ resolved.push(buildDoneButDivergedResolution(spec.id));
114
+ }
115
+ catch (err) {
116
+ const msg = err instanceof Error ? err.message : String(err);
117
+ manualReview.push(`${spec.id}: follow-up spec write failed — ${msg}`);
118
+ }
97
119
  }
98
120
  else if (spec.status === 'implementing') {
99
121
  // In-progress spec with code ahead — annotate but don't block
@@ -1,7 +1,12 @@
1
- // evidence-skeletons.ts — SPEC-1356: auto-generate schema-valid discovery/task-plan skeletons
2
- // when the underlying handoff evidence file is missing, so lifecycle gates self-heal instead
3
- // of blocking on a manual authoring step. Skeletons still require review; they only unblock
4
- // the transition, they do not fabricate confidence.
1
+ // evidence-skeletons.ts — SPEC-1356: auto-generate a schema-valid task-plan skeleton when the
2
+ // underlying handoff evidence file is missing, so the `implementing` lifecycle gate self-heals
3
+ // instead of blocking on a manual authoring step.
4
+ //
5
+ // SPEC-1406 (DEFECT 3): `generateDiscoverySkeleton` is kept here (still exercised directly by
6
+ // its own tests) but is no longer wired into the `approved` gate's autofill path — writing a
7
+ // fabricated discovery skeleton made the blocking discovery-evidence gate self-certify. The
8
+ // `approved` transition now reports `discovery_missing` with the specific missing fields
9
+ // instead of silently manufacturing the evidence it was supposed to require.
5
10
  import { join } from 'node:path';
6
11
  import { extractListItems, extractSection } from '../spec-format/markdown-sections.js';
7
12
  import { parseCriterionGroundingRecords } from '../spec-grounding/contract.js';
@@ -18,12 +18,20 @@ export declare class TransactionalOutboxWorker {
18
18
  readonly clock?: () => Date;
19
19
  readonly limit?: number;
20
20
  readonly topics?: readonly string[];
21
+ readonly timeBudgetMs?: number;
21
22
  }): Promise<{
22
23
  readonly delivered: number;
24
+ readonly failed: number;
23
25
  readonly skipped: number;
26
+ readonly ineligible: number;
27
+ /** True when no eligible pending record remains in this scope right now. */
28
+ readonly exhausted: boolean;
24
29
  }>;
30
+ private attemptDelivery;
25
31
  private claim;
26
32
  private fail;
33
+ /** Deterministic exponential backoff with a validated cap; no random jitter. */
34
+ private computeNextAttemptAt;
27
35
  private complete;
28
36
  private deliveryKey;
29
37
  private fenceKey;
@@ -1,20 +1,32 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { BoundaryFailure } from '../../errors/error-taxonomy.js';
3
+ import { getRuntimePolicy } from '../runtime-policy.js';
3
4
  function parseFence(value) {
4
5
  if (!value || typeof value !== 'object') {
5
6
  return undefined;
6
7
  }
7
8
  const fence = value;
9
+ const nextAttemptAtValid = fence.nextAttemptAt === undefined ||
10
+ (typeof fence.nextAttemptAt === 'string' && Number.isFinite(Date.parse(fence.nextAttemptAt)));
8
11
  if (fence.schemaVersion !== 1 ||
9
12
  typeof fence.workerId !== 'string' ||
10
13
  !Number.isSafeInteger(fence.fencingToken) ||
11
14
  typeof fence.leaseExpiresAt !== 'string' ||
12
15
  typeof fence.state !== 'string' ||
13
- !['leased', 'failed', 'dead-letter', 'delivered'].includes(fence.state)) {
16
+ !['leased', 'failed', 'dead-letter', 'delivered'].includes(fence.state) ||
17
+ !nextAttemptAtValid) {
14
18
  throw new BoundaryFailure('Corrupt', 'outbox fence read', 'Quarantine the invalid fence record before retrying delivery.');
15
19
  }
16
20
  return fence;
17
21
  }
22
+ /**
23
+ * Upper bound on how many rows a single internal page fetch may request while
24
+ * paging past ineligible head records (foreign lease, delivered self-heal) that
25
+ * consume no record budget. 1000 is the existing hard ceiling `listOutbox`
26
+ * already enforces, so this reuses that ceiling rather than introducing a new
27
+ * tunable.
28
+ */
29
+ const OUTBOX_PAGE_FETCH_LIMIT = 1_000;
18
30
  /**
19
31
  * Durable outbox dispatcher. The sink must deduplicate `deliveryKey`; this stable key closes the
20
32
  * unavoidable crash window between an external acknowledgement and the local delivered marker.
@@ -26,7 +38,7 @@ export class TransactionalOutboxWorker {
26
38
  workerId;
27
39
  leaseMs;
28
40
  maxAttempts;
29
- constructor(database, projectId, workspaceId, workerId, leaseMs = 30_000, maxAttempts = 3) {
41
+ constructor(database, projectId, workspaceId, workerId, leaseMs = 30_000, maxAttempts = getRuntimePolicy().outbox.maxAttempts) {
30
42
  this.database = database;
31
43
  this.projectId = projectId;
32
44
  this.workspaceId = workspaceId;
@@ -46,38 +58,83 @@ export class TransactionalOutboxWorker {
46
58
  async drain(deliver, options = {}) {
47
59
  const clock = options.clock ?? (() => new Date());
48
60
  const now = currentIso(clock, options.now);
61
+ const policy = getRuntimePolicy().outbox;
62
+ const timeBudgetMs = options.timeBudgetMs ?? policy.sliceTimeBudgetMs;
63
+ const startedAtMs = clock().getTime();
64
+ let recordBudget = options.limit ?? policy.sliceRecordBudget;
49
65
  let delivered = 0;
50
- let skipped = 0;
51
- const pending = this.database.listOutbox(this.projectId, this.workspaceId, {
52
- pendingOnly: true,
53
- limit: options.limit,
54
- topics: options.topics,
55
- });
56
- for (const message of pending) {
57
- const fence = this.claim(message, now);
58
- if (!fence) {
59
- skipped += 1;
60
- continue;
61
- }
62
- try {
63
- await deliver({
64
- deliveryKey: this.deliveryKey(message),
65
- fencingToken: fence.fencingToken,
66
- topic: message.topic,
67
- payload: message.payload,
68
- });
69
- }
70
- catch {
71
- const failure = new BoundaryFailure('DependencyUnavailable', 'outbox delivery', 'Retry with the stable delivery key after the dependency recovers.');
72
- this.fail(message, fence, failure, currentIso(clock, options.now));
73
- throw failure;
66
+ let failed = 0;
67
+ let ineligible = 0;
68
+ let cursorId = 0;
69
+ let timedOut = false;
70
+ while (recordBudget > 0 && !timedOut) {
71
+ const batch = this.database.listOutbox(this.projectId, this.workspaceId, {
72
+ pendingOnly: true,
73
+ limit: OUTBOX_PAGE_FETCH_LIMIT,
74
+ topics: options.topics,
75
+ eligibleAt: now,
76
+ afterId: cursorId,
77
+ });
78
+ if (batch.length === 0) {
79
+ break;
74
80
  }
75
- if (!this.complete(message, fence, currentIso(clock, options.now))) {
76
- throw new BoundaryFailure('Conflict', 'outbox delivery commit', 'Another worker owns the lease; retry with the stable delivery key.');
81
+ for (const message of batch) {
82
+ // Admission check between deliveries: never aborts an in-flight delivery.
83
+ if (clock().getTime() - startedAtMs > timeBudgetMs) {
84
+ timedOut = true;
85
+ break;
86
+ }
87
+ cursorId = message.id;
88
+ const fence = this.claim(message, now);
89
+ if (!fence) {
90
+ // Ineligible for a reason SQL cannot pre-filter (foreign unexpired lease,
91
+ // self-healed delivered fence): consumes no record budget and does not
92
+ // stop the worker from advancing to the next record in this same call.
93
+ ineligible += 1;
94
+ continue;
95
+ }
96
+ recordBudget -= 1;
97
+ const outcome = await this.attemptDelivery(message, fence, deliver, clock, options.now);
98
+ if (outcome.status === 'failed') {
99
+ // Isolate the failure: continue offering remaining eligible records
100
+ // instead of aborting the whole slice.
101
+ failed += 1;
102
+ }
103
+ else {
104
+ delivered += 1;
105
+ }
106
+ if (recordBudget === 0) {
107
+ break;
108
+ }
77
109
  }
78
- delivered += 1;
79
110
  }
80
- return { delivered, skipped };
111
+ // Exact count of eligible work left unattempted (budget or time cutoff), not
112
+ // an estimate — this is what a further slice would find eligible right now.
113
+ const skipped = this.database.countEligibleOutbox(this.projectId, this.workspaceId, {
114
+ topics: options.topics,
115
+ eligibleAt: now,
116
+ afterId: cursorId,
117
+ });
118
+ return { delivered, failed, skipped, ineligible, exhausted: skipped === 0 };
119
+ }
120
+ async attemptDelivery(message, fence, deliver, clock, now) {
121
+ try {
122
+ await deliver({
123
+ deliveryKey: this.deliveryKey(message),
124
+ fencingToken: fence.fencingToken,
125
+ topic: message.topic,
126
+ payload: message.payload,
127
+ });
128
+ }
129
+ catch {
130
+ const failure = new BoundaryFailure('DependencyUnavailable', 'outbox delivery', 'Retry with the stable delivery key after the dependency recovers.');
131
+ this.fail(message, fence, failure, currentIso(clock, now));
132
+ return { status: 'failed' };
133
+ }
134
+ if (!this.complete(message, fence, currentIso(clock, now))) {
135
+ throw new BoundaryFailure('Conflict', 'outbox delivery commit', 'Another worker owns the lease; retry with the stable delivery key.');
136
+ }
137
+ return { status: 'delivered' };
81
138
  }
82
139
  claim(message, now) {
83
140
  return this.database.transaction(() => {
@@ -93,6 +150,12 @@ export class TransactionalOutboxWorker {
93
150
  if (existing?.state === 'leased' && existing.leaseExpiresAt > now) {
94
151
  return undefined;
95
152
  }
153
+ if (existing?.state === 'failed' &&
154
+ existing.nextAttemptAt !== undefined &&
155
+ existing.nextAttemptAt > now) {
156
+ // Durable retry deadline not yet reached: not eligible, attempts unchanged.
157
+ return undefined;
158
+ }
96
159
  const fence = {
97
160
  schemaVersion: 1,
98
161
  workerId: this.workerId,
@@ -126,6 +189,7 @@ export class TransactionalOutboxWorker {
126
189
  return false;
127
190
  }
128
191
  const attempts = current.attempts ?? 1;
192
+ const deadLettered = attempts >= this.maxAttempts;
129
193
  this.database.putRecord({
130
194
  projectId: this.projectId,
131
195
  workspaceId: this.workspaceId,
@@ -133,8 +197,9 @@ export class TransactionalOutboxWorker {
133
197
  key,
134
198
  value: {
135
199
  ...current,
136
- state: attempts >= this.maxAttempts ? 'dead-letter' : 'failed',
200
+ state: deadLettered ? 'dead-letter' : 'failed',
137
201
  leaseExpiresAt: now,
202
+ ...(deadLettered ? {} : { nextAttemptAt: this.computeNextAttemptAt(now, attempts) }),
138
203
  lastError: error instanceof BoundaryFailure
139
204
  ? error.message
140
205
  : new BoundaryFailure('Internal', 'outbox failure recording', 'Inspect local diagnostics and retry safely.').message,
@@ -144,6 +209,12 @@ export class TransactionalOutboxWorker {
144
209
  return true;
145
210
  });
146
211
  }
212
+ /** Deterministic exponential backoff with a validated cap; no random jitter. */
213
+ computeNextAttemptAt(now, attempts) {
214
+ const policy = getRuntimePolicy().outbox;
215
+ const delayMs = Math.min(policy.backoffBaseMs * 2 ** (attempts - 1), policy.backoffCapMs);
216
+ return new Date(Date.parse(now) + delayMs).toISOString();
217
+ }
147
218
  complete(message, fence, now) {
148
219
  return this.database.transaction(() => {
149
220
  const key = this.fenceKey(message.id);
@@ -444,12 +444,19 @@ function extractBacktickedFiles(content) {
444
444
  }
445
445
  return [...files].sort();
446
446
  }
447
+ /**
448
+ * SPEC-1406 (DEFECT 6): markdown heading lines (e.g. "### Edge Cases And Failure Modes")
449
+ * match keyword regexes like /\bedge\b/ just as readily as real bullet content, so headings
450
+ * used to leak into testPlan/risks/ownership as if they were grounded items. Skip headings
451
+ * and dedupe before truncating, so a criterion repeated under multiple sections — or the same
452
+ * heading matching two keyword sets — is not counted as distinct evidence twice.
453
+ */
447
454
  function extractLinesByKeywords(content, keywords) {
448
- return content
455
+ const lines = content
449
456
  .split('\n')
450
457
  .map((line) => line.trim().replace(/^[-*]\s*/, ''))
451
- .filter((line) => line.length > 0 && keywords.test(line))
452
- .slice(0, 12);
458
+ .filter((line) => line.length > 0 && !/^#{1,6}\s/.test(line) && keywords.test(line));
459
+ return [...new Set(lines)].slice(0, 12);
453
460
  }
454
461
  function extractOperationalSections(content, spec) {
455
462
  return {
@@ -1,5 +1,17 @@
1
1
  export declare const IMPLEMENTATION_CONTRACT_SECTION = "Implementation Contract";
2
- export declare const IMPLEMENTATION_CONTRACT_SUBSECTIONS: readonly ['User Outcome', 'Behavior Contract', 'File-Level Work Plan', 'Acceptance-To-Verification Map', 'Edge Cases And Failure Modes', 'Non-Goals And Forbidden Approaches', 'Verification Commands'];
2
+ /** Subsections every rendered contract must include omitting one is a defect. */
3
+ export declare const IMPLEMENTATION_CONTRACT_REQUIRED_SUBSECTIONS: readonly ['User Outcome', 'File-Level Work Plan', 'Acceptance-To-Verification Map', 'Verification Commands'];
4
+ /**
5
+ * SPEC-1406: subsections that carry no grounded content are omitted from the rendered
6
+ * body rather than filled with restated-criterion filler. Their absence is not an
7
+ * evaluator defect — only their presence-but-empty is. This keeps the ~1100
8
+ * already-persisted specs that still carry these sections (with real, non-empty
9
+ * content) transitionable: `evaluateImplementationContract` only requires them when
10
+ * the rendered body chose to include the heading.
11
+ */
12
+ export declare const IMPLEMENTATION_CONTRACT_OPTIONAL_SUBSECTIONS: readonly ['Behavior Contract', 'Edge Cases And Failure Modes', 'Non-Goals And Forbidden Approaches'];
13
+ /** Union of required + optional subsections. Kept for callers that only need the full set. */
14
+ export declare const IMPLEMENTATION_CONTRACT_SUBSECTIONS: readonly ["User Outcome", "File-Level Work Plan", "Acceptance-To-Verification Map", "Verification Commands", "Behavior Contract", "Edge Cases And Failure Modes", "Non-Goals And Forbidden Approaches"];
3
15
  export declare function hasImplementationContract(specBody: string): boolean;
4
16
  export declare function extractTopLevelSection(body: string, sectionName: string): string;
5
17
  //# sourceMappingURL=common.d.ts.map
@@ -1,12 +1,28 @@
1
1
  export const IMPLEMENTATION_CONTRACT_SECTION = 'Implementation Contract';
2
- export const IMPLEMENTATION_CONTRACT_SUBSECTIONS = [
2
+ /** Subsections every rendered contract must include — omitting one is a defect. */
3
+ export const IMPLEMENTATION_CONTRACT_REQUIRED_SUBSECTIONS = [
3
4
  'User Outcome',
4
- 'Behavior Contract',
5
5
  'File-Level Work Plan',
6
6
  'Acceptance-To-Verification Map',
7
+ 'Verification Commands',
8
+ ];
9
+ /**
10
+ * SPEC-1406: subsections that carry no grounded content are omitted from the rendered
11
+ * body rather than filled with restated-criterion filler. Their absence is not an
12
+ * evaluator defect — only their presence-but-empty is. This keeps the ~1100
13
+ * already-persisted specs that still carry these sections (with real, non-empty
14
+ * content) transitionable: `evaluateImplementationContract` only requires them when
15
+ * the rendered body chose to include the heading.
16
+ */
17
+ export const IMPLEMENTATION_CONTRACT_OPTIONAL_SUBSECTIONS = [
18
+ 'Behavior Contract',
7
19
  'Edge Cases And Failure Modes',
8
20
  'Non-Goals And Forbidden Approaches',
9
- 'Verification Commands',
21
+ ];
22
+ /** Union of required + optional subsections. Kept for callers that only need the full set. */
23
+ export const IMPLEMENTATION_CONTRACT_SUBSECTIONS = [
24
+ ...IMPLEMENTATION_CONTRACT_REQUIRED_SUBSECTIONS,
25
+ ...IMPLEMENTATION_CONTRACT_OPTIONAL_SUBSECTIONS,
10
26
  ];
11
27
  export function hasImplementationContract(specBody) {
12
28
  return extractTopLevelSection(specBody, IMPLEMENTATION_CONTRACT_SECTION).trim().length > 0;