moflo 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.
@@ -224,6 +224,14 @@ gh issue edit <issue-number> --remove-label "in-progress" --add-label "ready-for
224
224
  gh issue comment <issue-number> --body "PR created: <pr-url>"
225
225
  ```
226
226
 
227
+ If the story body carries an `Epic: #<n>` back-reference (decomposition writes it — `./ticket.md`) **and** `--epic-branch` is not set, sync the parent epic — check this story's box off and close the epic if it was the last:
228
+
229
+ ```bash
230
+ flo epic checkoff <epic-number> <story-number>
231
+ ```
232
+
233
+ Idempotent and safe to skip when there is no back-reference. `--epic-branch` runs skip it — the epic orchestrator owns checklist state there. The box flips when the work is delivered (PR opened), matching the orchestrator; if a PR is later rejected, reopen the epic manually.
234
+
227
235
  ### 5.5 Finalize run record (Flo Runs dashboard)
228
236
 
229
237
  Update the tasklist row written in Phase 0 with the terminal status. Same `runId`, `upsert: true`. On success:
@@ -35,25 +35,47 @@ When promoting to epic:
35
35
  2. Each story should be completable in a single PR.
36
36
  3. Stories should have clear boundaries (one concern per story).
37
37
  4. Order stories by dependency (independent ones first).
38
- 5. Create each story as a GitHub issue with its own Description, Acceptance Criteria, and Test Cases.
39
- 6. Convert the parent issue into an epic with a `## Stories` checklist.
38
+ 5. Establish the epic issue **first** so its number exists before the stories do (either the promoted parent issue or a freshly created epic).
39
+ 6. Create each story as a GitHub issue with its own Description, Acceptance Criteria, Test Cases, **and an `Epic: #<epic-number>` back-reference line at the top of the body**.
40
+ 7. Fill in the epic's `## Stories` checklist with a `- [ ] #<story-number>` line for every story.
41
+
42
+ The **back-reference is load-bearing**, not decoration: a story is usually worked on later via a standalone `/flo <story>` run, not through `flo epic run`. That standalone run reads the `Epic: #<n>` line to find its parent, check its box off, and close the epic when it is the last story. Without the back-reference the epic silently drifts out of sync — the exact miss this step exists to prevent. See `./phases.md` Phase 5.6.
40
43
 
41
44
  ## Epic decomposition (score >= 7)
42
45
 
