@planu/cli 4.11.7 → 4.11.8

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 (51) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/cli/commands/serve.js +4 -0
  3. package/dist/config/license-plans.json +1 -0
  4. package/dist/engine/browser-validator.js +26 -21
  5. package/dist/engine/crash-shield/file-collector.d.ts +20 -3
  6. package/dist/engine/crash-shield/file-collector.js +137 -8
  7. package/dist/engine/crash-shield/index.d.ts +18 -1
  8. package/dist/engine/crash-shield/index.js +58 -17
  9. package/dist/engine/dogfooding/runtime-gap-detector.d.ts +3 -0
  10. package/dist/engine/dogfooding/runtime-gap-detector.js +386 -0
  11. package/dist/engine/figma/visual-qa.d.ts +2 -1
  12. package/dist/engine/figma/visual-qa.js +8 -7
  13. package/dist/engine/qa-gate.js +2 -1
  14. package/dist/engine/spec-state-machine/transition-spec.d.ts +16 -1
  15. package/dist/engine/spec-state-machine/transition-spec.js +19 -4
  16. package/dist/engine/triagier/classifier.d.ts +2 -2
  17. package/dist/engine/triagier/classifier.js +12 -15
  18. package/dist/index.js +12 -4
  19. package/dist/storage/approval-operation-lock.d.ts +10 -0
  20. package/dist/storage/approval-operation-lock.js +44 -0
  21. package/dist/storage/approval-store.d.ts +2 -0
  22. package/dist/storage/approval-store.js +9 -1
  23. package/dist/storage/spec-store.d.ts +29 -2
  24. package/dist/storage/spec-store.js +307 -7
  25. package/dist/tools/approval-handler.js +255 -124
  26. package/dist/tools/browser-validate-handler.js +17 -3
  27. package/dist/tools/dogfood-watch.d.ts +6 -0
  28. package/dist/tools/dogfood-watch.js +48 -0
  29. package/dist/tools/figma/visual-qa.js +2 -1
  30. package/dist/tools/tool-registry/core-tools.js +12 -0
  31. package/dist/tools/tool-registry/group-quality-compliance.js +12 -1
  32. package/dist/tools/update-status/file-sync.js +3 -2
  33. package/dist/tools/update-status/index.d.ts +2 -0
  34. package/dist/tools/update-status/index.js +1083 -821
  35. package/dist/tools/update-status/response-builder.js +11 -0
  36. package/dist/tools/update-status/side-effects.d.ts +16 -1
  37. package/dist/tools/update-status/side-effects.js +140 -0
  38. package/dist/tools/update-status/transition-guard.js +1 -1
  39. package/dist/tools/update-status-actions.d.ts +10 -2
  40. package/dist/tools/update-status-actions.js +166 -192
  41. package/dist/tools/update-status-convention-gate.d.ts +3 -1
  42. package/dist/tools/update-status-convention-gate.js +135 -7
  43. package/dist/types/browser-validator.d.ts +2 -0
  44. package/dist/types/dogfooding.d.ts +34 -0
  45. package/dist/types/dogfooding.js +2 -0
  46. package/dist/types/index.d.ts +1 -0
  47. package/dist/types/index.js +1 -0
  48. package/dist/types/spec/core.d.ts +28 -1
  49. package/package.json +25 -25
  50. package/planu-native.json +1 -1
  51. package/planu-plugin.json +1 -1
