@sienklogic/plan-build-run 2.48.0 → 2.50.0

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ All notable changes to Plan-Build-Run will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.50.0](https://github.com/SienkLogic/plan-build-run/compare/plan-build-run-v2.49.0...plan-build-run-v2.50.0) (2026-03-01)
9
+
10
+
11
+ ### Features
12
+
13
+ * **52-04:** replace 4 milestone banners with tmpl refs; condense 5 plan SKILL.md sections to < 600 lines ([3e383c4](https://github.com/SienkLogic/plan-build-run/commit/3e383c45d4cfdb51298a2d2531f51752132368a0))
14
+ * **53-01:** add cleanup section and CRITICAL markers to build, consolidate plan pre-planner briefing ([7c14693](https://github.com/SienkLogic/plan-build-run/commit/7c14693e7790728e00d5e4d080e19f54648bc156))
15
+ * **53-02:** GREEN - implement ciPoll and rollback in lib/build.js ([d2ffa30](https://github.com/SienkLogic/plan-build-run/commit/d2ffa309eab8bf10981e88ded12c8e2b4029ad28))
16
+ * **53-02:** register ci-poll and rollback in pbr-tools.js dispatcher ([88c551e](https://github.com/SienkLogic/plan-build-run/commit/88c551e82d3acb1d807bd302c758d041cb0577c0))
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * **52-05:** remove blank lines to bring build SKILL.md to 868 lines (< 870 target) ([6cd9549](https://github.com/SienkLogic/plan-build-run/commit/6cd954972c33540462cb08f6a192604879b8e6df))
22
+
23
+ ## [2.49.0](https://github.com/SienkLogic/plan-build-run/compare/plan-build-run-v2.48.0...plan-build-run-v2.49.0) (2026-03-01)
24
+
25
+
26
+ ### Features
27
+
28
+ * **52-04:** extract 4 milestone completion banners to tmpl files ([62064af](https://github.com/SienkLogic/plan-build-run/commit/62064af63ce6fb3bd044cd807d08aeb5979be219))
29
+ * **52-05:** remove duplicate wave spot-checks paragraph from build SKILL.md ([6b90261](https://github.com/SienkLogic/plan-build-run/commit/6b9026193d868d1bdb0225ccf197e919c30040c9))
30
+
8
31
  ## [2.48.0](https://github.com/SienkLogic/plan-build-run/compare/plan-build-run-v2.47.0...plan-build-run-v2.48.0) (2026-03-01)
9
32
 
10
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sienklogic/plan-build-run",
3
- "version": "2.48.0",
3
+ "version": "2.50.0",
4
4
  "description": "Plan it, Build it, Run it — structured development workflow for Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pbr",
3
3
  "displayName": "Plan-Build-Run",
4
- "version": "2.48.0",
4
+ "version": "2.50.0",
5
5
  "description": "Plan-Build-Run — Structured development workflow for GitHub Copilot CLI. Solves context rot through disciplined agent delegation, structured planning, atomic execution, and goal-backward verification.",
6
6
  "author": {
7
7
  "name": "SienkLogic",
@@ -669,6 +669,8 @@ This ensures that `/pbr:review` after a `--gaps-only` build sees the updated ver
669
669
 
670
670
  **8-pre-c. Codebase map incremental update (conditional):**
671
671
 
672
+ **CRITICAL (no hook): Run codebase map update if conditions are met. Do NOT skip this step.**
673
+
672
674
  Only run if ALL of these are true:
673
675
  - `.planning/codebase/` directory exists (project was previously scanned with `/pbr:scan`)
674
676
  - Build was not aborted
@@ -779,6 +781,8 @@ Write `.planning/.auto-next` containing the next logical command (e.g., `/pbr:pl
779
781
 
780
782
  **8e-ii. Check Pending Todos:**
781
783
 
784
+ **CRITICAL (no hook): Check pending todos after build. Do NOT skip this step.**
785
+
782
786
  After completing the build, check if any pending todos are now satisfied:
783
787
 
784
788
  1. Check if `.planning/todos/pending/` exists and contains files
@@ -895,3 +899,9 @@ If `git.branching` is `phase` but we're not on the phase branch:
895
899
  | `.planning/STATE.md` | Updated progress | Steps 6f, 8b |
896
900
  | `.planning/.auto-next` | Next command signal (if auto_continue enabled) | Step 8e |
897
901
  | Project source files | Actual code | Step 6 (executors) |
902
+
903
+ ---
904
+
905
+ ## Cleanup
906
+
907
+ Delete `.planning/.active-skill` if it exists. This must happen on all paths (success, partial, and failure) before reporting results.
@@ -243,61 +243,51 @@ After the researcher completes, check the agent output for a completion marker:
243
243
 
244
244
  ---
245
245
 
246
- ### Step 4.5: Seed Scanning (inline, before planning)
247
-
248
- Before invoking the planner, scan `.planning/seeds/` for seeds whose trigger matches the current phase:
249
-
250
- 1. Glob for `.planning/seeds/*.md`
251
- 2. For each seed file, read its frontmatter and check the `trigger` field
252
- 3. A seed matches if ANY of these are true:
253
- - `trigger` equals the phase slug (e.g., `trigger: authentication`) — **preferred**
254
- - `trigger` is a substring of the phase directory name (e.g., `trigger: auth` matches `03-authentication`)
255
- - `trigger` equals the current phase number as integer (e.g., `trigger: 3`) — backward compatible but NOT recommended for new seeds (breaks with decimal phases like 3.1)
256
- - `trigger` equals `*` (always matches)
257
- 4. If matching seeds are found, present them to the user:
258
- ```
259
- Found {N} seeds related to Phase {NN}:
260
- - {seed_name}: {seed description}
261
- - {seed_name}: {seed description}
262
- ```
263
-
264
- Use the yes-no-pick pattern from `skills/shared/gate-prompts.md`:
265
- question: "Include these {N} seeds in planning?"
266
- header: "Seeds?"
267
- options:
268
- - label: "Yes, all" description: "Include all {N} matching seeds"
269
- - label: "Let me pick" description: "Choose which seeds to include"
270
- - label: "No" description: "Proceed without seeds"
271
- 5. If "Yes, all": include all matching seed content in the planner's context
272
- 6. If "Let me pick": present individual seeds for selection
273
- 7. If "No" or "Other": proceed without seeds
274
- 8. If no matching seeds found: proceed silently
246
+ ### Step 4.5: Pre-Planner Briefing (delegated)
275
247
 
276
- ---
248
+ **CRITICAL (no hook): Run pre-planner briefing before spawning the planner. Do NOT skip this step.**
249
+
250
+ Consolidate seed scanning and deferred idea surfacing into a single lightweight Task():
251
+
252
+ ```
253
+ Task({
254
+ subagent_type: "pbr:general",
255
+ model: "haiku",
256
+ prompt: "Pre-planner briefing for Phase {NN} ({phase-slug}).
257
+
258
+ 1. SEED SCANNING:
259
+ Run: `node ${PLUGIN_ROOT}/scripts/pbr-tools.js seeds match {phase-slug} {phase-number}`
260
+ If `matched` is non-empty, output a ## Seeds section listing each seed name, description, and content.
261
+ If empty, output: ## Seeds\nNo matching seeds found.
262
+
263
+ 2. DEFERRED IDEAS:
264
+ Read `.planning/CONTEXT.md`. If it has a section containing 'deferred' or 'ideas' (case-insensitive),
265
+ extract items that mention Phase {NN} or keywords matching the phase slug.
266
+ If relevant items found, output a ## Deferred Ideas section listing them.
267
+ If none found, output: ## Deferred Ideas\nNo relevant deferred items.
268
+
269
+ Output format: Return both sections as markdown. End with ## BRIEFING COMPLETE."
270
+ })
271
+ ```
277
272
 
278
- ### Step 4.6: Surface Deferred Ideas (inline, before planning)
279
-
280
- Before invoking the planner, check `.planning/CONTEXT.md` for deferred ideas that may be relevant to this phase:
281
-
282
- 1. If `.planning/CONTEXT.md` does NOT exist, skip this step silently
283
- 2. If it exists, scan for sections named "Deferred Ideas", "Deferred", "Ideas", or "Seeds" (case-insensitive heading match)
284
- 3. For each deferred item found, check relevance to the current phase by comparing the item text against the phase goal, requirements, and slug
285
- 4. If relevant deferred items are found, present them to the user:
286
- ```
287
- Found {N} deferred idea(s) from previous discussions that may be relevant to Phase {NN}:
288
- - {deferred item summary}
289
- - {deferred item summary}
290
- ```
291
- Use the yes-no pattern from `skills/shared/gate-prompts.md`:
292
- question: "Include these deferred ideas in the planning context?"
293
- header: "Deferred Ideas"
294
- options:
295
- - label: "Yes" description: "Pass relevant deferred ideas to the planner"
296
- - label: "No" description: "Proceed without deferred ideas"
297
- 5. If "Yes": append the relevant deferred items to the context bundle for the planner prompt (add them to the `<project_context>` block under a `Deferred ideas to consider:` heading)
298
- 6. If "No" or no relevant items found: proceed without changes
299
-
300
- This is a lightweight relevance filter — do NOT invoke an agent for this. Just match keywords from the deferred items against the phase goal and requirement text.
273
+ After the Task() completes:
274
+ - If `## Seeds` section contains matches: present them to the user via the yes-no-pick pattern from `skills/shared/gate-prompts.md`:
275
+ question: "Include these {N} seeds in planning?"
276
+ header: "Seeds?"
277
+ options:
278
+ - label: "Yes, all" description: "Include all {N} matching seeds"
279
+ - label: "Let me pick" description: "Choose which seeds to include"
280
+ - label: "No" description: "Proceed without seeds"
281
+ - If "Yes, all": include seed content in planner context
282
+ - If "Let me pick": present individual seeds for selection
283
+ - If "No": proceed without seeds
284
+
285
+ - If `## Deferred Ideas` section has items: present via the yes-no pattern from `skills/shared/gate-prompts.md`:
286
+ question: "Include these deferred ideas in planning context?"
287
+ - If "Yes": append to planner context under `Deferred ideas to consider:`
288
+ - If "No": proceed without changes
289
+
290
+ - If both sections are empty: proceed silently to Step 5 (no AskUserQuestion needed)
301
291
 
302
292
  ---
303
293
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pbr",
3
3
  "displayName": "Plan-Build-Run",
4
- "version": "2.48.0",
4
+ "version": "2.50.0",
5
5
  "description": "Plan-Build-Run — Structured development workflow for Cursor. Solves context rot through disciplined subagent delegation, structured planning, atomic execution, and goal-backward verification.",
6
6
  "author": {
7
7
  "name": "SienkLogic",
@@ -670,6 +670,8 @@ This ensures that `/pbr:review` after a `--gaps-only` build sees the updated ver
670
670
 
671
671
  **8-pre-c. Codebase map incremental update (conditional):**
672
672
 
673
+ **CRITICAL (no hook): Run codebase map update if conditions are met. Do NOT skip this step.**
674
+
673
675
  Only run if ALL of these are true:
674
676
  - `.planning/codebase/` directory exists (project was previously scanned with `/pbr:scan`)
675
677
  - Build was not aborted
@@ -780,6 +782,8 @@ Write `.planning/.auto-next` containing the next logical command (e.g., `/pbr:pl
780
782
 
781
783
  **8e-ii. Check Pending Todos:**
782
784
 
785
+ **CRITICAL (no hook): Check pending todos after build. Do NOT skip this step.**
786
+
783
787
  After completing the build, check if any pending todos are now satisfied:
784
788
 
785
789
  1. Check if `.planning/todos/pending/` exists and contains files
@@ -894,3 +898,9 @@ If `git.branching` is `phase` but we're not on the phase branch:
894
898
  | `.planning/STATE.md` | Updated progress | Steps 6f, 8b |
895
899
  | `.planning/.auto-next` | Next command signal (if auto_continue enabled) | Step 8e |
896
900
  | Project source files | Actual code | Step 6 (executors) |
901
+
902
+ ---
903
+
904
+ ## Cleanup
905
+
906
+ Delete `.planning/.active-skill` if it exists. This must happen on all paths (success, partial, and failure) before reporting results.
@@ -244,61 +244,51 @@ After the researcher completes, check the agent output for a completion marker:
244
244
 
245
245
  ---
246
246
 
247
- ### Step 4.5: Seed Scanning (inline, before planning)
248
-
249
- Before invoking the planner, scan `.planning/seeds/` for seeds whose trigger matches the current phase:
250
-
251
- 1. Glob for `.planning/seeds/*.md`
252
- 2. For each seed file, read its frontmatter and check the `trigger` field
253
- 3. A seed matches if ANY of these are true:
254
- - `trigger` equals the phase slug (e.g., `trigger: authentication`) — **preferred**
255
- - `trigger` is a substring of the phase directory name (e.g., `trigger: auth` matches `03-authentication`)
256
- - `trigger` equals the current phase number as integer (e.g., `trigger: 3`) — backward compatible but NOT recommended for new seeds (breaks with decimal phases like 3.1)
257
- - `trigger` equals `*` (always matches)
258
- 4. If matching seeds are found, present them to the user:
259
- ```
260
- Found {N} seeds related to Phase {NN}:
261
- - {seed_name}: {seed description}
262
- - {seed_name}: {seed description}
263
- ```
264
-
265
- Use the yes-no-pick pattern from `skills/shared/gate-prompts.md`:
266
- question: "Include these {N} seeds in planning?"
267
- header: "Seeds?"
268
- options:
269
- - label: "Yes, all" description: "Include all {N} matching seeds"
270
- - label: "Let me pick" description: "Choose which seeds to include"
271
- - label: "No" description: "Proceed without seeds"
272
- 5. If "Yes, all": include all matching seed content in the planner's context
273
- 6. If "Let me pick": present individual seeds for selection
274
- 7. If "No" or "Other": proceed without seeds
275
- 8. If no matching seeds found: proceed silently
247
+ ### Step 4.5: Pre-Planner Briefing (delegated)
276
248
 
277
- ---
249
+ **CRITICAL (no hook): Run pre-planner briefing before spawning the planner. Do NOT skip this step.**
250
+
251
+ Consolidate seed scanning and deferred idea surfacing into a single lightweight Task():
252
+
253
+ ```
254
+ Task({
255
+ subagent_type: "pbr:general",
256
+ model: "haiku",
257
+ prompt: "Pre-planner briefing for Phase {NN} ({phase-slug}).
258
+
259
+ 1. SEED SCANNING:
260
+ Run: `node ${PLUGIN_ROOT}/scripts/pbr-tools.js seeds match {phase-slug} {phase-number}`
261
+ If `matched` is non-empty, output a ## Seeds section listing each seed name, description, and content.
262
+ If empty, output: ## Seeds\nNo matching seeds found.
263
+
264
+ 2. DEFERRED IDEAS:
265
+ Read `.planning/CONTEXT.md`. If it has a section containing 'deferred' or 'ideas' (case-insensitive),
266
+ extract items that mention Phase {NN} or keywords matching the phase slug.
267
+ If relevant items found, output a ## Deferred Ideas section listing them.
268
+ If none found, output: ## Deferred Ideas\nNo relevant deferred items.
269
+
270
+ Output format: Return both sections as markdown. End with ## BRIEFING COMPLETE."
271
+ })
272
+ ```
278
273
 
279
- ### Step 4.6: Surface Deferred Ideas (inline, before planning)
280
-
281
- Before invoking the planner, check `.planning/CONTEXT.md` for deferred ideas that may be relevant to this phase:
282
-
283
- 1. If `.planning/CONTEXT.md` does NOT exist, skip this step silently
284
- 2. If it exists, scan for sections named "Deferred Ideas", "Deferred", "Ideas", or "Seeds" (case-insensitive heading match)
285
- 3. For each deferred item found, check relevance to the current phase by comparing the item text against the phase goal, requirements, and slug
286
- 4. If relevant deferred items are found, present them to the user:
287
- ```
288
- Found {N} deferred idea(s) from previous discussions that may be relevant to Phase {NN}:
289
- - {deferred item summary}
290
- - {deferred item summary}
291
- ```
292
- Use the yes-no pattern from `skills/shared/gate-prompts.md`:
293
- question: "Include these deferred ideas in the planning context?"
294
- header: "Deferred Ideas"
295
- options:
296
- - label: "Yes" description: "Pass relevant deferred ideas to the planner"
297
- - label: "No" description: "Proceed without deferred ideas"
298
- 5. If "Yes": append the relevant deferred items to the context bundle for the planner prompt (add them to the `<project_context>` block under a `Deferred ideas to consider:` heading)
299
- 6. If "No" or no relevant items found: proceed without changes
300
-
301
- This is a lightweight relevance filter — do NOT invoke an agent for this. Just match keywords from the deferred items against the phase goal and requirement text.
274
+ After the Task() completes:
275
+ - If `## Seeds` section contains matches: present them to the user via the yes-no-pick pattern from `skills/shared/gate-prompts.md`:
276
+ question: "Include these {N} seeds in planning?"
277
+ header: "Seeds?"
278
+ options:
279
+ - label: "Yes, all" description: "Include all {N} matching seeds"
280
+ - label: "Let me pick" description: "Choose which seeds to include"
281
+ - label: "No" description: "Proceed without seeds"
282
+ - If "Yes, all": include seed content in planner context
283
+ - If "Let me pick": present individual seeds for selection
284
+ - If "No": proceed without seeds
285
+
286
+ - If `## Deferred Ideas` section has items: present via the yes-no pattern from `skills/shared/gate-prompts.md`:
287
+ question: "Include these deferred ideas in planning context?"
288
+ - If "Yes": append to planner context under `Deferred ideas to consider:`
289
+ - If "No": proceed without changes
290
+
291
+ - If both sections are empty: proceed silently to Step 5 (no AskUserQuestion needed)
302
292
 
303
293
  ---
304
294
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pbr",
3
- "version": "2.48.0",
3
+ "version": "2.50.0",
4
4
  "description": "Plan-Build-Run — Structured development workflow for Claude Code. Solves context rot through disciplined subagent delegation, structured planning, atomic execution, and goal-backward verification.",
5
5
  "author": {
6
6
  "name": "SienkLogic",
@@ -12,10 +12,13 @@
12
12
  * checkpointInit(phaseSlug, plans, planningDir) — Initialize checkpoint manifest
13
13
  * checkpointUpdate(phaseSlug, opts, planningDir) — Update checkpoint manifest
14
14
  * seedsMatch(phaseSlug, phaseNumber, planningDir) — Find matching seed files
15
+ * ciPoll(runId, timeoutSecs, planningDir) — Single-check GitHub Actions CI run status
16
+ * rollback(manifestPath, planningDir) — Rollback failed plan via checkpoint manifest
15
17
  */
16
18
 
17
19
  const fs = require('fs');
18
20
  const path = require('path');
21
+ const { execSync } = require('child_process');
19
22
 
20
23
  /**
21
24
  * Resolve the .planning directory from the given planningDir or env/cwd fallback.
@@ -441,6 +444,236 @@ function parseSeedFrontmatter(content) {
441
444
  return result;
442
445
  }
443
446
 
447
+ // ---------------------------------------------------------------------------
448
+ // ciPoll
449
+ // ---------------------------------------------------------------------------
450
+
451
+ /**
452
+ * Single-check GitHub Actions CI run status (not a polling loop).
453
+ * The build skill calls this repeatedly when needed.
454
+ *
455
+ * @param {string|null} runId - GitHub Actions run ID
456
+ * @param {number} [timeoutSecs=300] - Max seconds allowed (used only to detect timeout already exceeded)
457
+ * @param {string} [planningDir] - Unused; kept for signature consistency
458
+ * @returns {{ status: string, conclusion: string|null, url: string, next_action: string, elapsed_seconds: number, error?: string }}
459
+ */
460
+ function ciPoll(runId, timeoutSecs, _planningDir) {
461
+ const startMs = Date.now();
462
+ const timeout = (typeof timeoutSecs === 'number' && !isNaN(timeoutSecs)) ? timeoutSecs : 300;
463
+
464
+ if (!runId) {
465
+ return {
466
+ status: 'error',
467
+ conclusion: null,
468
+ url: '',
469
+ next_action: 'abort',
470
+ elapsed_seconds: 0,
471
+ error: 'Missing run-id. Usage: ci-poll <run-id> [--timeout <seconds>]'
472
+ };
473
+ }
474
+
475
+ // If timeout already exceeded (caller sets 0 to signal timeout)
476
+ if (timeout <= 0) {
477
+ return {
478
+ status: 'timed_out',
479
+ conclusion: null,
480
+ url: '',
481
+ next_action: 'abort',
482
+ elapsed_seconds: 0,
483
+ error: 'CI timeout exceeded'
484
+ };
485
+ }
486
+
487
+ let raw;
488
+ try {
489
+ raw = execSync(`gh run view ${runId} --json status,conclusion,url`, {
490
+ encoding: 'utf8',
491
+ timeout: 10000
492
+ });
493
+ } catch (e) {
494
+ const elapsed = Math.round((Date.now() - startMs) / 1000);
495
+ return {
496
+ status: 'error',
497
+ conclusion: null,
498
+ url: '',
499
+ next_action: 'abort',
500
+ elapsed_seconds: elapsed,
501
+ error: e.message || 'gh command failed'
502
+ };
503
+ }
504
+
505
+ let parsed;
506
+ try {
507
+ parsed = JSON.parse(raw);
508
+ } catch (_e) {
509
+ const elapsed = Math.round((Date.now() - startMs) / 1000);
510
+ return {
511
+ status: 'error',
512
+ conclusion: null,
513
+ url: '',
514
+ next_action: 'abort',
515
+ elapsed_seconds: elapsed,
516
+ error: 'Failed to parse gh output: ' + raw
517
+ };
518
+ }
519
+
520
+ const elapsed = Math.round((Date.now() - startMs) / 1000);
521
+ const ghStatus = parsed.status || '';
522
+ const conclusion = parsed.conclusion || null;
523
+ const url = parsed.url || '';
524
+
525
+ if (ghStatus === 'completed') {
526
+ const passed = conclusion === 'success';
527
+ return {
528
+ status: passed ? 'passed' : 'failed',
529
+ conclusion,
530
+ url,
531
+ next_action: passed ? 'continue' : 'abort',
532
+ elapsed_seconds: elapsed
533
+ };
534
+ }
535
+
536
+ // in_progress, queued, waiting, etc.
537
+ return {
538
+ status: ghStatus,
539
+ conclusion: null,
540
+ url,
541
+ next_action: 'wait',
542
+ elapsed_seconds: elapsed
543
+ };
544
+ }
545
+
546
+ // ---------------------------------------------------------------------------
547
+ // rollback
548
+ // ---------------------------------------------------------------------------
549
+
550
+ /**
551
+ * Rollback a failed plan using checkpoint manifest.
552
+ * Reads manifest, runs git reset --soft, invalidates downstream SUMMARY.md files.
553
+ *
554
+ * @param {string} manifestPath - Absolute path to .checkpoint-manifest.json
555
+ * @param {string} [planningDir]
556
+ * @returns {{ ok: boolean, rolled_back_to: string|null, plans_invalidated: string[], files_deleted: string[], warnings: string[], error?: string }}
557
+ */
558
+ function rollback(manifestPath, _planningDir) {
559
+ // Step 1: Read manifest
560
+ if (!fs.existsSync(manifestPath)) {
561
+ return {
562
+ ok: false,
563
+ rolled_back_to: null,
564
+ plans_invalidated: [],
565
+ files_deleted: [],
566
+ warnings: [],
567
+ error: 'Manifest not found: ' + manifestPath
568
+ };
569
+ }
570
+
571
+ let manifest;
572
+ try {
573
+ const raw = fs.readFileSync(manifestPath, 'utf8');
574
+ manifest = JSON.parse(raw);
575
+ } catch (e) {
576
+ return {
577
+ ok: false,
578
+ rolled_back_to: null,
579
+ plans_invalidated: [],
580
+ files_deleted: [],
581
+ warnings: [],
582
+ error: 'Cannot parse manifest: ' + e.message
583
+ };
584
+ }
585
+
586
+ // Step 2: Extract last_good_commit
587
+ const lastGoodCommit = manifest.last_good_commit || null;
588
+ if (!lastGoodCommit) {
589
+ return {
590
+ ok: false,
591
+ rolled_back_to: null,
592
+ plans_invalidated: [],
593
+ files_deleted: [],
594
+ warnings: [],
595
+ error: 'No rollback point available (last_good_commit is missing or null). Use abort instead.'
596
+ };
597
+ }
598
+
599
+ const phaseDir = path.dirname(manifestPath);
600
+ const filesDeleted = [];
601
+ const plansInvalidated = [];
602
+ const warnings = [];
603
+
604
+ // Step 3: git reset --soft to last good commit
605
+ try {
606
+ execSync(`git reset --soft ${lastGoodCommit}`, { encoding: 'utf8', timeout: 15000 });
607
+ } catch (e) {
608
+ return {
609
+ ok: false,
610
+ rolled_back_to: null,
611
+ plans_invalidated: [],
612
+ files_deleted: [],
613
+ warnings: [],
614
+ error: 'git reset --soft failed: ' + e.message
615
+ };
616
+ }
617
+
618
+ // Step 4: Identify failed plan
619
+ const failedPlan = manifest.failed_plan || null;
620
+
621
+ // Step 5: Delete failed plan's SUMMARY.md if it exists
622
+ if (failedPlan) {
623
+ const failedSummary = path.join(phaseDir, `SUMMARY-${failedPlan}.md`);
624
+ if (fs.existsSync(failedSummary)) {
625
+ try {
626
+ fs.unlinkSync(failedSummary);
627
+ filesDeleted.push(failedSummary);
628
+ } catch (e) {
629
+ warnings.push(`Could not delete ${failedSummary}: ${e.message}`);
630
+ }
631
+ }
632
+ }
633
+
634
+ // Step 6: Scan for downstream plans and invalidate them
635
+ // downstream_of maps planId -> [deps]. A plan is downstream if failedPlan is in its deps.
636
+ const downstreamOf = manifest.downstream_of || {};
637
+ const resolved = Array.isArray(manifest.checkpoints_resolved) ? manifest.checkpoints_resolved : [];
638
+
639
+ for (const planId of resolved) {
640
+ if (planId === failedPlan) continue;
641
+ const deps = downstreamOf[planId] || [];
642
+ if (failedPlan && deps.includes(failedPlan)) {
643
+ plansInvalidated.push(planId);
644
+ const summaryPath = path.join(phaseDir, `SUMMARY-${planId}.md`);
645
+ if (fs.existsSync(summaryPath)) {
646
+ try {
647
+ fs.unlinkSync(summaryPath);
648
+ filesDeleted.push(summaryPath);
649
+ } catch (e) {
650
+ warnings.push(`Could not delete ${summaryPath}: ${e.message}`);
651
+ }
652
+ }
653
+ }
654
+ }
655
+
656
+ // Step 7: Write updated manifest back
657
+ const updatedManifest = Object.assign({}, manifest, {
658
+ checkpoints_resolved: resolved.filter(p =>
659
+ p !== failedPlan && !plansInvalidated.includes(p)
660
+ )
661
+ });
662
+ try {
663
+ fs.writeFileSync(manifestPath, JSON.stringify(updatedManifest, null, 2), 'utf8');
664
+ } catch (e) {
665
+ warnings.push('Could not update manifest: ' + e.message);
666
+ }
667
+
668
+ return {
669
+ ok: true,
670
+ rolled_back_to: lastGoodCommit,
671
+ plans_invalidated: plansInvalidated,
672
+ files_deleted: filesDeleted,
673
+ warnings
674
+ };
675
+ }
676
+
444
677
  // ---------------------------------------------------------------------------
445
678
  // Exports
446
679
  // ---------------------------------------------------------------------------
@@ -450,5 +683,7 @@ module.exports = {
450
683
  summaryGate,
451
684
  checkpointInit,
452
685
  checkpointUpdate,
453
- seedsMatch
686
+ seedsMatch,
687
+ ciPoll,
688
+ rollback
454
689
  };
@@ -163,7 +163,9 @@ const {
163
163
  summaryGate: _summaryGate,
164
164
  checkpointInit: _checkpointInit,
165
165
  checkpointUpdate: _checkpointUpdate,
166
- seedsMatch: _seedsMatch
166
+ seedsMatch: _seedsMatch,
167
+ ciPoll: _ciPoll,
168
+ rollback: _rollback
167
169
  } = require('./lib/build');
168
170
 
169
171
  // --- Local LLM imports (not extracted — separate module tree) ---
@@ -341,6 +343,8 @@ function summaryGate(phaseSlug, planId) { return _summaryGate(phaseSlug, planId,
341
343
  function checkpointInit(phaseSlug, plans) { return _checkpointInit(phaseSlug, plans, planningDir); }
342
344
  function checkpointUpdate(phaseSlug, opts) { return _checkpointUpdate(phaseSlug, opts, planningDir); }
343
345
  function seedsMatch(phaseSlug, phaseNum) { return _seedsMatch(phaseSlug, phaseNum, planningDir); }
346
+ function ciPoll(runId, timeoutSecs) { return _ciPoll(runId, timeoutSecs, planningDir); }
347
+ function rollbackPlan(manifestPath) { return _rollback(manifestPath, planningDir); }
344
348
 
345
349
  // --- validateProject stays here (cross-cutting across modules) ---
346
350
 
@@ -819,6 +823,16 @@ async function main() {
819
823
  } else {
820
824
  error('Usage: seeds match <phase-slug> <phase-number>'); process.exit(1);
821
825
  }
826
+ } else if (command === 'ci-poll') {
827
+ const runId = args[1];
828
+ const timeoutIdx = args.indexOf('--timeout');
829
+ const timeoutSecs = timeoutIdx !== -1 ? parseInt(args[timeoutIdx + 1], 10) : 300;
830
+ if (!runId) { error('Usage: pbr-tools.js ci-poll <run-id> [--timeout <seconds>]'); return; }
831
+ output(ciPoll(runId, timeoutSecs));
832
+ } else if (command === 'rollback') {
833
+ const manifestPath = args[1];
834
+ if (!manifestPath) { error('Usage: pbr-tools.js rollback <manifest-path>'); return; }
835
+ output(rollbackPlan(manifestPath));
822
836
  } else if (command === 'context-triage') {
823
837
  const options = {};
824
838
  const agentsIdx = args.indexOf('--agents-done');
@@ -842,7 +856,7 @@ async function main() {
842
856
  } else if (command === 'validate-project') {
843
857
  output(validateProject());
844
858
  } else {
845
- error(`Unknown command: ${args.join(' ')}\nCommands: state load|check-progress|update|patch|advance-plan|record-metric, config validate|load-defaults|save-defaults|resolve-depth, validate-project, migrate [--dry-run] [--force], init execute-phase|plan-phase|quick|verify-work|resume|progress, state-bundle <phase>, plan-index, frontmatter, must-haves, phase-info, phase add|remove|list, roadmap update-status|update-plans, history append|load, todo list|get|add|done, event, llm health|status|classify|score-source|classify-error|summarize|metrics [--session <ISO>]|adjust-thresholds, learnings ingest|query|check-thresholds, milestone-stats <version>, context-triage [--agents-done N] [--plans-total N] [--step NAME]`);
859
+ error(`Unknown command: ${args.join(' ')}\nCommands: state load|check-progress|update|patch|advance-plan|record-metric, config validate|load-defaults|save-defaults|resolve-depth, validate-project, migrate [--dry-run] [--force], init execute-phase|plan-phase|quick|verify-work|resume|progress, state-bundle <phase>, plan-index, frontmatter, must-haves, phase-info, phase add|remove|list, roadmap update-status|update-plans, history append|load, todo list|get|add|done, event, llm health|status|classify|score-source|classify-error|summarize|metrics [--session <ISO>]|adjust-thresholds, learnings ingest|query|check-thresholds, milestone-stats <version>, context-triage [--agents-done N] [--plans-total N] [--step NAME], ci-poll <run-id> [--timeout <seconds>], rollback <manifest-path>`);
846
860
  }
847
861
  } catch (e) {
848
862
  error(e.message);
@@ -850,6 +864,6 @@ async function main() {
850
864
  }
851
865
 
852
866
  if (require.main === module || process.argv[1] === __filename) { main().catch(err => { process.stderr.write(err.message + '\n'); process.exit(1); }); }
853
- module.exports = { KNOWN_AGENTS, initExecutePhase, initPlanPhase, initQuick, initVerifyWork, initResume, initProgress, initStateBundle: stateBundle, stateBundle, statePatch, stateAdvancePlan, stateRecordMetric, parseStateMd, parseRoadmapMd, parseYamlFrontmatter, parseMustHaves, countMustHaves, stateLoad, stateCheckProgress, configLoad, configClearCache, configValidate, lockedFileUpdate, planIndex, determinePhaseStatus, findFiles, atomicWrite, tailLines, frontmatter, mustHavesCollect, phaseInfo, stateUpdate, roadmapUpdateStatus, roadmapUpdatePlans, updateLegacyStateField, updateFrontmatterField, updateTableRow, findRoadmapRow, resolveDepthProfile, DEPTH_PROFILE_DEFAULTS, historyAppend, historyLoad, VALID_STATUS_TRANSITIONS, validateStatusTransition, writeActiveSkill, validateProject, phaseAdd, phaseRemove, phaseList, loadUserDefaults, saveUserDefaults, mergeUserDefaults, USER_DEFAULTS_PATH, todoList, todoGet, todoAdd, todoDone, migrate, spotCheck, referenceGet, milestoneStats, contextTriage, stalenessCheck, summaryGate, checkpointInit, checkpointUpdate, seedsMatch };
867
+ module.exports = { KNOWN_AGENTS, initExecutePhase, initPlanPhase, initQuick, initVerifyWork, initResume, initProgress, initStateBundle: stateBundle, stateBundle, statePatch, stateAdvancePlan, stateRecordMetric, parseStateMd, parseRoadmapMd, parseYamlFrontmatter, parseMustHaves, countMustHaves, stateLoad, stateCheckProgress, configLoad, configClearCache, configValidate, lockedFileUpdate, planIndex, determinePhaseStatus, findFiles, atomicWrite, tailLines, frontmatter, mustHavesCollect, phaseInfo, stateUpdate, roadmapUpdateStatus, roadmapUpdatePlans, updateLegacyStateField, updateFrontmatterField, updateTableRow, findRoadmapRow, resolveDepthProfile, DEPTH_PROFILE_DEFAULTS, historyAppend, historyLoad, VALID_STATUS_TRANSITIONS, validateStatusTransition, writeActiveSkill, validateProject, phaseAdd, phaseRemove, phaseList, loadUserDefaults, saveUserDefaults, mergeUserDefaults, USER_DEFAULTS_PATH, todoList, todoGet, todoAdd, todoDone, migrate, spotCheck, referenceGet, milestoneStats, contextTriage, stalenessCheck, summaryGate, checkpointInit, checkpointUpdate, seedsMatch, ciPoll, rollbackPlan };
854
868
  // NOTE: validateProject, phaseAdd, phaseRemove, phaseList were previously CLI-only (not exported).
855
869
  // They are now exported for testability. This is additive and backwards-compatible.