moflo 4.11.10-rc.9 → 4.12.1

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.
@@ -104,6 +104,19 @@ function loadSddConfig() {
104
104
  return out;
105
105
  }
106
106
 
107
+ // Parse the top-level `merge:` block from moflo.yaml (#1285). Block-scoped for
108
+ // the same reason as loadSddConfig: a bare `auto:` key can appear under other
109
+ // sections. Cross-platform: tolerates CRLF.
110
+ function loadMergeConfig() {
111
+ var out = { auto: false };
112
+ var content = MOFLO_YAML;
113
+ if (!content) return out;
114
+ var block = content.match(/^merge:[ \t]*\r?\n((?:[ \t]+.*(?:\r?\n|$))*)/m);
115
+ if (!block) return out;
116
+ if (/^\s*auto:\s*true\b/im.test(block[1])) out.auto = true;
117
+ return out;
118
+ }
119
+
107
120
  // Read moflo.yaml exactly once per gate process (#1297 review): loadGateConfig
108
121
  // and loadSddConfig both parse it, and the gate fires on every Write/Edit — a
109
122
  // second read is wasted syscalls. Single readFileSync in try/catch (no existsSync
@@ -116,6 +129,7 @@ var MOFLO_YAML = readMofloYaml();
116
129
 
117
130
  var config = loadGateConfig();
118
131
  var sddConf = loadSddConfig();
132
+ var mergeConf = loadMergeConfig();
119
133
  var command = process.argv[2];
120
134
 
121
135
  var EXEMPT = ['.claude/', '.claude\\', 'CLAUDE.md', 'MEMORY.md', 'workflow-state', 'node_modules', 'moflo.yaml'];
@@ -288,16 +302,71 @@ function detectFlMode(promptText) {
288
302
  return null;
289
303
  }
290
304
 