@@ -86,6 +86,12 @@ export function buildStatusResponse(result, specId, currentStatus, newStatus, re
86
86
  const lines = [
87
87
  `**SPEC-${specId}** moved: ${fromEmoji} \`${currentStatus}\` → ${toEmoji} \`${newStatus}\``,
88
88
  ];
89
+ if (r.committed && r.transitionId) {
90
+ lines.push(`\nTransition committed: \`${r.transitionId}\``);
91
+ }
92
+ if (r.pendingBackgroundActions && r.pendingBackgroundActions.length > 0) {
93
+ lines.push(`\nBackground actions queued: ${r.pendingBackgroundActions.join(', ')}`);
94
+ }
89
95
  if (r.autoBranch) {
90
96
  lines.push(`\n🌿 Branch created: \`${r.autoBranch}\``);
91
97
  }
@@ -221,6 +227,11 @@ export function buildStatusResponse(result, specId, currentStatus, newStatus, re
221
227
  specId,
222
228
  previousStatus: currentStatus,
223
229
  newStatus,
230
+ committed: r.committed ?? undefined,
231
+ transitionId: r.transitionId ?? undefined,
232
+ committedAt: r.committedAt ?? undefined,
233
+ idempotent: r.idempotent ?? undefined,
234
+ pendingBackgroundActions: r.pendingBackgroundActions ?? undefined,
224
235
  autoBranch: r.autoBranch ?? undefined,
225
236
  protectedBranchWarning: r.protectedBranchWarning ?? undefined,
226
237
  mergeWarning: r.mergeWarning ?? undefined,
@@ -1,7 +1,22 @@
1
1
  import type { SpecStatus } from '../../types/index.js';
2
- import type { knowledgeStore } from '../../storage/index.js';
2
+ import { type knowledgeStore } from '../../storage/index.js';
3
3
  import type { Spec } from '../../types/spec/core.js';
4
4
  import type { RunCascadeResult } from '../../types/cascade-hooks.js';
5
+ export interface PostCommitTask {
6
+ name: string;
7
+ run: (signal?: AbortSignal) => Promise<unknown>;
8
+ timeoutMs?: number;
9
+ }
10
+ /**
11
+ * Queue optional work after a durable transition. Returns immediately and
12
+ * deduplicates retries by transition identity for the lifetime of the process.
13
+ */
14
+ export declare function queuePostCommitTasks(args: {
15
+ projectId: string;
16
+ specId: string;
17
+ transitionId: string;
18
+ tasks: PostCommitTask[];
19
+ }): string[];
5
20
  /**
6
21
  * SPEC-585: Autopush on done if configured.
7
22
  * Returns the push result for inclusion in autopilotSummary (or null when disabled/skipped).
@@ -1,4 +1,144 @@
1
+ import { specStore } from '../../storage/index.js';
1
2
  import { runCascade } from '../../engine/cascade-hooks/runner.js';
3
+ import { appendAutopilotLogEntry } from '../../storage/autopilot-log-store.js';
4
+ const DEFAULT_POST_COMMIT_TASK_TIMEOUT_MS = 10_000;
5
+ const MIN_POST_COMMIT_LEASE_MS = 30_000;
6
+ const MAX_DEDUPED_TRANSITIONS = 1_000;
7
+ const queuedTransitions = new Map();
8
+ function rememberTransition(transitionKey) {
9
+ if (queuedTransitions.has(transitionKey)) {
10
+ return false;
11
+ }
12
+ if (queuedTransitions.size >= MAX_DEDUPED_TRANSITIONS) {
13
+ const oldest = queuedTransitions.keys().next().value;
14
+ if (oldest !== undefined) {
15
+ queuedTransitions.delete(oldest);
16
+ }
17
+ }
18
+ queuedTransitions.set(transitionKey, Date.now());
19
+ return true;
20
+ }
21
+ async function runBoundedPostCommitTask(args) {
22
+ const { projectId, specId, transitionId, task } = args;
23
+ const timeoutMs = task.timeoutMs ?? DEFAULT_POST_COMMIT_TASK_TIMEOUT_MS;
24
+ const leaseMs = Math.max(MIN_POST_COMMIT_LEASE_MS, timeoutMs * 3);
25
+ const claim = await specStore.claimPostCommitTask(projectId, specId, transitionId, task.name, {
26
+ leaseMs,
27
+ });
28
+ if (!claim.claimed) {
29
+ return;
30
+ }
31
+ const startedAt = Date.now();
32
+ const controller = new AbortController();
33
+ let timer;
34
+ let heartbeat;
35
+ try {
36
+ heartbeat = setInterval(() => {
37
+ void specStore
38
+ .renewPostCommitTaskLease(projectId, specId, transitionId, task.name, claim.executionId, {
39
+ leaseMs,
40
+ })
41
+ .catch((error) => {
42
+ console.warn('[planu:post-commit] failed to renew task lease', {
43
+ specId,
44
+ transitionId,
45
+ task: task.name,
46
+ error: error instanceof Error ? error.message : String(error),
47
+ });
48
+ });
49
+ }, Math.max(1_000, Math.floor(leaseMs / 3)));
50
+ heartbeat.unref();
51
+ const runPromise = Promise.resolve().then(() => task.run(controller.signal));
52
+ const timeout = new Promise((resolve) => {
53
+ timer = setTimeout(() => {
54
+ controller.abort(new Error(`Timed out after ${String(timeoutMs)}ms`));
55
+ resolve('timeout');
56
+ }, timeoutMs);
57
+ timer.unref();
58
+ });
59
+ const firstResult = await Promise.race([runPromise.then(() => 'completed'), timeout]);
60
+ if (firstResult === 'timeout') {
61
+ await runPromise;
62
+ await specStore.settlePostCommitTask(projectId, specId, transitionId, task.name, claim.executionId, { status: 'done' });
63
+ try {
64
+ await appendAutopilotLogEntry(projectId, {
65
+ specId,
66
+ hookName: `post-commit:${task.name}:${transitionId}`,
67
+ result: 'fail',
68
+ error: `Timed out after ${String(timeoutMs)}ms, then completed successfully; retry suppressed.`,
69
+ timestamp: new Date().toISOString(),
70
+ durationMs: Date.now() - startedAt,
71
+ });
72
+ }
73
+ catch (logError) {
74
+ console.warn('[planu:post-commit] failed to record late task completion', {
75
+ specId,
76
+ transitionId,
77
+ task: task.name,
78
+ error: logError instanceof Error ? logError.message : String(logError),
79
+ });
80
+ }
81
+ return;
82
+ }
83
+ await specStore.settlePostCommitTask(projectId, specId, transitionId, task.name, claim.executionId, { status: 'done' });
84
+ }
85
+ catch (error) {
86
+ const message = error instanceof Error ? error.message : String(error);
87
+ await specStore
88
+ .settlePostCommitTask(projectId, specId, transitionId, task.name, claim.executionId, {
89
+ status: 'failed',
90
+ error: message,
91
+ })
92
+ .catch((settleError) => {
93
+ console.warn('[planu:post-commit] failed to persist task failure', {
94
+ specId,
95
+ transitionId,
96
+ task: task.name,
97
+ error: settleError instanceof Error ? settleError.message : String(settleError),
98
+ });
99
+ });
100
+ try {
101
+ await appendAutopilotLogEntry(projectId, {
102
+ specId,
103
+ hookName: `post-commit:${task.name}:${transitionId}`,
104
+ result: 'fail',
105
+ error: message,
106
+ timestamp: new Date().toISOString(),
107
+ durationMs: Date.now() - startedAt,
108
+ });
109
+ }
110
+ catch (logError) {
111
+ console.warn('[planu:post-commit] failed to record task failure', {
112
+ specId,
113
+ transitionId,
114
+ task: task.name,
115
+ error: logError instanceof Error ? logError.message : String(logError),
116
+ });
117
+ }
118
+ }
119
+ finally {
120
+ if (timer !== undefined) {
121
+ clearTimeout(timer);
122
+ }
123
+ if (heartbeat !== undefined) {
124
+ clearInterval(heartbeat);
125
+ }
126
+ }
127
+ }
128
+ /**
129
+ * Queue optional work after a durable transition. Returns immediately and
130
+ * deduplicates retries by transition identity for the lifetime of the process.
131
+ */
132
+ export function queuePostCommitTasks(args) {
133
+ const transitionKey = `${args.projectId}\0${args.specId}\0${args.transitionId}`;
134
+ if (!rememberTransition(transitionKey)) {
135
+ return [];
136
+ }
137
+ void Promise.allSettled(args.tasks.map((task) => runBoundedPostCommitTask({ ...args, task }))).finally(() => {
138
+ queuedTransitions.delete(transitionKey);
139
+ });
140
+ return args.tasks.map((task) => task.name);
141
+ }
2
142
  /**
3
143
  * SPEC-585: Autopush on done if configured.
4
144
  * Returns the push result for inclusion in autopilotSummary (or null when disabled/skipped).
@@ -21,7 +21,7 @@ import { getResolvedChallengeEvidence, requiredResolvedChallenges, } from '../ch
21
21
  export const VALID_TRANSITIONS = {
22
22
  draft: ['review', 'discarded'],
23
23
  review: ['draft', 'approved', 'discarded'],
24
- approved: ['review', 'implementing', 'discarded'],
24
+ approved: ['draft', 'review', 'implementing', 'discarded'],
25
25
  implementing: ['approved', 'done', 'discarded'],
26
26
  done: ['implementing'], // SPEC-733: reverse transition with mandatory reason
27
27
  discarded: ['draft'], // SPEC-733: reverse transition with mandatory reason
@@ -1,11 +1,17 @@
1
1
  import type { ConstitutionViolation } from '../types/index.js';
2
- export declare function runImplementingActions(projectId: string, specId: string): Promise<{
2
+ export declare function runImplementingActions(projectId: string, specId: string, options?: {
3
+ deferSideEffects?: boolean;
4
+ }): Promise<{
3
5
  autoBranch: string | undefined;
4
6
  protectedBranchWarning: string | null;
5
7
  suggestions: string[];
6
8
  autopilotSummary: string[];
7
9
  }>;
8
- export declare function runDoneActions(projectId: string, specId: string, gitBranch: string | undefined): Promise<{
10
+ /** Create the implementation branch and refresh derived context after commit. */
11
+ export declare function runImplementingSideEffects(projectId: string, specId: string): Promise<string | undefined>;
12
+ export declare function runDoneActions(projectId: string, specId: string, gitBranch: string | undefined, options?: {
13
+ deferSideEffects?: boolean;
14
+ }): Promise<{
9
15
  mergeWarning: string | null;
10
16
  prSuggestion: {
11
17
  title: string;
@@ -15,6 +21,8 @@ export declare function runDoneActions(projectId: string, specId: string, gitBra
15
21
  uncommittedWarning: string | null;
16
22
  autopilotSummary: string[];
17
23
  }>;
24
+ /** Run cleanup and state refreshes only after the done transition is durable. */
25
+ export declare function runDoneSideEffects(projectId: string, specId: string, gitBranch: string | undefined): Promise<void>;
18
26
  /**
19
27
  * Best-effort constitution compliance check for status transitions.
20
28
  * Returns warnings (never blocks the transition).
@@ -2,7 +2,13 @@ import { specStore, knowledgeStore } from '../storage/index.js';
2
2
  import { checkConstitutionCompliance } from './create-spec-tech.js';
3
3
  import { withAudit } from '../engine/autopilot/audit-logger.js';
4
4
  import { hasPending, markPending, clearPending } from '../engine/autopilot/cascade-deduplicator.js';
5
- export async function runImplementingActions(projectId, specId) {
5
+ function normalizeRejectedReasons(results) {
6
+ return results.map((result) => {
7
+ const reason = result.reason;
8
+ return reason instanceof Error ? reason : new Error(String(reason));
9
+ });
10
+ }
11
+ export async function runImplementingActions(projectId, specId, options = {}) {
6
12
  let autoBranch;
7
13
  let protectedBranchWarning = null;
8
14
  // Check if on a protected branch
@@ -19,7 +25,24 @@ export async function runImplementingActions(projectId, specId) {
19
25
  catch {
20
26
  // Not a git repo — skip check
21
27
  }
22
- // Auto-create branch
28
+ if (!options.deferSideEffects) {
29
+ try {
30
+ autoBranch = await runImplementingSideEffects(projectId, specId);
31
+ }
32
+ catch {
33
+ // Legacy direct callers keep best-effort behavior.
34
+ }
35
+ }
36
+ return {
37
+ autoBranch,
38
+ protectedBranchWarning,
39
+ suggestions: [],
40
+ autopilotSummary: [],
41
+ };
42
+ }
43
+ /** Create the implementation branch and refresh derived context after commit. */
44
+ export async function runImplementingSideEffects(projectId, specId) {
45
+ let autoBranch;
23
46
  const foundSpec = await specStore.getSpec(projectId, specId);
24
47
  if (foundSpec && !foundSpec.gitBranch) {
25
48
  let branchResult;
@@ -46,98 +69,89 @@ export async function runImplementingActions(projectId, specId) {
46
69
  // -------------------------------------------------------------------------
47
70
  // SPEC-600: Fire-and-forget cascade — recommend_model, orchestration plan, context check
48
71
  // -------------------------------------------------------------------------
49
- const autopilotSummary = [];
50
- void (async () => {
51
- try {
52
- const { resolveProjectPath: resolveCascadePath } = await import('./git/git-helpers.js');
53
- const projectPath = await resolveCascadePath(projectId);
54
- const spec = foundSpec ?? (await specStore.getSpec(projectId, specId));
55
- void Promise.allSettled([
56
- // SPEC-633: Write session state when spec moves to implementing
57
- (async () => {
58
- const { writeSpecSessionState } = await import('../engine/session-state/writer.js');
59
- await writeSpecSessionState(projectPath, specId, spec?.title ?? specId, 'moved to implementing').catch(() => {
60
- /* best-effort */
61
- });
62
- })(),
63
- // SPEC-635: Regenerate context.md
64
- (async () => {
65
- const { generateContextMd } = await import('../engine/context-md-generator.js');
66
- await generateContextMd(projectPath, projectId).catch(() => {
72
+ try {
73
+ const { resolveProjectPath: resolveCascadePath } = await import('./git/git-helpers.js');
74
+ const projectPath = await resolveCascadePath(projectId);
75
+ const spec = foundSpec ?? (await specStore.getSpec(projectId, specId));
76
+ const results = await Promise.allSettled([
77
+ // SPEC-633: Write session state when spec moves to implementing
78
+ (async () => {
79
+ const { writeSpecSessionState } = await import('../engine/session-state/writer.js');
80
+ await writeSpecSessionState(projectPath, specId, spec?.title ?? specId, 'moved to implementing').catch(() => {
81
+ /* best-effort */
82
+ });
83
+ })(),
84
+ // SPEC-635: Regenerate context.md
85
+ (async () => {
86
+ const { generateContextMd } = await import('../engine/context-md-generator.js');
87
+ await generateContextMd(projectPath, projectId).catch(() => {
88
+ /* best-effort */
89
+ });
90
+ })(),
91
+ // SPEC-656: Recalculate epicProgress when spec moves to implementing
92
+ (async () => {
93
+ try {
94
+ const { updateEpicProgressInSession } = await import('../engine/session-state/writer.js');
95
+ const allSpecs = await specStore.listSpecs(projectId);
96
+ await updateEpicProgressInSession(projectPath, allSpecs).catch(() => {
67
97
  /* best-effort */
68
98
  });
69
- })(),
70
- // SPEC-656: Recalculate epicProgress when spec moves to implementing
71
- (async () => {
72
- try {
73
- const { updateEpicProgressInSession } = await import('../engine/session-state/writer.js');
74
- const allSpecs = await specStore.listSpecs(projectId);
75
- await updateEpicProgressInSession(projectPath, allSpecs).catch(() => {
76
- /* best-effort */
99
+ }
100
+ catch {
101
+ /* best-effort */
102
+ }
103
+ })(),
104
+ // recommend_model — suggest haiku/sonnet/opus based on spec complexity
105
+ (async () => {
106
+ try {
107
+ const { handleRecommendModel } = await import('./recommend-model-handler.js');
108
+ const result = handleRecommendModel({ specId });
109
+ void result;
110
+ }
111
+ catch {
112
+ /* best-effort */
113
+ }
114
+ })(),
115
+ // generate_orchestration_plan — if cross-module or high difficulty
116
+ (async () => {
117
+ try {
118
+ const scope = spec?.scope ?? '';
119
+ const difficulty = spec?.difficulty ?? 1;
120
+ if (scope === 'cross-module' || difficulty >= 3) {
121
+ const { handleGenerateOrchestrationScript } = await import('./generate-orchestration-script.js');
122
+ handleGenerateOrchestrationScript({
123
+ specIds: [specId],
124
+ projectPath,
77
125
  });
78
126
  }
79
- catch {
80
- /* best-effort */
81
- }
82
- })(),
83
- // recommend_model — suggest haiku/sonnet/opus based on spec complexity
84
- (async () => {
85
- try {
86
- const { handleRecommendModel } = await import('./recommend-model-handler.js');
87
- const result = handleRecommendModel({ specId });
88
- if (!result.isError) {
89
- autopilotSummary.push('recommend_model: auto-recommended');
90
- }
91
- }
92
- catch {
93
- /* best-effort */
94
- }
95
- })(),
96
- // generate_orchestration_plan — if cross-module or high difficulty
97
- (async () => {
98
- try {
99
- const scope = spec?.scope ?? '';
100
- const difficulty = spec?.difficulty ?? 1;
101
- if (scope === 'cross-module' || difficulty >= 3) {
102
- const { handleGenerateOrchestrationScript } = await import('./generate-orchestration-script.js');
103
- handleGenerateOrchestrationScript({
104
- specIds: [specId],
105
- projectPath,
106
- });
107
- autopilotSummary.push('generate_orchestration_plan: auto-generated');
108
- }
109
- }
110
- catch {
111
- /* best-effort */
112
- }
113
- })(),
114
- // context_window_status — check context weight and warn
115
- (async () => {
116
- try {
117
- const { handleContextWindowStatus } = await import('./context-manager-handler.js');
118
- const ctxResult = await handleContextWindowStatus({ projectPath });
119
- if (!ctxResult.isError) {
120
- autopilotSummary.push('context_window_status: checked');
121
- }
122
- }
123
- catch {
124
- /* best-effort */
125
- }
126
- })(),
127
- ]);
128
- }
129
- catch {
130
- // Project path unavailable — skip cascade silently
127
+ }
128
+ catch {
129
+ /* best-effort */
130
+ }
131
+ })(),
132
+ // context_window_status — check context weight and warn
133
+ (async () => {
134
+ try {
135
+ const { handleContextWindowStatus } = await import('./context-manager-handler.js');
136
+ const ctxResult = await handleContextWindowStatus({ projectPath });
137
+ void ctxResult;
138
+ }
139
+ catch {
140
+ /* best-effort */
141
+ }
142
+ })(),
143
+ ]);
144
+ const failures = results.filter((result) => result.status === 'rejected');
145
+ if (failures.length > 0) {
146
+ throw new AggregateError(normalizeRejectedReasons(failures), `${String(failures.length)} implementing side effect(s) failed`);
131
147
  }
132
- })();
133
- return {
134
- autoBranch,
135
- protectedBranchWarning,
136
- suggestions: [],
137
- autopilotSummary,
138
- };
148
+ }
149
+ catch (error) {
150
+ throw error instanceof Error ? error : new Error(String(error));
151
+ }
152
+ return autoBranch;
139
153
  }
140
- export async function runDoneActions(projectId, specId, gitBranch) {
154
+ export async function runDoneActions(projectId, specId, gitBranch, options = {}) {
141
155
  // -------------------------------------------------------------------------
142
156
  // SPEC-628: Group A — fast synchronous checks only (< 100ms each)
143
157
  // Validation removed: already done by runValidateGate in index.ts (BATCH B)
@@ -198,112 +212,15 @@ export async function runDoneActions(projectId, specId, gitBranch) {
198
212
  })());
199
213
  mergeWarning = mergeResult;
200
214
  }
201
- // -------------------------------------------------------------------------
202
- // Group C — fire-and-forget side effects: launch all in parallel, don't await
203
- // -------------------------------------------------------------------------
204
- void (async () => {
205
- try {
206
- const { resolveProjectPath } = await import('./git/git-helpers.js');
207
- const projectPath = await resolveProjectPath(projectId);
208
- // AC-01: Cleanup worktree, local branch, and remote branch for done spec (best-effort)
209
- // SPEC-491: Sweep planu/ to remove legacy files
210
- // SPEC-496: Auto-generate portable session context snapshot
211
- void Promise.allSettled([
212
- (async () => {
213
- const { cleanupSpecOnDone } = await import('./git/cleanup-ops.js');
214
- await withAudit(projectPath, 'update_status(done)', 'cleanupSpecOnDone', () => cleanupSpecOnDone(projectPath, specId, gitBranch)).catch(() => {
215
- /* best-effort — never blocks status transition */
216
- });
217
- })(),
218
- // SPEC-633: Remove spec from session.json index when done
219
- (async () => {
220
- const { removeSpecFromSession } = await import('../engine/session-state/writer.js');
221
- await removeSpecFromSession(projectPath, specId).catch(() => {
222
- /* best-effort */
223
- });
224
- })(),
225
- // SPEC-656: Recalculate epicProgress after status change
226
- (async () => {
227
- try {
228
- const { updateEpicProgressInSession } = await import('../engine/session-state/writer.js');
229
- const allSpecs = await specStore.listSpecs(projectId);
230
- await updateEpicProgressInSession(projectPath, allSpecs).catch(() => {
231
- /* best-effort */
232
- });
233
- }
234
- catch {
235
- /* best-effort */
236
- }
237
- })(),
238
- // SPEC-635: Regenerate context.md after status change
239
- (async () => {
240
- const { generateContextMd } = await import('../engine/context-md-generator.js');
241
- await generateContextMd(projectPath, projectId).catch(() => {
242
- /* best-effort */
243
- });
244
- })(),
245
- (async () => {
246
- const { join } = await import('node:path');
247
- const { cleanPlanuRoot } = await import('../engine/spec-migrator/planu-root-cleaner.js');
248
- await cleanPlanuRoot(join(projectPath, 'planu')).catch(() => {
249
- /* best-effort */
250
- });
251
- })(),
252
- // SPEC-1002: Auto-cleanup stale branches/worktrees when spec reaches done
253
- (async () => {
254
- try {
255
- const { runHousekeepingSweep } = await import('../engine/housekeeping/index.js');
256
- await runHousekeepingSweep({ projectPath, dryRun: false }).catch(() => {
257
- /* best-effort — never blocks status transition */
258
- });
259
- }
260
- catch {
261
- /* best-effort */
262
- }
263
- })(),
264
- // SPEC-660: Dedup — session.json update runs once per spec within 5s window
265
- (async () => {
266
- if (hasPending(specId, 'generateSessionContext')) {
267
- return;
268
- }
269
- markPending(specId, 'generateSessionContext');
270
- try {
271
- const { generateSessionContext } = await import('../engine/session-context-generator.js');
272
- await generateSessionContext(projectPath, projectId).catch(() => {
273
- /* best-effort — never blocks status transition */
274
- });
275
- }
276
- finally {
277
- clearPending(specId, 'generateSessionContext');
278
- }
279
- })(),
280
- // SPEC-629: Delete ephemeral prompt.md — file served its purpose once implementing starts
281
- (async () => {
282
- /* v8 ignore start */
283
- try {
284
- const { glob } = await import('glob');
285
- const { unlink } = await import('node:fs/promises');
286
- const specFiles = await glob(`planu/specs/${specId}-*/prompt.md`, { cwd: projectPath, absolute: true });
287
- const exactFile = await glob(`planu/specs/${specId}/prompt.md`, { cwd: projectPath, absolute: true });
288
- await Promise.allSettled([...specFiles, ...exactFile].map((f) => unlink(f).catch(() => {
289
- /* best-effort */
290
- })));
291
- }
292
- catch {
293
- /* best-effort — never blocks status transition */
294
- }
295
- /* v8 ignore stop */
296
- })(),
297
- ]);
298
- }
299
- catch {
300
- // Project path unavailable — skip all fire-and-forget cleanup silently
301
- }
302
- })();
215
+ if (!options.deferSideEffects) {
216
+ void runDoneSideEffects(projectId, specId, gitBranch).catch(() => {
217
+ // Legacy direct callers keep best-effort behavior.
218
+ });
219
+ }
303
220
  // SPEC-649: slim cascade — 2 actions only
304
221
  const autopilotSummary = [
305
- 'session.json: updated (next spec suggested)',
306
- 'releases/pending.json: spec appended',
222
+ 'session.json: refresh queued',
223
+ 'releases/pending.json: refresh queued',
307
224
  ];
308
225
  return {
309
226
  mergeWarning,
@@ -313,6 +230,63 @@ export async function runDoneActions(projectId, specId, gitBranch) {
313
230
  autopilotSummary,
314
231
  };
315
232
  }
233
+ /** Run cleanup and state refreshes only after the done transition is durable. */
234
+ export async function runDoneSideEffects(projectId, specId, gitBranch) {
235
+ const { resolveProjectPath } = await import('./git/git-helpers.js');
236
+ const projectPath = await resolveProjectPath(projectId);
237
+ const results = await Promise.allSettled([
238
+ (async () => {
239
+ const { cleanupSpecOnDone } = await import('./git/cleanup-ops.js');
240
+ await withAudit(projectPath, 'update_status(done)', 'cleanupSpecOnDone', () => cleanupSpecOnDone(projectPath, specId, gitBranch));
241
+ })(),
242
+ (async () => {
243
+ const { removeSpecFromSession } = await import('../engine/session-state/writer.js');
244
+ await removeSpecFromSession(projectPath, specId);
245
+ })(),
246
+ (async () => {
247
+ const { updateEpicProgressInSession } = await import('../engine/session-state/writer.js');
248
+ const allSpecs = await specStore.listSpecs(projectId);
249
+ await updateEpicProgressInSession(projectPath, allSpecs);
250
+ })(),
251
+ (async () => {
252
+ const { generateContextMd } = await import('../engine/context-md-generator.js');
253
+ await generateContextMd(projectPath, projectId);
254
+ })(),
255
+ (async () => {
256
+ const { join } = await import('node:path');
257
+ const { cleanPlanuRoot } = await import('../engine/spec-migrator/planu-root-cleaner.js');
258
+ await cleanPlanuRoot(join(projectPath, 'planu'));
259
+ })(),
260
+ (async () => {
261
+ const { runHousekeepingSweep } = await import('../engine/housekeeping/index.js');
262
+ await runHousekeepingSweep({ projectPath, dryRun: false });
263
+ })(),
264
+ (async () => {
265
+ if (hasPending(specId, 'generateSessionContext')) {
266
+ return;
267
+ }
268
+ markPending(specId, 'generateSessionContext');
269
+ try {
270
+ const { generateSessionContext } = await import('../engine/session-context-generator.js');
271
+ await generateSessionContext(projectPath, projectId);
272
+ }
273
+ finally {
274
+ clearPending(specId, 'generateSessionContext');
275
+ }
276
+ })(),
277
+ (async () => {
278
+ const { glob } = await import('glob');
279
+ const { unlink } = await import('node:fs/promises');
280
+ const specFiles = await glob(`planu/specs/${specId}-*/prompt.md`, { cwd: projectPath, absolute: true });
281
+ const exactFiles = await glob(`planu/specs/${specId}/prompt.md`, { cwd: projectPath, absolute: true });
282
+ await Promise.allSettled([...specFiles, ...exactFiles].map((file) => unlink(file)));
283
+ })(),
284
+ ]);
285
+ const failures = results.filter((result) => result.status === 'rejected');
286
+ if (failures.length > 0) {
287
+ throw new AggregateError(normalizeRejectedReasons(failures), `${String(failures.length)} done side effect(s) failed`);
288
+ }
289
+ }
316
290
  /**
317
291
  * Best-effort constitution compliance check for status transitions.
318
292
  * Returns warnings (never blocks the transition).
@@ -1,7 +1,9 @@
1
1
  import type { SpecStatus, ComplianceGateResult } from '../types/index.js';
2
+ /** Test-only reset to prevent process-cache state leaking between cases. */
3
+ export declare function resetProcessValidationCacheForTests(): void;
2
4
  /**
3
5
  * Runs non-blocking transition checks not already covered by the authoritative validate report.
4
6
  * in parallel. Extracts complexity from handleUpdateStatus.
5
7
  */
6
- export declare function runComplianceGates(projectId: string, specTitle: string, specTags: string[], newStatus: SpecStatus): Promise<ComplianceGateResult>;
8
+ export declare function runComplianceGates(projectId: string, specTitle: string, specTags: string[], newStatus: SpecStatus, specId?: string): Promise<ComplianceGateResult>;
7
9
  //# sourceMappingURL=update-status-convention-gate.d.ts.map