46
+ The order matters: the epic must exist before the stories so each story can carry the `Epic: #<n>` back-reference, and the epic's checklist is filled in last.
47
+
48
+ Pass multi-line bodies as a single literal `--body "..."` string (portable — no `printf`/heredoc, which are not guaranteed on every consumer OS; Rule #1). For very large bodies write the text to a file with the Write tool and use `--body-file <path>`.
49
+
43
50
  ```bash
44
- # Step 1: create each sub-issue
45
- gh issue create --title "Story: <story-title>" --body "<## Description + ## Acceptance Criteria + ## Suggested Test Cases>" --label "story"
46
- # capture the new issue number from output
51
+ # Step 1: establish the epic issue and capture its number (EPIC).
52
+ # Promoting an existing issue it *is* the epic; add the label now:
53
+ gh issue edit <parent-number> --add-label "epic" # EPIC = <parent-number>
54
+ # Or create a fresh epic with an empty Stories section:
55
+ gh issue create --title "Epic: <title>" --label "epic" --body "<## Overview + empty ## Stories section>"
56
+ # capture EPIC = the epic issue number from whichever path above
57
+
58
+ # Step 2: create each story WITH the Epic back-reference as the first line; capture each number.
59
+ # (repeat for all stories, typically 2–6)
60
+ gh issue create --title "Story: <story-title>" --label "story" --body "<Epic: #<EPIC> + ## Description + ## Acceptance Criteria + ## Suggested Test Cases>"
61
+
62
+ # Step 3: fill the epic's ## Stories checklist with a - [ ] #<story> line per story.
63
+ gh issue edit <EPIC> --body "<epic body with the ## Stories checklist filled in>"
64
+ ```
65
+
66
+ **Story body format** — the first line is the back-reference; the standalone `/flo <story>` run (`./phases.md` Phase 5.6) parses `Epic: #<n>` from it:
47
67
 
48
- # Step 2: repeat for all stories (typically 2–6)
68
+ ```markdown
69
+ Epic: #<epic-number>
49
70
 
50
- # Step 3: build the epic body with a checklist referencing every story number
71
+ ## Description
72
+ <what and why>
51
73
 
52
- # Step 4: update an existing issue into an epic
53
- gh issue edit <parent-number> --add-label "epic" --body "<epic body with ## Stories checklist>"
74
+ ## Acceptance Criteria
75
+ - [ ] ...
54
76
 
55
- # Step 5: or create a new epic
56
- gh issue create --title "Epic: <title>" --label "epic" --body "<epic body>"
77
+ ## Suggested Test Cases
78
+ - ...
57
79
  ```
58
80
 
59
81
  **Epic body format** — the `## Stories` checklist with `- [ ] #<number>` is what enables epic detection, story extraction, and progress tracking:
@@ -12,11 +12,12 @@
12
12
  * flo epic run 42 --verbose Echo each step's stdout/stderr after it completes
13
13
  * flo epic status <epic-number> Check progress via memory
14
14
  * flo epic reset <epic-number> Clear epic memory state
15
+ * flo epic checkoff <epic> <story> Check a story off; close the epic if it was the last
15
16
  */
16
17
  import { readFileSync } from 'node:fs';
17
18
  import { execSync } from 'node:child_process';
18
19
  import { join } from 'node:path';
19
- import { isEpicIssue, fetchEpicIssue, extractStories, enrichStoryNames, } from '../epic/index.js';
20
+ import { isEpicIssue, fetchEpicIssue, extractStories, enrichStoryNames, checkOffStoryInEpic, } from '../epic/index.js';
20
21
  import { runEpicSpell, } from '../epic/runner-adapter.js';
21
22
  import { locateMofloModulePath } from '../services/moflo-require.js';
22
23
  import { select } from '../prompt.js';
@@ -362,6 +363,36 @@ async function resetEpic(epicNumber) {
362
363
  return { success: true };
363
364
  }
364
365
  // ============================================================================
366
+ // Subcommand: checkoff
367
+ // ============================================================================
368
+ async function checkOffStory(epicArg, storyArg) {
369
+ const epic = parseInt(epicArg, 10);
370
+ const story = parseInt(storyArg, 10);
371
+ if (isNaN(epic) || isNaN(story)) {
372
+ return { success: false, message: 'Usage: flo epic checkoff <epic-number> <story-number>' };
373
+ }
374
+ try {
375
+ const { checked, epicClosed, alreadyClosed } = await checkOffStoryInEpic(epic, story);
376
+ const parts = [];
377
+ parts.push(checked
378
+ ? `Checked off story #${story} in epic #${epic}.`
379
+ : `Story #${story} was already checked (or not listed) in epic #${epic}.`);
380
+ if (epicClosed)
381
+ parts.push(`All stories complete — closed epic #${epic}.`);
382
+ else if (alreadyClosed)
383
+ parts.push(`Epic #${epic} was already closed.`);
384
+ const message = parts.join(' ');
385
+ console.log(`[epic] ${message}`);
386
+ return { success: true, message };
387
+ }
388
+ catch (err) {
389
+ return {
390
+ success: false,
391
+ message: buildFailureSummary(err.message, { stepId: 'checkoff' }),
392
+ };
393
+ }
394
+ }
395
+ // ============================================================================
365
396
  // Preflight warning interactive resolver
366
397
  // ============================================================================
367
398
  function isInteractive() {
@@ -461,6 +492,7 @@ const epicCommand = {
461
492
  { command: 'flo epic run 42', description: 'Explicit run subcommand (same as above)' },
462
493
  { command: 'flo epic status 42', description: 'Check epic progress' },
463
494
  { command: 'flo epic reset 42', description: 'Reset epic state for re-run' },
495
+ { command: 'flo epic checkoff 42 43', description: 'Check story #43 off in epic #42; close #42 if it was the last story' },
464
496
  ],
465
497
  action: async (ctx) => {
466
498
  const subcommand = ctx.args?.[0];
@@ -469,10 +501,11 @@ const epicCommand = {
469
501
  console.log(' flo epic <command> [args] [flags]');
470
502
  console.log('');
471
503
  console.log('Commands:');
472
- console.log(' <issue-number> Execute epic (shorthand for "run")');
473
- console.log(' run <issue-number> Execute a GitHub epic via spell engine');
474
- console.log(' status <epic-number> Check epic progress');
475
- console.log(' reset <epic-number> Reset epic state for re-run');
504
+ console.log(' <issue-number> Execute epic (shorthand for "run")');
505
+ console.log(' run <issue-number> Execute a GitHub epic via spell engine');
506
+ console.log(' status <epic-number> Check epic progress');
507
+ console.log(' reset <epic-number> Reset epic state for re-run');
508
+ console.log(' checkoff <epic> <story> Check a story off in its epic; close the epic if it was the last');
476
509
  console.log('');
477
510
  console.log('Flags:');
478
511
  console.log(' --strategy <name> single-branch (default) or auto-merge');
@@ -518,8 +551,10 @@ const epicCommand = {
518
551
  return showStatus(commandArgs[0] || '');
519
552
  case 'reset':
520
553
  return resetEpic(commandArgs[0] || '');
554
+ case 'checkoff':
555
+ return checkOffStory(commandArgs[0] || '', commandArgs[1] || '');
521
556
  default:
522
- return { success: false, message: `Unknown subcommand: ${subcommand}. Available: run, status, reset` };
557
+ return { success: false, message: `Unknown subcommand: ${subcommand}. Available: run, status, reset, checkoff` };
523
558
  }
524
559
  },
525
560
  };
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * Story #195: Shared epic detection & extraction module.
8
8
  */
9
- import { exec } from 'node:child_process';
9
+ import { exec, spawnSync } from 'node:child_process';
10
10
  import { promisify } from 'node:util';
11
11
  const execAsync = promisify(exec);
12
12
  // ============================================================================
@@ -138,4 +138,59 @@ export async function findPrForIssue(issueNumber) {
138
138
  return null;
139
139
  }
140
140
  }
141
+ // ============================================================================
142
+ // Story checkoff (standalone `/flo <story>` runs)
143
+ // ============================================================================
144
+ /**
145
+ * Pure core of the story-checkoff operation — no I/O, unit-testable.
146
+ *
147
+ * Flips a story's `- [ ] #<n>` checkbox to `- [x] #<n>` in an epic body and
148
+ * reports whether the epic is now complete (it has story checkboxes and none
149
+ * remain unchecked). Word-boundary guarded so #12 never matches inside #123.
150
+ */
151
+ export function computeEpicCheckoff(body, storyNumber) {
152
+ const pattern = new RegExp(`- \\[ \\] #${storyNumber}(?!\\d)`);
153
+ const updated = body.replace(pattern, `- [x] #${storyNumber}`);
154
+ const checked = updated !== body;
155
+ const hasStoryCheckboxes = /- \[[ x]\] #\d+/.test(updated);
156
+ const hasUnchecked = /- \[ \] #\d+/.test(updated);
157
+ const allComplete = hasStoryCheckboxes && !hasUnchecked;
158
+ return { updated, checked, allComplete };
159
+ }
160
+ /**
161
+ * Check a story off in its parent epic's checklist, and close the epic when no
162
+ * unchecked stories remain. Used by standalone `/flo <story>` runs via
163
+ * `flo epic checkoff`; the orchestrated `flo epic run` path does its own
164
+ * checkoff inside the spell (epic-single-branch.yaml).
165
+ *
166
+ * Cross-platform (Rule #1): drives `gh` through `spawnSync` arg arrays (no
167
+ * shell string to escape) and pipes the rewritten body over stdin
168
+ * (`--body-file -`), the same pattern proven on Windows CI in the spell.
169
+ */
170
+ export async function checkOffStoryInEpic(epicNumber, storyNumber) {
171
+ const issue = await fetchEpicIssue(epicNumber);
172
+ const { updated, checked, allComplete } = computeEpicCheckoff(issue.body || '', storyNumber);
173
+ if (checked) {
174
+ const edit = spawnSync('gh', ['issue', 'edit', String(epicNumber), '--body-file', '-'], {
175
+ input: updated,
176
+ encoding: 'utf8',
177
+ });
178
+ if (edit.status !== 0) {
179
+ throw new Error(`gh issue edit failed: ${(edit.stderr || edit.error?.message || 'unknown').toString().trim()}`);
180
+ }
181
+ }
182
+ const alreadyClosed = issue.state.toUpperCase() === 'CLOSED';
183
+ const epicClosed = allComplete && !alreadyClosed;
184
+ if (epicClosed) {
185
+ const close = spawnSync('gh', [
186
+ 'issue', 'close', String(epicNumber),
187
+ '--reason', 'completed',
188
+ '--comment', `All stories complete — closed automatically after story #${storyNumber}.`,
189
+ ], { encoding: 'utf8' });
190
+ if (close.status !== 0) {
191
+ throw new Error(`gh issue close failed: ${(close.stderr || close.error?.message || 'unknown').toString().trim()}`);
192
+ }
193
+ }
194
+ return { checked, epicClosed, alreadyClosed };
195
+ }
141
196
  //# sourceMappingURL=detection.js.map
@@ -3,5 +3,5 @@
3
3
  *
4
4
  * Story #195: Shared epic detection & extraction module.
5
5
  */
6
- export { isEpicIssue, extractStories, extractUncheckedStories, fetchEpicIssue, enrichStoryNames, findPrForIssue, } from './detection.js';
6
+ export { isEpicIssue, extractStories, extractUncheckedStories, fetchEpicIssue, enrichStoryNames, findPrForIssue, computeEpicCheckoff, checkOffStoryInEpic, } from './detection.js';
7
7
  //# sourceMappingURL=index.js.map
@@ -2,5 +2,5 @@
2
2
  * Auto-generated by build. Do not edit manually.
3
3
  * Source of truth: root package.json → scripts/sync-version.mjs
4
4
  */
5
- export const VERSION = '4.11.7';
5
+ export const VERSION = '4.11.8';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.11.7",
3
+ "version": "4.11.8",
4
4
  "description": "MoFlo — AI agent orchestration for Claude Code. A standalone, opinionated toolkit with semantic memory, learned routing, gates, spells, and the /flo issue-execution skill.",
5
5
  "main": "dist/src/cli/index.js",
6
6
  "type": "module",
@@ -94,7 +94,7 @@
94
94
  "@typescript-eslint/eslint-plugin": "^7.18.0",
95
95
  "@typescript-eslint/parser": "^7.18.0",
96
96
  "eslint": "^8.0.0",
97
- "moflo": "^4.11.6",
97
+ "moflo": "^4.11.7",
98
98
  "tsx": "^4.21.0",
99
99
  "typescript": "^5.9.3",
100
100
  "vitest": "^4.0.0"