291
- // #1297 arm the SDD implement gate from the user prompt. Only /fl or /flo runs
292
- // can arm it. Explicit -sd/--sdd wins; --no-sdd disarms; otherwise honor the
293
- // sdd.default config. `-sd` is a distinct token from `-s` (swarm): the `d` sits
294
- // on the word boundary so `-s\b` never matches `-sd`.
295
- function detectSddMode(promptText) {
305
+ // Resolve the FULL set of /flo run modifiers from the prompt + moflo.yaml the
306
+ // single source of truth for both gate arming (#1297) and the authoritative
307
+ // announcement emitted by prompt-reminder. Two consumers, one resolver: a second
308
+ // implementation is exactly how `sdd.default: true` got silently ignored (the
309
+ // skill's prompt carried `let sddMode = false` and the model executed the literal).
310
+ //
311
+ // Precedence per key: explicit --no-X > explicit -x/--X > moflo.yaml > built-in.
312
+ // NOTE the differing built-in defaults: sdd is opt-IN (false), verify is opt-OUT
313
+ // (true, #1294), merge is opt-IN (false, #1285).
314
+ //
315
+ // `-sd` is a distinct token from `-s` (swarm): the `d` sits on the word boundary
316
+ // so `-s\b` never matches `-sd`.
317
+ function resolveFloRun(promptText) {
296
318
  var p = promptText || '';
297
- if (!/^\s*\/(?:fl|flo)\b/i.test(p)) return false;
298
- if (/(?:^|\s)--no-sdd\b/.test(p)) return false;
299
- if (/(?:^|\s)(?:-sd|--sdd)\b/.test(p)) return true;
300
- return !!sddConf.default;
319
+ var out = { isFlo: false, workflow: 'full', sdd: false, verify: false, merge: false,
320
+ sddSrc: 'default', verifySrc: 'default', mergeSrc: 'default' };
321
+ if (!/^\s*\/(?:fl|flo)\b/i.test(p)) return out;
322
+ out.isFlo = true;
323
+
324
+ // Workflow mode — decides which modifiers are even applicable.
325
+ if (/(?:^|\s)(?:-wf|--workflow)\b/.test(p)) out.workflow = 'spell-engine';
326
+ else if (/(?:^|\s)(?:-r|--research)\b/.test(p)) out.workflow = 'research';
327
+ else if (/(?:^|\s)(?:-t|--ticket)\b/.test(p)) out.workflow = 'ticket';
328
+ var epicBranch = /(?:^|\s)--epic-branch\b/.test(p);
329
+
330
+ if (/(?:^|\s)--no-sdd\b/.test(p)) { out.sdd = false; out.sddSrc = 'flag'; }
331
+ else if (/(?:^|\s)(?:-sd|--sdd)\b/.test(p)) { out.sdd = true; out.sddSrc = 'flag'; }
332
+ else if (sddConf.default) { out.sdd = true; out.sddSrc = 'moflo.yaml sdd.default'; }
333
+
334
+ if (/(?:^|\s)--no-verify\b/.test(p)) { out.verify = false; out.verifySrc = 'flag'; }
335
+ else if (/(?:^|\s)(?:-v|--verify)\b/.test(p)) { out.verify = true; out.verifySrc = 'flag'; }
336
+ else if (!config.verify_before_done) { out.verify = false; out.verifySrc = 'moflo.yaml gates.verify_before_done'; }
337
+ else { out.verify = true; out.verifySrc = 'default'; }
338
+ // --sdd implies --verify: a spec/plan without an enforced verify step drifts.
339
+ if (out.sdd && !out.verify && out.verifySrc !== 'flag') out.verify = true;
340
+
341
+ if (/(?:^|\s)--no-merge\b/.test(p)) { out.merge = false; out.mergeSrc = 'flag'; }
342
+ else if (/(?:^|\s)(?:-m|--merge)\b/.test(p)) { out.merge = true; out.mergeSrc = 'flag'; }
343
+ else if (mergeConf.auto) { out.merge = true; out.mergeSrc = 'moflo.yaml merge.auto'; }
344
+
345
+ // Applicability: -t/-r never implement, so verify is a no-op there; -r produces
346
+ // no artifacts, so sdd is a no-op too (in -t the spec/plan goes INTO the ticket,
347
+ // so sdd stays on). Only a full non-epic-branch run opens a PR to merge.
348
+ // Re-attribute anything applicability turned off, so a false never carries the
349
+ // source of the value it no longer has. This whole change exists to stop modes
350
+ // being reported inaccurately — the attribution has to be honest too.
351
+ if (out.workflow === 'ticket' || out.workflow === 'research') {
352
+ if (out.verify) out.verifySrc = out.workflow + ' mode does not implement';
353
+ out.verify = false;
354
+ }
355
+ if (out.workflow === 'research' || out.workflow === 'spell-engine') {
356
+ if (out.sdd) out.sddSrc = out.workflow + ' mode produces no spec artifacts';
357
+ out.sdd = false;
358
+ }
359
+ if (out.workflow !== 'full' || epicBranch) {
360
+ if (out.merge) out.mergeSrc = epicBranch ? '--epic-branch owns merging' : out.workflow + ' mode opens no PR';
361
+ out.merge = false;
362
+ }
363
+ return out;
364
+ }
365
+
366
+ // #1297 — arm the SDD implement gate from the user prompt. Thin wrapper so the
367
+ // arming decision and the announced decision can never disagree.
368
+ function detectSddMode(promptText) {
369
+ return resolveFloRun(promptText).sdd;
301
370
  }
302
371
 
303
372
  // Resolve the absolute specs root the same way TS specsRoot does (#1294): split
@@ -981,6 +1050,33 @@ switch (command) {
981
1050
  applyPromptStateReset(s, prompt);
982
1051
  s.interactionCount = (s.interactionCount || 0) + 1;
983
1052
  writeState(s);
1053
+ // Announce the resolved /flo run modifiers. The gate already parsed
1054
+ // moflo.yaml in THIS process (fresh per prompt — a git pull or a mid-session
1055
+ // yaml edit is picked up automatically, no cache to invalidate), so this
1056
+ // costs no extra read. Emitting it is what closes the loop: the skill used
1057
+ // to carry `let sddMode = false` as a literal and the model executed it,
1058
+ // silently ignoring `sdd.default: true`. Only fires on /fl|/flo prompts.
1059
+ var floRun = resolveFloRun(prompt);
1060
+ if (floRun.isFlo) {
1061
+ console.log(
1062
+ '[moflo] /flo run modes (AUTHORITATIVE — use verbatim; do NOT re-derive from the skill defaults): ' +
1063
+ 'sdd=' + (floRun.sdd ? 'ON' : 'off') +
1064
+ ' verify=' + (floRun.verify ? 'ON' : 'off') +
1065
+ ' merge=' + (floRun.merge ? 'ON' : 'off') +
1066
+ ' [workflow=' + floRun.workflow + ']'
1067
+ );
1068
+ // Spell out the surprising case: a project default the user did not type.
1069
+ if (floRun.sdd && floRun.sddSrc !== 'flag') {
1070
+ console.log(
1071
+ '[moflo] sdd is ON via ' + floRun.sddSrc + ' — the spec→plan→implement→verify cycle is ' +
1072
+ 'MANDATORY this run. Author the spec before editing source (the sdd_gate blocks source ' +
1073
+ 'Write/Edit until a reviewed plan exists). One-off opt out: re-run with --no-sdd.'
1074
+ );
1075
+ }
1076
+ if (floRun.merge && floRun.mergeSrc !== 'flag') {
1077
+ console.log('[moflo] merge is ON via ' + floRun.mergeSrc + ' — the PR will be auto-merged. Opt out: --no-merge.');
1078
+ }
1079
+ }
984
1080
  if (config.context_tracking) {
985
1081
  var ic = s.interactionCount;
986
1082
  if (ic > 30) console.log('Context: CRITICAL. Commit, store learnings, suggest new session.');
@@ -72,7 +72,7 @@ Two **independent** modifiers, orthogonal to execution mode (`-n`/`-s`/`-h`) and
72
72
  | `-v` | `--verify` | **Verify-before-done** — a normal run plus the `/verify` skill (the completion gate), no spec/plan front-half. **On by default** (#1294) — this flag only forces it back on for a project that set `gates.verify_before_done: false`. |
73
73
  | `--no-sdd`, `--no-verify` | | Opt a single run out. `--no-verify` skips the (default-on) verify step. |
74
74
 
75
- Defaults seed from `moflo.yaml` — `sdd.default` (default **off**) and `gates.verify_before_done` (default **on** since #1294). So the SDD spec/plan ceremony is opt-in, but **verify-before-done runs by default**; per-run flags override (`--no-verify` to skip). `--sdd` implies `--verify` (a spec/plan without an enforced verify step drifts). In `-t`/`-r` modes (no implementation) verify is a no-op — cleared silently, with the one-line ignored note only when the user explicitly passed `-v`/`--verify`; `--sdd` in `-t` writes the spec/plan **into the ticket** rather than scaffolding artifacts. Full mechanics in `./sdd.md`.
75
+ Defaults seed from `moflo.yaml` — `sdd.default` (built-in **off**) and `gates.verify_before_done` (built-in **on** since #1294). So the SDD spec/plan ceremony is opt-in *by default*, but **a project can turn it on for every run** — never assume it is off; resolve it per the "Resolved run modes" section below. Verify-before-done runs by default; per-run flags override (`--no-verify` to skip). `--sdd` implies `--verify` (a spec/plan without an enforced verify step drifts). In `-t`/`-r` modes (no implementation) verify is a no-op — cleared silently, with the one-line ignored note only when the user explicitly passed `-v`/`--verify`; `--sdd` in `-t` writes the spec/plan **into the ticket** rather than scaffolding artifacts. Full mechanics in `./sdd.md`.
76
76
 
77
77
  ## Auto-merge (#1285)
78
78
 
@@ -124,6 +124,35 @@ Read the relevant file before executing that part of the run.
124
124
  | `./spell-engine.md` | `-wf` invocations (list, info, execute) |
125
125
  | `./sdd.md` | `-sd`/`--sdd` and `-v`/`--verify` — the spec→plan→implement→verify cycle |
126
126
 
127
+ ## Resolved run modes — read this BEFORE parsing arguments
128
+
129
+ `sddMode`, `verifyMode`, and `mergeMode` are **config-derived**. Their project defaults live in `moflo.yaml` (`sdd.default`, `gates.verify_before_done`, `merge.auto`), so they cannot be known from this document — you MUST obtain them, never assume them. A run that assumes `sdd=off` on a project with `sdd.default: true` silently skips the entire spec→plan cycle, which is the exact failure this step exists to prevent.
130
+
131
+ **Primary source — the injected resolution line.** moflo's `prompt-reminder` hook resolves all three from `moflo.yaml` on every `/flo` prompt (fresh process per prompt, so a `git pull` or a mid-session edit to `moflo.yaml` is picked up automatically) and emits them into your context immediately above the user's message:
132
+
133
+ ```
134
+ [moflo] /flo run modes (AUTHORITATIVE — use verbatim; do NOT re-derive from the skill defaults): sdd=ON verify=ON merge=off [workflow=full]
135
+ ```
136
+
137
+ Scan for that `[moflo] /flo run modes` line and assign `sddMode` / `verifyMode` / `mergeMode` from it **verbatim**. It already accounts for flags the user typed, `moflo.yaml` defaults, `--sdd` implying `--verify`, and mode applicability — do not second-guess it.
138
+
139
+ **Fallback — no such line in context** (hooks disabled, or a consumer on a gate.cjs predating this). Shell out once; the CLI resolves from the same precedence rules:
140
+
141
+ ```bash
142
+ flo sdd mode --args="$ARGUMENTS"
143
+ ```
144
+
145
+ Use `--args=` (with the `=`), **not** `--args <value>` — a value starting with `-` would otherwise be parsed as flags and you would silently get the bare config default back.
146
+
147
+ ```json
148
+ { "workflow": "full", "sdd": true, "verify": true, "merge": false,
149
+ "sddSrc": "moflo.yaml sdd.default", "verifySrc": "default", "mergeSrc": "default" }
150
+ ```
151
+
152
+ **Never skip both.** If the injected line is absent and the CLI call fails, say so explicitly to the user and stop rather than proceeding on a guessed mode — a wrongly-assumed `sdd=off` is invisible to the user until the PR lands without a spec.
153
+
154
+ When `sdd` resolved ON from config rather than a typed flag, state that in your first message (e.g. *"SDD is on via `moflo.yaml sdd.default` — running the spec→plan→implement→verify cycle."*) so the user can `--no-sdd` out before work starts.
155
+
127
156
  ## Argument parsing
128
157
 
129
158
  ```javascript
@@ -135,21 +164,13 @@ let epicBranch = null;
135
164
  let issueNumber = null;
136
165
  let titleWords = [];
137
166
 
138
- // SDD/verify modifiers (Epic #1269, #1294). Seed from moflo.yaml BEFORE parsing
139
- // so a project default applies unless a per-run flag overrides it. Read the two
140
- // keys from moflo.yaml at the project root NOTE the different absent-defaults:
141
- // sddMode ← `sdd.default` (absent false spec/plan is opt-in)
142
- // verifyMode `gates.verify_before_done` (absent TRUE verify is on by default, #1294)
143
- let sddMode = false; // -sd / --sdd (full spec→plan→implement→verify)
144
- let verifyMode = true; // -v / --verify (on by default; --no-verify opts out)
167
+ // SDD / verify / merge modifiers (Epic #1269, #1294, #1285) DO NOT resolve
168
+ // these here. They are config-derived, and the values below are placeholders,
169
+ // NOT defaults. Take them from the "Resolved run modes" step above; the loop
170
+ // below only applies what the user typed on top of that resolution.
171
+ let sddMode, verifyMode, mergeMode; // assigned from the resolution, never guessed
145
172
  let verifyExplicit = false; // did the user actually type -v/--verify? (drives the -t/-r note only, so it doesn't fire on the default)
146
173
 
147
- // Auto-merge modifier (#1285). Seed from moflo.yaml BEFORE parsing, same as
148
- // sddMode/verifyMode: read `merge.auto` at the project root (absent ⇒ false).
149
- // When true, a full run awaits the PR's merge preconditions and merges it
150
- // (Phase 5.3b) instead of stopping at "PR opened".
151
- let mergeMode = false; // -m / --merge ← moflo.yaml merge.auto
152
-
153
174
  let wfName = null, wfSubcommand = null;
154
175
  let wfArgs = [], wfNamedArgs = {};
155
176
 
@@ -2,10 +2,12 @@
2
2
 
3
3
  Spec-Driven Development for `/flo` — Epic #1269. Two independent modifiers:
4
4
 
5
- - **`-sd` / `--sdd`** — run the full **spec → plan → (review) → implement → verify** cycle. Opt-in (`sdd.default` defaults false). Implies verify.
5
+ - **`-sd` / `--sdd`** — run the full **spec → plan → (review) → implement → verify** cycle. Opt-in *by built-in default* (`sdd.default` = false), but **a project can set `sdd.default: true` and turn it on for every run**. Implies verify.
6
6
  - **`-v` / `--verify`** — verify-before-done: a normal run plus the completion gate, no spec/plan front-half. **On by default** (`gates.verify_before_done` defaults true, #1294) — `-v` is explicit, `--no-verify` opts out for one run.
7
7
 
8
- Defaults seed from `moflo.yaml` (`sdd.default` = false, `gates.verify_before_done` = true); per-run flags and `--no-sdd` / `--no-verify` override. Both are orthogonal to execution mode (`-n`/`-s`/`-h`) and `--worktree`, so `--sdd -s -wt 42` runs the SDD cycle in a swarm inside a worktree.
8
+ **Never infer whether SDD is on from these built-in defaults.** They are project-configurable, so the effective value is only knowable at run time. Resolve it as `SKILL.md` § "Resolved run modes" describes — the `[moflo] /flo run modes` line injected into context, or `flo sdd mode --args="$ARGUMENTS"`. Assuming `sdd=off` on a project with `sdd.default: true` silently skips the whole spec/plan cycle and is invisible to the user until the PR lands without a spec.
9
+
10
+ Precedence: per-run flags and `--no-sdd` / `--no-verify` override `moflo.yaml`, which overrides the built-ins. Both are orthogonal to execution mode (`-n`/`-s`/`-h`) and `--worktree`, so `--sdd -s -wt 42` runs the SDD cycle in a swarm inside a worktree.
9
11
 
10
12
  The artifact model, paths, and CLI live in `src/cli/sdd/` (`flo sdd …`). The constitution layer (CLAUDE.md + `.claude/guidance/`) is referenced by every stage — never restated in a spec.
11
13
 
package/bin/gate.cjs CHANGED
@@ -104,6 +104,19 @@ function loadSddConfig() {
104
104
  return out;
105
105
  }
106
106
 
107
+ // Parse the top-level `merge:` block from moflo.yaml (#1285). Block-scoped for
108
+ // the same reason as loadSddConfig: a bare `auto:` key can appear under other
109
+ // sections. Cross-platform: tolerates CRLF.
110
+ function loadMergeConfig() {
111
+ var out = { auto: false };
112
+ var content = MOFLO_YAML;
113
+ if (!content) return out;
114
+ var block = content.match(/^merge:[ \t]*\r?\n((?:[ \t]+.*(?:\r?\n|$))*)/m);
115
+ if (!block) return out;
116
+ if (/^\s*auto:\s*true\b/im.test(block[1])) out.auto = true;
117
+ return out;
118
+ }
119
+
107
120
  // Read moflo.yaml exactly once per gate process (#1297 review): loadGateConfig
108
121
  // and loadSddConfig both parse it, and the gate fires on every Write/Edit — a
109
122
  // second read is wasted syscalls. Single readFileSync in try/catch (no existsSync
@@ -116,6 +129,7 @@ var MOFLO_YAML = readMofloYaml();
116
129
 
117
130
  var config = loadGateConfig();
118
131
  var sddConf = loadSddConfig();
132
+ var mergeConf = loadMergeConfig();
119
133
  var command = process.argv[2];
120
134
 
121
135
  var EXEMPT = ['.claude/', '.claude\\', 'CLAUDE.md', 'MEMORY.md', 'workflow-state', 'node_modules', 'moflo.yaml'];
@@ -288,16 +302,71 @@ function detectFlMode(promptText) {
288
302
  return null;
289
303
  }
290
304
 
291
- // #1297 arm the SDD implement gate from the user prompt. Only /fl or /flo runs
292
- // can arm it. Explicit -sd/--sdd wins; --no-sdd disarms; otherwise honor the
293
- // sdd.default config. `-sd` is a distinct token from `-s` (swarm): the `d` sits
294
- // on the word boundary so `-s\b` never matches `-sd`.
295
- function detectSddMode(promptText) {
305
+ // Resolve the FULL set of /flo run modifiers from the prompt + moflo.yaml the
306
+ // single source of truth for both gate arming (#1297) and the authoritative
307
+ // announcement emitted by prompt-reminder. Two consumers, one resolver: a second
308
+ // implementation is exactly how `sdd.default: true` got silently ignored (the
309
+ // skill's prompt carried `let sddMode = false` and the model executed the literal).
310
+ //
311
+ // Precedence per key: explicit --no-X > explicit -x/--X > moflo.yaml > built-in.
312
+ // NOTE the differing built-in defaults: sdd is opt-IN (false), verify is opt-OUT
313
+ // (true, #1294), merge is opt-IN (false, #1285).
314
+ //
315
+ // `-sd` is a distinct token from `-s` (swarm): the `d` sits on the word boundary
316
+ // so `-s\b` never matches `-sd`.
317
+ function resolveFloRun(promptText) {
296
318
  var p = promptText || '';
297
- if (!/^\s*\/(?:fl|flo)\b/i.test(p)) return false;
298
- if (/(?:^|\s)--no-sdd\b/.test(p)) return false;
299
- if (/(?:^|\s)(?:-sd|--sdd)\b/.test(p)) return true;
300
- return !!sddConf.default;
319
+ var out = { isFlo: false, workflow: 'full', sdd: false, verify: false, merge: false,
320
+ sddSrc: 'default', verifySrc: 'default', mergeSrc: 'default' };
321
+ if (!/^\s*\/(?:fl|flo)\b/i.test(p)) return out;
322
+ out.isFlo = true;
323
+
324
+ // Workflow mode — decides which modifiers are even applicable.
325
+ if (/(?:^|\s)(?:-wf|--workflow)\b/.test(p)) out.workflow = 'spell-engine';
326
+ else if (/(?:^|\s)(?:-r|--research)\b/.test(p)) out.workflow = 'research';
327
+ else if (/(?:^|\s)(?:-t|--ticket)\b/.test(p)) out.workflow = 'ticket';
328
+ var epicBranch = /(?:^|\s)--epic-branch\b/.test(p);
329
+
330
+ if (/(?:^|\s)--no-sdd\b/.test(p)) { out.sdd = false; out.sddSrc = 'flag'; }
331
+ else if (/(?:^|\s)(?:-sd|--sdd)\b/.test(p)) { out.sdd = true; out.sddSrc = 'flag'; }
332
+ else if (sddConf.default) { out.sdd = true; out.sddSrc = 'moflo.yaml sdd.default'; }
333
+
334
+ if (/(?:^|\s)--no-verify\b/.test(p)) { out.verify = false; out.verifySrc = 'flag'; }
335
+ else if (/(?:^|\s)(?:-v|--verify)\b/.test(p)) { out.verify = true; out.verifySrc = 'flag'; }
336
+ else if (!config.verify_before_done) { out.verify = false; out.verifySrc = 'moflo.yaml gates.verify_before_done'; }
337
+ else { out.verify = true; out.verifySrc = 'default'; }
338
+ // --sdd implies --verify: a spec/plan without an enforced verify step drifts.
339
+ if (out.sdd && !out.verify && out.verifySrc !== 'flag') out.verify = true;
340
+
341
+ if (/(?:^|\s)--no-merge\b/.test(p)) { out.merge = false; out.mergeSrc = 'flag'; }
342
+ else if (/(?:^|\s)(?:-m|--merge)\b/.test(p)) { out.merge = true; out.mergeSrc = 'flag'; }
343
+ else if (mergeConf.auto) { out.merge = true; out.mergeSrc = 'moflo.yaml merge.auto'; }
344
+
345
+ // Applicability: -t/-r never implement, so verify is a no-op there; -r produces
346
+ // no artifacts, so sdd is a no-op too (in -t the spec/plan goes INTO the ticket,
347
+ // so sdd stays on). Only a full non-epic-branch run opens a PR to merge.
348
+ // Re-attribute anything applicability turned off, so a false never carries the
349
+ // source of the value it no longer has. This whole change exists to stop modes
350
+ // being reported inaccurately — the attribution has to be honest too.
351
+ if (out.workflow === 'ticket' || out.workflow === 'research') {
352
+ if (out.verify) out.verifySrc = out.workflow + ' mode does not implement';
353
+ out.verify = false;
354
+ }
355
+ if (out.workflow === 'research' || out.workflow === 'spell-engine') {
356
+ if (out.sdd) out.sddSrc = out.workflow + ' mode produces no spec artifacts';
357
+ out.sdd = false;
358
+ }
359
+ if (out.workflow !== 'full' || epicBranch) {
360
+ if (out.merge) out.mergeSrc = epicBranch ? '--epic-branch owns merging' : out.workflow + ' mode opens no PR';
361
+ out.merge = false;
362
+ }
363
+ return out;
364
+ }
365
+
366
+ // #1297 — arm the SDD implement gate from the user prompt. Thin wrapper so the
367
+ // arming decision and the announced decision can never disagree.
368
+ function detectSddMode(promptText) {
369
+ return resolveFloRun(promptText).sdd;
301
370
  }
302
371
 
303
372
  // Resolve the absolute specs root the same way TS specsRoot does (#1294): split
@@ -981,6 +1050,33 @@ switch (command) {
981
1050
  applyPromptStateReset(s, prompt);
982
1051
  s.interactionCount = (s.interactionCount || 0) + 1;
983
1052
  writeState(s);
1053
+ // Announce the resolved /flo run modifiers. The gate already parsed
1054
+ // moflo.yaml in THIS process (fresh per prompt — a git pull or a mid-session
1055
+ // yaml edit is picked up automatically, no cache to invalidate), so this
1056
+ // costs no extra read. Emitting it is what closes the loop: the skill used
1057
+ // to carry `let sddMode = false` as a literal and the model executed it,
1058
+ // silently ignoring `sdd.default: true`. Only fires on /fl|/flo prompts.
1059
+ var floRun = resolveFloRun(prompt);
1060
+ if (floRun.isFlo) {
1061
+ console.log(
1062
+ '[moflo] /flo run modes (AUTHORITATIVE — use verbatim; do NOT re-derive from the skill defaults): ' +
1063
+ 'sdd=' + (floRun.sdd ? 'ON' : 'off') +
1064
+ ' verify=' + (floRun.verify ? 'ON' : 'off') +
1065
+ ' merge=' + (floRun.merge ? 'ON' : 'off') +
1066
+ ' [workflow=' + floRun.workflow + ']'
1067
+ );
1068
+ // Spell out the surprising case: a project default the user did not type.
1069
+ if (floRun.sdd && floRun.sddSrc !== 'flag') {
1070
+ console.log(
1071
+ '[moflo] sdd is ON via ' + floRun.sddSrc + ' — the spec→plan→implement→verify cycle is ' +
1072
+ 'MANDATORY this run. Author the spec before editing source (the sdd_gate blocks source ' +
1073
+ 'Write/Edit until a reviewed plan exists). One-off opt out: re-run with --no-sdd.'
1074
+ );
1075
+ }
1076
+ if (floRun.merge && floRun.mergeSrc !== 'flag') {
1077
+ console.log('[moflo] merge is ON via ' + floRun.mergeSrc + ' — the PR will be auto-merged. Opt out: --no-merge.');
1078
+ }
1079
+ }
984
1080
  if (config.context_tracking) {
985
1081
  var ic = s.interactionCount;
986
1082
  if (ic > 30) console.log('Context: CRITICAL. Commit, store learnings, suggest new session.');
@@ -7,31 +7,49 @@
7
7
  * (via the doctor registry) and, transitively, by `/eldar` (which renders the
8
8
  * healer's JSON).
9
9
  *
10
+ * #1301 — the check used to demand ALL three gate tokens whenever EITHER toggle
11
+ * was on. Because `gates.verify_before_done` defaults to `true`, a consumer who
12
+ * opted OUT of SDD (`sdd.default: false`) was still forced to carry the SDD
13
+ * implement-gate (`check-before-implement`) and failed spuriously. The required
14
+ * set is now decoupled per feature (see `computeSddVerifyRequirements`), and a
15
+ * LOCKED hook block (`moflo.hooks.locked: true`) — which moflo deliberately
16
+ * won't rewrite — is reported as a warn with actionable reconciliations rather
17
+ * than a permanently-red fail that `init --fix` can never clear.
18
+ *
10
19
  * Cross-platform (Rule #1): all paths via path.join; no shelling out.
11
20
  */
12
21
  import { existsSync, readFileSync } from 'node:fs';
13
22
  import { join } from 'node:path';
14
23
  import { findProjectRoot } from '../services/project-root.js';
15
24
  import { loadMofloConfig } from '../config/moflo-config.js';
25
+ import { isHookBlockLocked } from '../services/hook-block-hash.js';
16
26
  import { errorDetail } from '../shared/utils/error-detail.js';
17
27
  const NAME = 'SDD + Verify Wiring';
18
- /** The gate cases + hook subcommands that must be present for the feature to work. */
19
- const REQUIRED_GATE_CASES = ['check-before-done', 'record-verify-run', 'check-before-implement'];
20
- const REQUIRED_HOOK_TOKENS = ['check-before-done', 'record-verify-run', 'check-before-implement'];
21
- export async function checkSddVerifyWiring() {
22
- const projectDir = findProjectRoot();
28
+ /**
29
+ * The SDD implement-gate — required only when `sdd.default === true`. This is
30
+ * the gate that pauses `/flo` at the implement step for a spec/plan checkpoint.
31
+ */
32
+ const SDD_GATE_CASES = ['check-before-implement'];
33
+ const SDD_HOOK_TOKENS = ['check-before-implement'];
34
+ /**
35
+ * The verify-before-done gate — required only when
36
+ * `gates.verify_before_done === true`. `check-before-done` blocks `gh pr create`
37
+ * until a `/verify` run is recorded; `record-verify-run` is the PostToolUse
38
+ * half that records it.
39
+ */
40
+ const VERIFY_GATE_CASES = ['check-before-done', 'record-verify-run'];
41
+ const VERIFY_HOOK_TOKENS = ['check-before-done', 'record-verify-run'];
42
+ /**
43
+ * Single source of truth for what SDD/verify wiring the consumer's toggles
44
+ * require and what is actually present. Shared by the health check and the
45
+ * `--fix` handler so the fixer can honestly re-verify its own work (#1301 — the
46
+ * old fixer reported `applied: true` on a locked block that it never touched).
47
+ *
48
+ * Pure read-only inspection — no file writes.
49
+ */
50
+ export function computeSddVerifyRequirements(projectDir = findProjectRoot()) {
23
51
  const helperGate = join(projectDir, '.claude', 'helpers', 'gate.cjs');
24
52
  const settingsPath = join(projectDir, '.claude', 'settings.json');
25
- // Uninitialised project — defer to `flo init`, don't hard-fail.
26
- if (!existsSync(helperGate) && !existsSync(settingsPath)) {
27
- return {
28
- name: NAME,
29
- status: 'warn',
30
- message: '.claude/ not initialised — SDD/verify wiring absent',
31
- fix: 'npx moflo init',
32
- };
33
- }
34
- // Read the two opt-in toggles (best-effort; default false).
35
53
  let sddDefault = false;
36
54
  let verifyGate = false;
37
55
  try {
@@ -42,56 +60,132 @@ export async function checkSddVerifyWiring() {
42
60
  catch {
43
61
  /* config unreadable — treat as defaults (both off) */
44
62
  }
45
- const issues = [];
46
- // 1. Gate logic present in the installed helper gate.cjs.
63
+ // Decouple the required set per feature (#1301). SDD's implement-gate is
64
+ // required only under sdd.default; the verify hooks only under
65
+ // verify_before_done. A verify-only consumer no longer drags in the SDD gate.
66
+ const requiredHookTokens = [
67
+ ...(sddDefault ? SDD_HOOK_TOKENS : []),
68
+ ...(verifyGate ? VERIFY_HOOK_TOKENS : []),
69
+ ];
70
+ const requiredGateCases = [
71
+ ...(sddDefault ? SDD_GATE_CASES : []),
72
+ ...(verifyGate ? VERIFY_GATE_CASES : []),
73
+ ];
74
+ const uninitialised = !existsSync(helperGate) && !existsSync(settingsPath);
75
+ const structural = [];
76
+ const missingHooks = [];
77
+ const missingGateCases = [];
78
+ let locked = false;
79
+ // gate.cjs — the installed helper must carry the cases for enabled features.
47
80
  if (existsSync(helperGate)) {
48
81
  try {
49
82
  const gate = readFileSync(helperGate, 'utf8');
50
- const missing = REQUIRED_GATE_CASES.filter((c) => !gate.includes(`case '${c}'`));
51
- if (missing.length > 0)
52
- issues.push(`gate.cjs missing: ${missing.join(', ')}`);
83
+ for (const c of requiredGateCases) {
84
+ if (!gate.includes(`case '${c}'`))
85
+ missingGateCases.push(c);
86
+ }
53
87
  }
54
88
  catch (e) {
55
- issues.push(`cannot read gate.cjs: ${errorDetail(e)}`);
89
+ structural.push(`cannot read gate.cjs: ${errorDetail(e)}`);
56
90
  }
57
91
  }
58
- else {
59
- issues.push('.claude/helpers/gate.cjs not found');
92
+ else if (!uninitialised) {
93
+ structural.push('.claude/helpers/gate.cjs not found');
60
94
  }
61
- // 2. Hooks wired in settings.json.
95
+ // settings.json the hook wiring plus the lock sentinel.
62
96
  if (existsSync(settingsPath)) {
63
97
  try {
64
- const settings = readFileSync(settingsPath, 'utf8');
65
- const missing = REQUIRED_HOOK_TOKENS.filter((t) => !settings.includes(t));
66
- if (missing.length > 0)
67
- issues.push(`settings.json missing hooks: ${missing.join(', ')}`);
98
+ const raw = readFileSync(settingsPath, 'utf8');
99
+ for (const t of requiredHookTokens) {
100
+ if (!raw.includes(t))
101
+ missingHooks.push(t);
102
+ }
103
+ try {
104
+ locked = isHookBlockLocked(JSON.parse(raw));
105
+ }
106
+ catch {
107
+ /* unparseable JSON — leave locked=false; structural check below flags it */
108
+ structural.push('.claude/settings.json is not valid JSON');
109
+ }
68
110
  }
69
111
  catch (e) {
70
- issues.push(`cannot read settings.json: ${errorDetail(e)}`);
112
+ structural.push(`cannot read settings.json: ${errorDetail(e)}`);
71
113
  }
72
114
  }
73
- else {
74
- issues.push('.claude/settings.json not found');
115
+ else if (!uninitialised) {
116
+ structural.push('.claude/settings.json not found');
75
117
  }
76
- const toggles = `sdd.default=${sddDefault}, gates.verify_before_done=${verifyGate}`;
77
- if (issues.length > 0) {
78
- // Wiring is broken. Severity depends on whether the consumer opted in: a
79
- // missing gate when verify is ON blocks nothing (fail-open) but breaks the
80
- // promised enforcement, so it's a real fault. When both toggles are off the
81
- // wiring is still expected (it ships inert), but a missing case only bites
82
- // once someone opts in — warn.
83
- const enforced = verifyGate || sddDefault;
118
+ return {
119
+ sddDefault,
120
+ verifyGate,
121
+ locked,
122
+ enforced: sddDefault || verifyGate,
123
+ uninitialised,
124
+ requiredHookTokens,
125
+ requiredGateCases,
126
+ missingHooks,
127
+ missingGateCases,
128
+ structural,
129
+ };
130
+ }
131
+ export async function checkSddVerifyWiring() {
132
+ const req = computeSddVerifyRequirements(findProjectRoot());
133
+ // Uninitialised project — defer to `flo init`, don't hard-fail.
134
+ if (req.uninitialised) {
84
135
  return {
85
136
  name: NAME,
86
- status: enforced ? 'fail' : 'warn',
87
- message: `SDD/verify wiring incomplete (${toggles}): ${issues.join('; ')}`,
88
- fix: 'npx moflo init --fix # re-syncs gate.cjs + settings.json hooks',
137
+ status: 'warn',
138
+ message: '.claude/ not initialised — SDD/verify wiring absent',
139
+ fix: 'npx moflo init',
140
+ };
141
+ }
142
+ const toggles = `sdd.default=${req.sddDefault}, gates.verify_before_done=${req.verifyGate}`;
143
+ const issues = [...req.structural];
144
+ if (req.missingGateCases.length > 0)
145
+ issues.push(`gate.cjs missing: ${req.missingGateCases.join(', ')}`);
146
+ if (req.missingHooks.length > 0)
147
+ issues.push(`settings.json missing hooks: ${req.missingHooks.join(', ')}`);
148
+ if (issues.length === 0) {
149
+ return {
150
+ name: NAME,
151
+ status: 'pass',
152
+ message: `SDD/verify wired (${toggles})${req.verifyGate ? ' — verify-before-done ENFORCED' : ''}`,
153
+ };
154
+ }
155
+ // Locked hook block: moflo won't rewrite it (init + launcher both skip on
156
+ // `isHookBlockLocked`), so pointing at `init --fix` is a dead-end and a hard
157
+ // fail is a permanently-red check the healer can never clear (#1301). When the
158
+ // ONLY outstanding problem is missing hook tokens in that locked block,
159
+ // downgrade to warn and hand over the two real reconciliations. gate.cjs and
160
+ // structural faults are unaffected by the lock, so they still fail normally.
161
+ if (req.locked &&
162
+ req.missingHooks.length > 0 &&
163
+ req.missingGateCases.length === 0 &&
164
+ req.structural.length === 0) {
165
+ const optOut = req.sddDefault && !req.verifyGate
166
+ ? 'set sdd.default: false'
167
+ : req.verifyGate && !req.sddDefault
168
+ ? 'set gates.verify_before_done: false'
169
+ : 'disable the SDD/verify toggles';
170
+ return {
171
+ name: NAME,
172
+ status: 'warn',
173
+ message: `SDD/verify wiring incomplete (${toggles}): settings.json missing hooks: ` +
174
+ `${req.missingHooks.join(', ')} — hook block is LOCKED (moflo.hooks.locked=true), ` +
175
+ `so moflo will not wire it`,
176
+ fix: `Reconcile: ${optOut} in moflo.yaml to match the locked block, OR remove ` +
177
+ `moflo.hooks.locked from .claude/settings.json and run npx moflo init --fix`,
89
178
  };
90
179
  }
180
+ // A missing required token means a feature the consumer turned ON isn't
181
+ // enforced — a real fault. With both toggles off, `requiredHookTokens`/
182
+ // `requiredGateCases` are empty, so only structural faults can reach here;
183
+ // those warn (the wiring ships inert until someone opts in).
91
184
  return {
92
185
  name: NAME,
93
- status: 'pass',
94
- message: `SDD/verify wired (${toggles})${verifyGate ? ' — verify-before-done ENFORCED' : ''}`,
186
+ status: req.enforced ? 'fail' : 'warn',
187
+ message: `SDD/verify wiring incomplete (${toggles}): ${issues.join('; ')}`,
188
+ fix: 'npx moflo init --fix # re-syncs gate.cjs + settings.json hooks',
95
189
  };
96
190
  }
97
191
  //# sourceMappingURL=doctor-checks-sdd.js.map
@@ -639,6 +639,77 @@ export async function autoFixCheck(check) {
639
639
  'Gate Health': async () => {
640
640
  return fixGateHealthHooks();
641
641
  },
642
+ // #1301 — SDD/verify hook wiring. The generic fall-through used to run
643
+ // `npx moflo init --fix` and report `applied: true` on its exit code alone,
644
+ // even when the hook block was LOCKED and init injected nothing — a false
645
+ // positive over a permanently-red check. This handler:
646
+ // 1. refuses (returns false) when the block is locked — moflo won't
647
+ // rewrite it, so claiming a fix would be a lie;
648
+ // 2. grafts ONLY the missing SDD/verify reference entries additively
649
+ // (`check-before-implement` lives in the reference block but NOT in
650
+ // repairHookWiring's REQUIRED_HOOK_WIRING, so the generic repair could
651
+ // never add it); and
652
+ // 3. re-verifies from disk and returns the honest result.
653
+ 'SDD + Verify Wiring': async () => {
654
+ const projectDir = findProjectRoot();
655
+ const { computeSddVerifyRequirements } = await import('./doctor-checks-sdd.js');
656
+ const before = computeSddVerifyRequirements(projectDir);
657
+ // Uninitialised `.claude/` — the graft path below assumes settings.json +
658
+ // gate.cjs already exist, and every `missing*` array is empty when nothing
659
+ // is on disk. Run the check's own remediation (`npx moflo init`) instead,
660
+ // preserving the pre-#1301 generic-fallthrough behavior this named handler
661
+ // now intercepts.
662
+ if (before.uninitialised) {
663
+ return runFixCommand('npx moflo init');
664
+ }
665
+ if (before.missingHooks.length === 0 && before.missingGateCases.length === 0 && before.structural.length === 0) {
666
+ return true; // nothing outstanding
667
+ }
668
+ if (before.locked && before.missingHooks.length > 0) {
669
+ output.writeln(output.warning(' Hook block is LOCKED (moflo.hooks.locked=true) — moflo will not wire SDD/verify hooks. ' +
670
+ 'Set the matching toggle to false in moflo.yaml, or remove the lock and re-run.'));
671
+ // If the locked hooks are the only gap, there is genuinely nothing we
672
+ // can do — report honestly rather than a false success.
673
+ if (before.missingGateCases.length === 0 && before.structural.length === 0)
674
+ return false;
675
+ }
676
+ // settings.json hook wiring — additively graft the missing SDD/verify
677
+ // reference entries (skip when locked; the block above already reported it).
678
+ const settingsPath = join(projectDir, '.claude', 'settings.json');
679
+ if (!before.locked && before.missingHooks.length > 0 && existsSync(settingsPath)) {
680
+ try {
681
+ const { computeHookBlockDrift, applyAdditiveRegeneration } = await import('../services/hook-block-hash.js');
682
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
683
+ const report = computeHookBlockDrift((settings.hooks ?? {}));
684
+ // Scope the graft to the tokens this check owns — leave unrelated
685
+ // drift to the Hook Block Drift check/fixer.
686
+ const wanted = before.requiredHookTokens;
687
+ const scoped = {
688
+ ...report,
689
+ missing: report.missing.filter((m) => wanted.some((t) => m.command.includes(t))),
690
+ };
691
+ if (scoped.missing.length > 0) {
692
+ applyAdditiveRegeneration(settings, scoped);
693
+ // Atomic swap — settings.json is actively re-read by Claude Code, so
694
+ // a concurrent reader must never see a truncated file (#1015).
695
+ atomicWriteFileSync(settingsPath, JSON.stringify(settings, null, 2));
696
+ }
697
+ }
698
+ catch (e) {
699
+ output.writeln(output.warning(` settings.json hook repair failed: ${errorDetail(e)}`));
700
+ return false;
701
+ }
702
+ }
703
+ // gate.cjs case drift — reuse the gate-health repair (mirrors bin/helper).
704
+ if (before.missingGateCases.length > 0) {
705
+ await fixGateHealthHooks();
706
+ }
707
+ // Truth check — re-read from disk and confirm the required wiring landed.
708
+ const after = computeSddVerifyRequirements(projectDir);
709
+ return after.missingHooks.length === 0
710
+ && after.missingGateCases.length === 0
711
+ && after.structural.length === 0;
712
+ },
642
713
  // Refresh the consumer's CLAUDE.md MoFlo block in place using the
643
714
  // shared `applyInjectionReplacement` service. Idempotent: a re-run sees
644
715
  // `state === 'in-sync'` and the autoFix dispatcher skips this entry.
@@ -16,12 +16,14 @@
16
16
  * flo sdd path <slug> [spec|plan] Print the artifact path (for skill shell-out)
17
17
  * flo sdd embed <slug> Print spec+plan as a PR-body block (#1297)
18
18
  * flo sdd index Re-index specs into memory now (also runs at session start)
19
+ * flo sdd mode --args "<flo args>" Resolve sdd/verify/merge for a /flo run as JSON
19
20
  */
20
21
  import { spawnSync } from 'node:child_process';
21
22
  import { existsSync, mkdirSync, readFileSync } from 'node:fs';
22
23
  import { dirname, join } from 'node:path';
23
24
  import { atomicWriteFileSync } from '../shared/utils/atomic-file-write.js';
24
25
  import { findProjectRoot } from '../services/project-root.js';
26
+ import { loadMofloConfig } from '../config/moflo-config.js';
25
27
  import { locateMofloRootPath } from '../services/moflo-require.js';
26
28
  import { artifactPath, assertReviewed, listSpecs, newArtifact, readArtifact, slugify, specExists, validateArtifact, writeArtifact, } from '../sdd/index.js';
27
29
  import { defaultPlanBody, defaultSpecBody } from '../sdd/templates.js';
@@ -226,6 +228,119 @@ function cmdList(ctx) {
226
228
  }
227
229
  return { success: true, data: specs };
228
230
  }
231
+ /**
232
+ * Resolve the effective `/flo` run modifiers from an argument string + moflo.yaml
233
+ * and print them as JSON. This is the FALLBACK path for the authoritative
234
+ * announcement that `bin/gate.cjs prompt-reminder` normally injects on every
235
+ * `/flo` prompt — consumers with hooks disabled (or a gate.cjs predating this
236
+ * change) still get a deterministic answer by shelling out to this.
237
+ *
238
+ * Exists because the `/flo` skill used to carry `let sddMode = false` as a
239
+ * literal beside a comment saying "seed from moflo.yaml"; the model executed the
240
+ * literal and `sdd.default: true` was silently ignored. Config-derived run modes
241
+ * must be COMPUTED, never described in prose next to a contradicting default.
242
+ *
243
+ * Precedence per key: `--no-X` > `-x`/`--X` > moflo.yaml > built-in. Built-in
244
+ * defaults differ deliberately: sdd is opt-in (false), verify is opt-out (true,
245
+ * #1294), merge is opt-in (false, #1285).
246
+ *
247
+ * SYNC: mirrors `resolveFloRun` in bin/gate.cjs (and its copy in
248
+ * init/helpers-generator.ts). Any precedence change MUST land in all three.
249
+ */
250
+ function cmdMode(ctx) {
251
+ // MUST be passed as `--args=<value>`, not `--args <value>`: the flag parser
252
+ // only consumes a following token as a value when it does not start with `-`,
253
+ // so `--args "-sd 42"` would silently degrade to `args=true` and resolve every
254
+ // run to the bare config default. The `=` form bypasses that lookahead.
255
+ // parseValue may coerce a bare numeric ("42") to a number — coerce back.
256
+ const flagArgs = ctx.flags?.args;
257
+ const raw = (typeof flagArgs === 'string' || typeof flagArgs === 'number')
258
+ ? String(flagArgs)
259
+ : (ctx.args ?? []).slice(1).join(' ');
260
+ // Tokenize on whitespace so `-sd` is matched as a whole token — it is NOT
261
+ // `-s` (swarm) + `d`.
262
+ const tokens = raw.trim().split(/\s+/).filter(Boolean);
263
+ const has = (...names) => names.some((n) => tokens.includes(n));
264
+ const cfg = loadMofloConfig(projectRoot(ctx));
265
+ let workflow = 'full';
266
+ if (has('-wf', '--workflow'))
267
+ workflow = 'spell-engine';
268
+ else if (has('-r', '--research'))
269
+ workflow = 'research';
270
+ else if (has('-t', '--ticket'))
271
+ workflow = 'ticket';
272
+ const epicBranch = has('--epic-branch');
273
+ let sdd = false;
274
+ let sddSrc = 'default';
275
+ if (has('--no-sdd')) {
276
+ sdd = false;
277
+ sddSrc = 'flag';
278
+ }
279
+ else if (has('-sd', '--sdd')) {
280
+ sdd = true;
281
+ sddSrc = 'flag';
282
+ }
283
+ else if (cfg.sdd.default) {
284
+ sdd = true;
285
+ sddSrc = 'moflo.yaml sdd.default';
286
+ }
287
+ let verify;
288
+ let verifySrc = 'default';
289
+ if (has('--no-verify')) {
290
+ verify = false;
291
+ verifySrc = 'flag';
292
+ }
293
+ else if (has('-v', '--verify')) {
294
+ verify = true;
295
+ verifySrc = 'flag';
296
+ }
297
+ else if (!cfg.gates.verify_before_done) {
298
+ verify = false;
299
+ verifySrc = 'moflo.yaml gates.verify_before_done';
300
+ }
301
+ else {
302
+ verify = true;
303
+ }
304
+ // --sdd implies --verify: a spec/plan without an enforced verify step drifts.
305
+ if (sdd && !verify && verifySrc !== 'flag')
306
+ verify = true;
307
+ let merge = false;
308
+ let mergeSrc = 'default';
309
+ if (has('--no-merge')) {
310
+ merge = false;
311
+ mergeSrc = 'flag';
312
+ }
313
+ else if (has('-m', '--merge')) {
314
+ merge = true;
315
+ mergeSrc = 'flag';
316
+ }
317
+ else if (cfg.merge.auto) {
318
+ merge = true;
319
+ mergeSrc = 'moflo.yaml merge.auto';
320
+ }
321
+ // Applicability: -t/-r never implement, so verify is a no-op there; -r and -wf
322
+ // produce no spec artifacts (in -t the spec/plan goes INTO the ticket, so sdd
323
+ // stays on). Only a full, non-epic-branch run opens a PR to merge.
324
+ // Re-attribute anything applicability turned off — a false must never carry the
325
+ // source of the value it no longer has. SYNC: mirrors bin/gate.cjs.
326
+ if (workflow === 'ticket' || workflow === 'research') {
327
+ if (verify)
328
+ verifySrc = `${workflow} mode does not implement`;
329
+ verify = false;
330
+ }
331
+ if (workflow === 'research' || workflow === 'spell-engine') {
332
+ if (sdd)
333
+ sddSrc = `${workflow} mode produces no spec artifacts`;
334
+ sdd = false;
335
+ }
336
+ if (workflow !== 'full' || epicBranch) {
337
+ if (merge)
338
+ mergeSrc = epicBranch ? '--epic-branch owns merging' : `${workflow} mode opens no PR`;
339
+ merge = false;
340
+ }
341
+ console.log(JSON.stringify({ workflow, sdd, verify, merge, sddSrc, verifySrc, mergeSrc }, null, 2));
342
+ return { success: true };
343
+ }
229
344
  function cmdPath(ctx) {
230
345
  const root = projectRoot(ctx);
231
346
  const slug = slugify(ctx.args[1] || '');
@@ -294,7 +409,8 @@ Spec-Driven Development artifacts (.moflo/specs/<slug>/{spec,plan}.md):
294
409
  list List every spec slug
295
410
  path <slug> [spec|plan] Print the artifact path
296
411
  embed <slug> Print spec+plan as a collapsible block for the PR body
297
- index Re-index specs into memory now`;
412
+ index Re-index specs into memory now
413
+ mode --args "<flo args>" Resolve sdd/verify/merge for a /flo run as JSON (moflo.yaml + flags)`;
298
414
  const sddCommand = {
299
415
  name: 'sdd',
300
416
  description: 'Spec-Driven Development artifacts (spec → plan → implement → verify)',
@@ -322,6 +438,8 @@ const sddCommand = {
322
438
  return cmdStatus(ctx);
323
439
  case 'list':
324
440
  return cmdList(ctx);
441
+ case 'mode':
442
+ return cmdMode(ctx);
325
443
  case 'path':
326
444
  return cmdPath(ctx);
327
445
  case 'embed':
@@ -293,6 +293,18 @@ function loadSddConfig() {
293
293
  return out;
294
294
  }
295
295
 
296
+ // #1285 — parse the top-level merge: block. Block-scoped like loadSddConfig.
297
+ // SYNC: mirrors bin/gate.cjs loadMergeConfig.
298
+ function loadMergeConfig() {
299
+ var out = { auto: false };
300
+ var content = MOFLO_YAML;
301
+ if (!content) return out;
302
+ var block = content.match(/^merge:[ \\t]*\\r?\\n((?:[ \\t]+.*(?:\\r?\\n|$))*)/m);
303
+ if (!block) return out;
304
+ if (/^\\s*auto:\\s*true\\b/im.test(block[1])) out.auto = true;
305
+ return out;
306
+ }
307
+
296
308
  // #1297 — read moflo.yaml once (both loaders parse it; gate fires on every
297
309
  // Write/Edit). SYNC: mirrors bin/gate.cjs readMofloYaml.
298
310
  function readMofloYaml() {
@@ -303,6 +315,7 @@ var MOFLO_YAML = readMofloYaml();
303
315
 
304
316
  var config = loadGateConfig();
305
317
  var sddConf = loadSddConfig();
318
+ var mergeConf = loadMergeConfig();
306
319
  var command = process.argv[2];
307
320
 
308
321
  var EXEMPT = ['.claude/', '.claude\\\\', 'CLAUDE.md', 'MEMORY.md', 'workflow-state', 'node_modules', 'moflo.yaml'];
@@ -388,13 +401,58 @@ function detectFlMode(promptText) {
388
401
  return null;
389
402
  }
390
403
 
391
- // #1297 arm the SDD implement gate from a /flo prompt. SYNC: mirrors bin/gate.cjs.
392
- function detectSddMode(promptText) {
404
+ // Resolve ALL /flo run modifiers from the prompt + moflo.yaml. Single source of
405
+ // truth for gate arming AND the authoritative announcement below — a second
406
+ // implementation is how sdd.default got silently ignored. Precedence per key:
407
+ // --no-X > -x/--X > moflo.yaml > built-in (sdd opt-in, verify opt-out, merge
408
+ // opt-in). SYNC: mirrors bin/gate.cjs resolveFloRun.
409
+ function resolveFloRun(promptText) {
393
410
  var p = promptText || '';
394
- if (!/^\\s*\\/(?:fl|flo)\\b/i.test(p)) return false;
395
- if (/(?:^|\\s)--no-sdd\\b/.test(p)) return false;
396
- if (/(?:^|\\s)(?:-sd|--sdd)\\b/.test(p)) return true;
397
- return !!sddConf.default;
411
+ var out = { isFlo: false, workflow: 'full', sdd: false, verify: false, merge: false,
412
+ sddSrc: 'default', verifySrc: 'default', mergeSrc: 'default' };
413
+ if (!/^\\s*\\/(?:fl|flo)\\b/i.test(p)) return out;
414
+ out.isFlo = true;
415
+
416
+ if (/(?:^|\\s)(?:-wf|--workflow)\\b/.test(p)) out.workflow = 'spell-engine';
417
+ else if (/(?:^|\\s)(?:-r|--research)\\b/.test(p)) out.workflow = 'research';
418
+ else if (/(?:^|\\s)(?:-t|--ticket)\\b/.test(p)) out.workflow = 'ticket';
419
+ var epicBranch = /(?:^|\\s)--epic-branch\\b/.test(p);
420
+
421
+ if (/(?:^|\\s)--no-sdd\\b/.test(p)) { out.sdd = false; out.sddSrc = 'flag'; }
422
+ else if (/(?:^|\\s)(?:-sd|--sdd)\\b/.test(p)) { out.sdd = true; out.sddSrc = 'flag'; }
423
+ else if (sddConf.default) { out.sdd = true; out.sddSrc = 'moflo.yaml sdd.default'; }
424
+
425
+ if (/(?:^|\\s)--no-verify\\b/.test(p)) { out.verify = false; out.verifySrc = 'flag'; }
426
+ else if (/(?:^|\\s)(?:-v|--verify)\\b/.test(p)) { out.verify = true; out.verifySrc = 'flag'; }
427
+ else if (!config.verify_before_done) { out.verify = false; out.verifySrc = 'moflo.yaml gates.verify_before_done'; }
428
+ else { out.verify = true; out.verifySrc = 'default'; }
429
+ if (out.sdd && !out.verify && out.verifySrc !== 'flag') out.verify = true;
430
+
431
+ if (/(?:^|\\s)--no-merge\\b/.test(p)) { out.merge = false; out.mergeSrc = 'flag'; }
432
+ else if (/(?:^|\\s)(?:-m|--merge)\\b/.test(p)) { out.merge = true; out.mergeSrc = 'flag'; }
433
+ else if (mergeConf.auto) { out.merge = true; out.mergeSrc = 'moflo.yaml merge.auto'; }
434
+
435
+ // Re-attribute anything applicability turned off — a false must never carry
436
+ // the source of the value it no longer has. SYNC: mirrors bin/gate.cjs.
437
+ if (out.workflow === 'ticket' || out.workflow === 'research') {
438
+ if (out.verify) out.verifySrc = out.workflow + ' mode does not implement';
439
+ out.verify = false;
440
+ }
441
+ if (out.workflow === 'research' || out.workflow === 'spell-engine') {
442
+ if (out.sdd) out.sddSrc = out.workflow + ' mode produces no spec artifacts';
443
+ out.sdd = false;
444
+ }
445
+ if (out.workflow !== 'full' || epicBranch) {
446
+ if (out.merge) out.mergeSrc = epicBranch ? '--epic-branch owns merging' : out.workflow + ' mode opens no PR';
447
+ out.merge = false;
448
+ }
449
+ return out;
450
+ }
451
+
452
+ // #1297 — arm the SDD implement gate from a /flo prompt. Thin wrapper so the
453
+ // armed decision and the announced decision can never disagree.
454
+ function detectSddMode(promptText) {
455
+ return resolveFloRun(promptText).sdd;
398
456
  }
399
457
 
400
458
  // SDD specs-root resolution + artifact helpers for check-before-implement.
@@ -779,6 +837,30 @@ switch (command) {
779
837
  applyPromptStateReset(s, prompt);
780
838
  s.interactionCount = (s.interactionCount || 0) + 1;
781
839
  writeState(s);
840
+ // Announce the resolved /flo run modifiers. moflo.yaml was already parsed in
841
+ // THIS process (fresh per prompt — a git pull or mid-session yaml edit is
842
+ // picked up automatically, no cache to invalidate), so this costs no extra
843
+ // read. SYNC: mirrors bin/gate.cjs prompt-reminder.
844
+ var floRun = resolveFloRun(prompt);
845
+ if (floRun.isFlo) {
846
+ console.log(
847
+ '[moflo] /flo run modes (AUTHORITATIVE — use verbatim; do NOT re-derive from the skill defaults): ' +
848
+ 'sdd=' + (floRun.sdd ? 'ON' : 'off') +
849
+ ' verify=' + (floRun.verify ? 'ON' : 'off') +
850
+ ' merge=' + (floRun.merge ? 'ON' : 'off') +
851
+ ' [workflow=' + floRun.workflow + ']'
852
+ );
853
+ if (floRun.sdd && floRun.sddSrc !== 'flag') {
854
+ console.log(
855
+ '[moflo] sdd is ON via ' + floRun.sddSrc + ' — the spec→plan→implement→verify cycle is ' +
856
+ 'MANDATORY this run. Author the spec before editing source (the sdd_gate blocks source ' +
857
+ 'Write/Edit until a reviewed plan exists). One-off opt out: re-run with --no-sdd.'
858
+ );
859
+ }
860
+ if (floRun.merge && floRun.mergeSrc !== 'flag') {
861
+ console.log('[moflo] merge is ON via ' + floRun.mergeSrc + ' — the PR will be auto-merged. Opt out: --no-merge.');
862
+ }
863
+ }
782
864
  if (config.context_tracking) {
783
865
  var ic = s.interactionCount;
784
866
  if (ic > 30) console.log('Context: CRITICAL. Commit, store learnings, suggest new session.');
@@ -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.10-rc.9';
5
+ export const VERSION = '4.12.1';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.11.10-rc.9",
3
+ "version": "4.12.1",
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",
@@ -95,7 +95,7 @@
95
95
  "@typescript-eslint/eslint-plugin": "^7.18.0",
96
96
  "@typescript-eslint/parser": "^7.18.0",
97
97
  "eslint": "^8.0.0",
98
- "moflo": "^4.11.10-rc.8",
98
+ "moflo": "^4.12.0",
99
99
  "tsx": "^4.21.0",
100
100
  "typescript": "^5.9.3",
101
101
  "vitest": "^4.0.0"