moflo 4.12.4 → 4.12.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/.claude/guidance/shipped/moflo-agent-rules.md +1 -0
  2. package/.claude/guidance/shipped/moflo-claude-swarm-cohesion.md +1 -1
  3. package/.claude/guidance/shipped/moflo-cli-reference.md +4 -4
  4. package/.claude/guidance/shipped/moflo-core-guidance.md +4 -4
  5. package/.claude/guidance/shipped/moflo-guidance-rules.md +21 -0
  6. package/.claude/guidance/shipped/moflo-inline-fixes.md +98 -0
  7. package/.claude/guidance/shipped/moflo-root-cause-discipline.md +10 -10
  8. package/.claude/guidance/shipped/moflo-sdd.md +5 -5
  9. package/.claude/guidance/shipped/moflo-spell-engine.md +1 -1
  10. package/.claude/guidance/shipped/moflo-yaml-reference.md +8 -8
  11. package/.claude/helpers/gate.cjs +46 -7
  12. package/.claude/helpers/pr-create-command.cjs +440 -0
  13. package/.claude/skills/eldar/SKILL.md +1 -1
  14. package/.claude/skills/fl/SKILL.md +6 -6
  15. package/.claude/skills/fl/execution-modes.md +3 -3
  16. package/.claude/skills/fl/phases.md +8 -8
  17. package/.claude/skills/fl/sdd.md +6 -6
  18. package/.claude/skills/guidance/SKILL.md +3 -0
  19. package/.claude/skills/verify/SKILL.md +4 -4
  20. package/bin/gate.cjs +46 -7
  21. package/bin/hooks.mjs +4 -1
  22. package/bin/lib/retired-files.mjs +38 -6
  23. package/bin/lib/shipped-scripts.json +2 -1
  24. package/bin/lib/skill-categories.mjs +81 -17
  25. package/bin/pr-create-command.cjs +440 -0
  26. package/bin/session-start-launcher.mjs +11 -6
  27. package/dist/src/cli/aidefence/domain/entities/threat.js +2 -1
  28. package/dist/src/cli/commands/doctor-checks-deep.js +3 -2
  29. package/dist/src/cli/commands/doctor-checks-memory-access.js +3 -2
  30. package/dist/src/cli/commands/doctor-checks-swarm.js +3 -2
  31. package/dist/src/cli/commands/hooks.js +2 -1
  32. package/dist/src/cli/commands/mcp.js +3 -2
  33. package/dist/src/cli/commands/memory.js +2 -1
  34. package/dist/src/cli/commands/performance.js +2 -1
  35. package/dist/src/cli/commands/retire.js +1 -1
  36. package/dist/src/cli/commands/spell-schedule.js +2 -1
  37. package/dist/src/cli/commands/swarm.js +216 -104
  38. package/dist/src/cli/guidance/headless.js +5 -8
  39. package/dist/src/cli/guidance/retriever.js +27 -6
  40. package/dist/src/cli/guidance/ruvbot-integration.js +2 -1
  41. package/dist/src/cli/guidance/uncertainty.js +2 -1
  42. package/dist/src/cli/hooks/bridge/official-hooks-bridge.js +2 -1
  43. package/dist/src/cli/hooks/mcp/index.js +3 -2
  44. package/dist/src/cli/hooks/reasoningbank/index.js +2 -1
  45. package/dist/src/cli/hooks/registry/index.js +2 -1
  46. package/dist/src/cli/hooks/swarm/index.js +6 -5
  47. package/dist/src/cli/init/claudemd-generator.js +2 -1
  48. package/dist/src/cli/init/helpers-generator.js +1 -1
  49. package/dist/src/cli/init/moflo-yaml-template.js +4 -4
  50. package/dist/src/cli/mcp-client.js +5 -2
  51. package/dist/src/cli/mcp-tools/agent-tools.js +4 -1
  52. package/dist/src/cli/mcp-tools/hive-mind-tools.js +4 -3
  53. package/dist/src/cli/mcp-tools/hooks-tools.js +6 -5
  54. package/dist/src/cli/mcp-tools/json-store.js +3 -2
  55. package/dist/src/cli/mcp-tools/neural-tools.js +2 -1
  56. package/dist/src/cli/mcp-tools/performance-tools.js +2 -1
  57. package/dist/src/cli/mcp-tools/session-tools.js +4 -2
  58. package/dist/src/cli/mcp-tools/spell-tools.js +2 -1
  59. package/dist/src/cli/memory/bridge-core.js +8 -2
  60. package/dist/src/cli/memory/controllers/_shared.js +8 -2
  61. package/dist/src/cli/memory/controllers/nightly-learner.js +2 -1
  62. package/dist/src/cli/memory/domain/services/memory-domain-service.js +2 -1
  63. package/dist/src/cli/memory/entries-write.js +2 -1
  64. package/dist/src/cli/memory/intelligence.js +2 -1
  65. package/dist/src/cli/memory/persistent-sona.js +1 -3
  66. package/dist/src/cli/memory/types.js +2 -3
  67. package/dist/src/cli/memory/verify.js +3 -2
  68. package/dist/src/cli/movector/graph-analyzer.js +14 -3
  69. package/dist/src/cli/neural/pattern-learner.js +4 -2
  70. package/dist/src/cli/neural/reasoning-bank.js +2 -1
  71. package/dist/src/cli/neural/reasoningbank-adapter.js +2 -1
  72. package/dist/src/cli/neural/sona-manager.js +4 -3
  73. package/dist/src/cli/production/monitoring.js +2 -1
  74. package/dist/src/cli/services/daemon-service.js +57 -46
  75. package/dist/src/cli/services/daemon-spell-executor.js +3 -2
  76. package/dist/src/cli/services/headless-worker-executor.js +4 -2
  77. package/dist/src/cli/services/learning-service.js +2 -1
  78. package/dist/src/cli/services/worker-daemon.js +2 -1
  79. package/dist/src/cli/shared/hooks/example-usage.js +3 -2
  80. package/dist/src/cli/shared/hooks/safety/git-commit.js +11 -2
  81. package/dist/src/cli/shared/hooks/session-hooks.js +3 -2
  82. package/dist/src/cli/shared/hooks/task-hooks.js +2 -1
  83. package/dist/src/cli/shared/mcp/session-manager.js +2 -1
  84. package/dist/src/cli/shared/plugins/official/hive-mind-plugin.js +2 -1
  85. package/dist/src/cli/shared/plugins/official/maestro-plugin.js +3 -2
  86. package/dist/src/cli/shared/security/index.js +6 -2
  87. package/dist/src/cli/shared/utils/atomic-file-write.js +9 -1
  88. package/dist/src/cli/shared/utils/id.js +54 -0
  89. package/dist/src/cli/shared/utils/platform.js +36 -5
  90. package/dist/src/cli/spells/commands/destructive-pattern-checker.js +6 -1
  91. package/dist/src/cli/spells/core/dry-run-validator.js +2 -1
  92. package/dist/src/cli/spells/core/interpolation.js +8 -3
  93. package/dist/src/cli/spells/core/permission-resolver.js +2 -2
  94. package/dist/src/cli/spells/core/runner.js +2 -1
  95. package/dist/src/cli/spells/core/shell.js +7 -8
  96. package/dist/src/cli/spells/factory/runner-bridge.js +3 -2
  97. package/dist/src/cli/spells/factory/runner-factory.js +3 -2
  98. package/dist/src/cli/spells/scheduler/scheduler.js +2 -1
  99. package/dist/src/cli/swarm/consensus/gossip.js +2 -1
  100. package/dist/src/cli/swarm/federation-hub.js +5 -4
  101. package/dist/src/cli/swarm/queen-coordinator.js +5 -4
  102. package/dist/src/cli/swarm/shared/events.js +2 -1
  103. package/dist/src/cli/swarm/unified-coordinator.js +3 -2
  104. package/dist/src/cli/version.js +1 -1
  105. package/package.json +2 -2
  106. package/retired-files.json +0 -72
  107. package/dist/src/cli/mcp-tools/coordination-tools.js +0 -86
  108. package/dist/src/cli/shared/security/secure-random.js +0 -142
@@ -81,6 +81,7 @@ Apply the universal rules from `.claude/guidance/moflo-guidance-rules.md`. The r
81
81
  7. Avoid the listed anti-patterns
82
82
  8. Optimize for RAG chunking
83
83
  9. End with a `## See Also` section
84
+ 10. Never cite issue or PR numbers — write the rule, not where it came from
84
85
 
85
86
  ### Audience: Humans (`-h` flag) — lighter human ruleset
86
87
 
@@ -97,6 +98,7 @@ Drop the rules that serve Claude's RAG retrieval and imperative-mood enforcement
97
98
  | 7 | Avoid anti-patterns | **Modified** | Short prose preambles ARE allowed for humans; code-comments-as-rules still bad |
98
99
  | 8 | Optimize for RAG chunking | **Drop** | Humans don't query a vector index |
99
100
  | 9 | `## See Also` section at end | **Keep** | Helps human readers traverse to related docs |
101
+ | 10 | No issue / PR citations | **Keep** | A bare issue number is unresolvable for any reader; provenance belongs in `git log` |
100
102
 
101
103
  ### Creating a new doc — scaffold this shape
102
104
 
@@ -139,6 +141,7 @@ For the loaded file, evaluate against the universal rules and report findings as
139
141
  | H2 headings are specific (not "Overview", "Configuration", "Examples") | list any generic ones |
140
142
  | Uses imperative voice for rules ("must"/"always"/"never") not hedged ("should"/"might"/"consider") | list hedged phrases found |
141
143
  | Has prose preamble before first rule | yes / no |
144
+ | Cites issue or PR numbers (`(#NNNN)`, `issue #NNNN`, `Epic #NNNN`) | list every citation found |
142
145
 
143
146
  Then propose edits as concrete diffs — never rewrite the whole file unless the user asks.
144
147
 
@@ -14,7 +14,7 @@ Prove the current change **actually does what it was supposed to** before it shi
14
14
 
15
15
  ## What satisfies the gate
16
16
 
17
- Invoking this skill (name `verify`) trips the `record-verify-run` hook, which flips the `verifyRun` state. **That alone does not open the gate** (#1332): `check-before-done` also requires the recorded verdict to be `PASS`, which reaches it from the `metadata.overall` your Step 5 store writes. So a run returning FAIL leaves `gh pr create` blocked — as it should, since the change did not meet its criteria — and a run that stores prose without `metadata` counts as *no verdict* and blocks too.
17
+ Invoking this skill (name `verify`) trips the `record-verify-run` hook, which flips the `verifyRun` state. **That alone does not open the gate**: `check-before-done` also requires the recorded verdict to be `PASS`, which reaches it from the `metadata.overall` your Step 5 store writes. So a run returning FAIL leaves `gh pr create` blocked — as it should, since the change did not meet its criteria — and a run that stores prose without `metadata` counts as *no verdict* and blocks too.
18
18
 
19
19
  **Only `/verify` satisfies it** — `/ward` and `/quicken` are targeted audits, not an end-to-end verification. A source edit *after* verifying invalidates both the flag and the verdict, so run `/verify` as the last step before the PR.
20
20
 
@@ -26,7 +26,7 @@ Search memory before reading files, same as any task (satisfies `memory_first`,
26
26
  mcp__moflo__memory_search { query: "verify <feature keywords>", namespace: "verify" }
27
27
  ```
28
28
 
29
- **Search `verify`, never `learnings`.** Verdict records moved out of `learnings` in #1375 — see Step 5.
29
+ **Search `verify`, never `learnings`.** Verdict records moved out of `learnings` — see Step 5.
30
30
 
31
31
  ## Step 1 — Locate the acceptance criteria
32
32
 
@@ -97,13 +97,13 @@ mcp__moflo__memory_store {
97
97
  }
98
98
  ```
99
99
 
100
- **Store to `verify`, never `learnings` (#1375).** A verdict is audit exhaust — one commit, one issue, criteria that never apply again — and `memory_search` returns a bounded set, so every verdict parked in `learnings` displaced a reusable lesson. The move is free: `record-learnings-stored` matches any `memory_store`, and `record-verify-outcome` keys on the `verify:` **key prefix**, not the namespace. Both gates still fire.
100
+ **Store to `verify`, never `learnings`.** A verdict is audit exhaust — one commit, one issue, criteria that never apply again — and `memory_search` returns a bounded set, so every verdict parked in `learnings` displaced a reusable lesson. The move is free: `record-learnings-stored` matches any `memory_store`, and `record-verify-outcome` keys on the `verify:` **key prefix**, not the namespace. Both gates still fire.
101
101
 
102
102
  **Why both.** `value` is what gets embedded for semantic search, so it stays prose — a JSON blob there would degrade every future `verify` search. `metadata` is stored verbatim and not embedded, so the structure survives without that cost. `memory_retrieve` returns the parsed `metadata` object for non-chunk entries, which is what makes the record readable rather than write-only.
103
103
 
104
104
  **`freshlyExecuted` is required on every criterion, not optional.** Step 3 deliberately permits *citing* an earlier green run instead of re-executing. That is a sound cost optimisation, but it means evidence may be a citation rather than a fresh result — set `freshlyExecuted: false` when you cited. Without that flag a later reader silently inherits stale evidence and cannot tell a re-verified criterion from a re-cited one.
105
105
 
106
- **Never record an exit code.** Claude Code's `tool_response` for Bash carries `stdout`/`stderr` but **no exit status**, and PostToolUse does not fire at all when a command exits non-zero (#1322). `evidence` is therefore descriptive by necessity; a field named `exitCode` would be agent-narrated fiction, which is the problem this record exists to remove.
106
+ **Never record an exit code.** Claude Code's `tool_response` for Bash carries `stdout`/`stderr` but **no exit status**, and PostToolUse does not fire at all when a command exits non-zero. `evidence` is therefore descriptive by necessity; a field named `exitCode` would be agent-narrated fiction, which is the problem this record exists to remove.
107
107
 
108
108
  `overall` MUST agree with the Step 4 rule — PASS iff no criterion is FAIL or UNVERIFIED. Record a FAIL as a FAIL; the store is the audit trail, and a verdict that only ever reads PASS is worth nothing.
109
109
 
package/bin/gate.cjs CHANGED
@@ -963,6 +963,45 @@ function creditIsLive(flag, stored, scope) {
963
963
  return stored === now;
964
964
  }
965
965
 
966
+ // #1410 — is this Bash command actually a `gh pr create` invocation? Delegates
967
+ // to pr-create-command.cjs, which sanitises data regions (quotes, heredoc
968
+ // bodies, comments) before looking for the command, so the gate neither misses
969
+ // real invocations (newline-separated, piped, parenthesised) nor fires on
970
+ // commands that merely quote the literal (`git commit -m "...gh pr create..."`).
971
+ //
972
+ // Fail-safe, in BOTH directions, because this runs on every Bash call in every
973
+ // consumer. gate-hook.mjs maps a non-zero exit from this script to exit 2, so an
974
+ // uncaught throw here does not degrade one gate — it blocks every Bash call the
975
+ // consumer makes. So a load failure (partial `.claude/helpers` sync mid-upgrade,
976
+ // hand-pruned install) AND a throw from the matcher itself both fall back to the
977
+ // pre-#1410 regex: previous behaviour, not a wedged session.
978
+ //
979
+ // Not silent (#854, hook-authoring §4): the fallback path advises on stderr and
980
+ // continues. If this ever fires it means the matcher crashed, which is worth
981
+ // being loud about — and it cannot spam a healthy session, because a healthy
982
+ // session never reaches it.
983
+ var LEGACY_PR_CREATE_RE = /(?:^|&&\s*|\|\|\s*|;\s*)\s*(?:[A-Z_][A-Z0-9_]*=\S+\s+)*gh\s+pr\s+create\b/;
984
+ var prCreateMatcher = null;
985
+ function isPrCreateCommand(cmd) {
986
+ if (prCreateMatcher === null) {
987
+ try {
988
+ prCreateMatcher = require('./pr-create-command.cjs').isPrCreateCommand;
989
+ } catch (e) {
990
+ prCreateMatcher = false;
991
+ process.stderr.write('moflo: pr-create-command.cjs unavailable (' + (e && e.message) + ') — PR gates fall back to the legacy matcher. Run `npx flo doctor --fix`.\n');
992
+ }
993
+ if (typeof prCreateMatcher !== 'function') prCreateMatcher = false;
994
+ }
995
+ if (prCreateMatcher) {
996
+ try {
997
+ return prCreateMatcher(cmd);
998
+ } catch (e) {
999
+ process.stderr.write('moflo: pr-create matcher threw (' + (e && e.message) + ') — falling back to the legacy matcher. Please report with the command that triggered it.\n');
1000
+ }
1001
+ }
1002
+ return LEGACY_PR_CREATE_RE.test(cmd);
1003
+ }
1004
+
966
1005
  // Classifier-aware simplify gate skip. Returns a string reason if the gate
967
1006
  // can be auto-passed, or null if /simplify must run. Uses simplify-classify.cjs
968
1007
  // so the gate's "trivial" definition matches the skill's exactly.
@@ -1600,12 +1639,12 @@ switch (command) {
1600
1639
  break;
1601
1640
  }
1602
1641
  case 'check-before-pr': {
1603
- // Anchored to command-start (or chained via && / || / ;) so heredoc bodies
1604
- // and quoted strings that contain the literal "gh pr create" don't trip
1605
- // the gate during regular `git commit -m "...gh pr create..."` flows. The
1606
- // optional ENV=val prefix segment catches `GH_TOKEN=x gh pr create`.
1642
+ // Anchored to command-start so heredoc bodies and quoted strings that
1643
+ // contain the literal "gh pr create" don't trip the gate during regular
1644
+ // `git commit -m "...gh pr create..."` flows, while still catching the
1645
+ // chained, piped, parenthesised, and multi-line shapes (#1410).
1607
1646
  var cmd = process.env.TOOL_INPUT_command || '';
1608
- if (!/(?:^|&&\s*|\|\|\s*|;\s*)\s*(?:[A-Z_][A-Z0-9_]*=\S+\s+)*gh\s+pr\s+create\b/.test(cmd)) break;
1647
+ if (!isPrCreateCommand(cmd)) break;
1609
1648
  // #1374 — close the loop the TaskCreate reminder opens. Advisory: stdout,
1610
1649
  // no exit, and worded as a reminder, because a message may only claim to
1611
1650
  // block when it blocks (#1326). An open task list is a reporting failure,
@@ -1680,7 +1719,7 @@ switch (command) {
1680
1719
  }
1681
1720
  }
1682
1721
  var missing = [];
1683
- if (config.testing_gate && !s.testsRun) missing.push('tests have not run green since the last code edit (run npm test, vitest, jest, pytest, or similar — a run whose output reports failures does not count, #1322)');
1722
+ if (config.testing_gate && !s.testsRun) missing.push('tests have not run green since the last code edit (run npm test, vitest, jest, pytest, or similar — a run whose output reports failures does not count)');
1684
1723
  if (config.simplify_gate && !s.simplifyRun) missing.push('/flo-simplify (or /distill) has not run since the last code edit');
1685
1724
  if (config.learnings_gate && !s.learningsStored) missing.push('learnings have not been stored (call mcp__moflo__memory_store)');
1686
1725
  if (missing.length === 0) break;
@@ -1716,7 +1755,7 @@ switch (command) {
1716
1755
  // command (docs-only diffs are exempt, so this never blocks a docs PR).
1717
1756
  if (!config.verify_before_done) break;
1718
1757
  var cmd = process.env.TOOL_INPUT_command || '';
1719
- if (!/(?:^|&&\s*|\|\|\s*|;\s*)\s*(?:[A-Z_][A-Z0-9_]*=\S+\s+)*gh\s+pr\s+create\b/.test(cmd)) break;
1758
+ if (!isPrCreateCommand(cmd)) break;
1720
1759
  // No-source-files exemption — a docs-only / path-inert diff needs no verify.
1721
1760
  var changedD = getChangedFilesVsBase();
1722
1761
  if (changedD && changedD.length > 0) {
package/bin/hooks.mjs CHANGED
@@ -23,6 +23,9 @@ import { spawn } from 'child_process';
23
23
  import { existsSync, appendFileSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'fs';
24
24
  import { resolve, dirname } from 'path';
25
25
  import { fileURLToPath, pathToFileURL } from 'url';
26
+ // A builtin, deliberately: this file is a shipped hook entry point, and pulling
27
+ // in a bin/lib/ module here would widen its resolve closure for one call site.
28
+ import { randomBytes } from 'node:crypto';
26
29
  import { createProcessManager } from './lib/process-manager.mjs';
27
30
  import { shouldDaemonAutoStart } from './lib/daemon-config.mjs';
28
31
  import { resolveMofloBin } from './lib/resolve-bin.mjs';
@@ -240,7 +243,7 @@ async function main() {
240
243
  case 'pre-task': {
241
244
  const description = getArg('description') || process.env.TOOL_INPUT_prompt;
242
245
  if (description) {
243
- const taskId = `task-${Date.now()}`;
246
+ const taskId = `task-${Date.now()}-${randomBytes(6).toString('hex')}`;
244
247
  await runClaudeFlow('hooks', ['pre-task', '--task-id', taskId, '--description', description]);
245
248
  }
246
249
  break;
@@ -123,6 +123,8 @@ export function loadRetiredManifest(manifestPath) {
123
123
  /**
124
124
  * Decide what to do with a consumer-side path against a retirement entry:
125
125
  *
126
+ * - 'shipped' — the installed package STILL ships this path, so the
127
+ * manifest entry contradicts the package; skip it entirely
126
128
  * - 'absent' — path doesn't exist on disk; nothing to do
127
129
  * - 'prune' — file exists and content hash matches a known-shipped
128
130
  * value; safe to delete (consumer didn't customize)
@@ -131,13 +133,34 @@ export function loadRetiredManifest(manifestPath) {
131
133
  * - 'unknown' — file exists but its hash couldn't be read (transient
132
134
  * error); leave alone, retry next session
133
135
  *
136
+ * The `shipped` check exists because a path can be retired and later RESTORED
137
+ * under the same name (#1414). The stale entry then holds only pre-deletion
138
+ * hashes, so the consumer's copy — which moflo itself just synced from the
139
+ * package — matches nothing and is reported as a customized retired file every
140
+ * session. Asking the package what it ships is the only source that cannot
141
+ * drift from the manifest, so it wins.
142
+ *
143
+ * `packageRoot` is optional and defaults to no cross-check: callers that don't
144
+ * know where the package lives keep exactly the pre-#1414 semantics.
145
+ *
146
+ * Order matters. The consumer-path check runs FIRST because most entries are
147
+ * `absent` on a typical install (already pruned, or never had the file), and
148
+ * those need no package stat at all — this runs on every consumer's session
149
+ * start, where a doubled stat count is 10-40ms on Windows with a scanner or a
150
+ * network `node_modules`. Entries that are absent locally are also exactly the
151
+ * ones a contradiction cannot hurt: there is nothing to delete or report.
152
+ *
134
153
  * @param {string} projectRoot
135
154
  * @param {{path:string, knownContentHashes:string[]}} entry
136
- * @returns {{ action: 'absent'|'prune'|'preserve'|'unknown', actualHash: string|null }}
155
+ * @param {string|null} [packageRoot] - root of the installed moflo package
156
+ * @returns {{ action: 'shipped'|'absent'|'prune'|'preserve'|'unknown', actualHash: string|null }}
137
157
  */
138
- export function classifyRetiredFile(projectRoot, entry) {
158
+ export function classifyRetiredFile(projectRoot, entry, packageRoot = null) {
139
159
  const abs = resolve(projectRoot, entry.path);
140
160
  if (!existsSync(abs)) return { action: 'absent', actualHash: null };
161
+ if (packageRoot && existsSync(resolve(packageRoot, entry.path))) {
162
+ return { action: 'shipped', actualHash: null };
163
+ }
141
164
  const actualHash = fileSha256(abs);
142
165
  if (!actualHash) return { action: 'unknown', actualHash: null };
143
166
  if (entry.knownContentHashes.includes(actualHash)) {
@@ -167,15 +190,24 @@ export function classifyRetiredFile(projectRoot, entry) {
167
190
  * `preservedDetails` carries the same set with the manifest's retirement
168
191
  * provenance attached, for the machine-readable record (#1307 finding 3).
169
192
  *
193
+ * `shipped` collects entries the installed package still ships (#1414). They
194
+ * are neither pruned nor preserved — the manifest is simply wrong about them,
195
+ * and reporting them as retained is what produced the false banner. Nothing
196
+ * reads it today; it is here so the return value is a complete accounting of
197
+ * every entry's disposition rather than silently dropping one class, and so
198
+ * tests can assert the skip happened instead of inferring it from an absence.
199
+ *
170
200
  * @param {string} projectRoot
171
201
  * @param {string} manifestPath
172
- * @returns {{ pruned: string[], preserved: string[], preservedDetails: Array<{path:string, retiredIn?:string, retiredBy?:string}>, unknown: string[], failed: Array<{path:string, message:string}> }}
202
+ * @param {string|null} [packageRoot] - root of the installed moflo package
203
+ * @returns {{ pruned: string[], preserved: string[], preservedDetails: Array<{path:string, retiredIn?:string, retiredBy?:string}>, shipped: string[], unknown: string[], failed: Array<{path:string, message:string}> }}
173
204
  */
174
- export function applyRetiredPrune(projectRoot, manifestPath) {
205
+ export function applyRetiredPrune(projectRoot, manifestPath, packageRoot = null) {
175
206
  const { entries } = loadRetiredManifest(manifestPath);
176
- const report = { pruned: [], preserved: [], preservedDetails: [], unknown: [], failed: [] };
207
+ const report = { pruned: [], preserved: [], preservedDetails: [], shipped: [], unknown: [], failed: [] };
177
208
  for (const entry of entries) {
178
- const { action } = classifyRetiredFile(projectRoot, entry);
209
+ const { action } = classifyRetiredFile(projectRoot, entry, packageRoot);
210
+ if (action === 'shipped') { report.shipped.push(entry.path); continue; }
179
211
  if (action === 'absent') continue;
180
212
  if (action === 'preserve') {
181
213
  report.preserved.push(entry.path);
@@ -22,7 +22,8 @@
22
22
  "gate-hook.mjs",
23
23
  "prompt-hook.mjs",
24
24
  "hook-handler.cjs",
25
- "simplify-classify.cjs"
25
+ "simplify-classify.cjs",
26
+ "pr-create-command.cjs"
26
27
  ],
27
28
  "sourceHelperFiles": [
28
29
  "auto-memory-hook.mjs",
@@ -74,6 +74,13 @@ export const SKILL_CATEGORY_NAMES = Object.keys(SKILL_CATEGORIES_MAP);
74
74
  */
75
75
  export const ALWAYS_INSTALLED_SKILLS = ['flo', 'fl'];
76
76
 
77
+ /**
78
+ * How far `parseSkillCategories` will look ahead for the `]` closing a
79
+ * flow-style `categories: [...]` list. Bounded so an unterminated bracket costs
80
+ * a fixed amount of work rather than a scan to end-of-file per candidate line.
81
+ */
82
+ const FLOW_LOOKAHEAD_LINES = 50;
83
+
77
84
  /**
78
85
  * Extract the selected skill categories from raw `moflo.yaml` text.
79
86
  *
@@ -89,6 +96,14 @@ export const ALWAYS_INSTALLED_SKILLS = ['flo', 'fl'];
89
96
  * - core
90
97
  * - memory
91
98
  *
99
+ * Scanned line by line rather than with one multi-line regex. The regex form
100
+ * spanned the gap between `skills:` and `categories:` with
101
+ * `(?:[ \t]+[^\r\n]*\r?\n)*?`, whose `[ \t]+` and `[^\r\n]*` overlap — every
102
+ * indented line could be split many ways, so a `skills:` block with no
103
+ * `categories:` key backtracked exponentially and hung the launcher at session
104
+ * start on the consumer's own `moflo.yaml` (#1418). Line scanning is linear and
105
+ * matches the same inputs.
106
+ *
92
107
  * @param {string} yamlContent
93
108
  * @returns {string[]|null} selected categories, or null when unconfigured
94
109
  * (meaning "no restriction" — sync everything).
@@ -102,29 +117,78 @@ export const ALWAYS_INSTALLED_SKILLS = ['flo', 'fl'];
102
117
  export function parseSkillCategories(yamlContent) {
103
118
  if (typeof yamlContent !== 'string' || yamlContent.length === 0) return null;
104
119
 
105
- // Flow style: categories: [a, b]
106
- const flow = yamlContent.match(/^[ \t]*skills:[ \t]*\r?\n(?:[ \t]+[^\r\n]*\r?\n)*?[ \t]+categories:[ \t]*\[([^\]]*)\]/m);
107
- if (flow) {
108
- return flow[1]
109
- .split(',')
110
- .map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
111
- .filter((s) => s.length > 0);
112
- }
120
+ const lines = yamlContent.split(/\r?\n/);
113
121
 
114
- // Block style: categories:\n - a\n - b
115
- const block = yamlContent.match(/^[ \t]*skills:[ \t]*\r?\n(?:[ \t]+[^\r\n]*\r?\n)*?[ \t]+categories:[ \t]*\r?\n((?:[ \t]*-[ \t]*[^\r\n]+\r?\n?)+)/m);
116
- if (block) {
117
- return block[1]
118
- .split(/\r?\n/)
119
- .map((line) => line.match(/^[ \t]*-[ \t]*(.+?)[ \t]*$/))
120
- .filter(Boolean)
121
- .map((m) => m[1].replace(/^['"]|['"]$/g, '').trim())
122
- .filter((s) => s.length > 0);
122
+ for (let i = 0; i < lines.length; i++) {
123
+ if (!/^[ \t]*skills:[ \t]*$/.test(lines[i])) continue;
124
+
125
+ // Walk the indented block under `skills:`. A dedent (or a blank line) ends
126
+ // it, exactly as the old `[ \t]+`-per-line repetition required.
127
+ for (let j = i + 1; j < lines.length; j++) {
128
+ const line = lines[j];
129
+ if (!/^[ \t]/.test(line)) break;
130
+
131
+ // Flow style: categories: [a, b], possibly wrapped across lines — the old
132
+ // regex accepted that because its `[^\]]*` spanned newlines, so dropping
133
+ // it would be a silent behaviour regression.
134
+ //
135
+ // Lookahead is bounded and accumulates FORWARD rather than re-slicing the
136
+ // remainder of the file. `lines.slice(j).join('\n')` on every candidate
137
+ // line is O(n^2) on a file with many unterminated `categories: [` lines —
138
+ // a smaller version of exactly the attacker-controlled-YAML blowup this
139
+ // rewrite exists to close. A real flow list is one or two lines; 50 is
140
+ // already far past any legitimate input.
141
+ if (/^[ \t]+categories:[ \t]*\[/.test(line)) {
142
+ let buffer = line;
143
+ for (let k = j + 1; k < lines.length && k <= j + FLOW_LOOKAHEAD_LINES; k++) {
144
+ if (buffer.includes(']')) break;
145
+ buffer += '\n' + lines[k];
146
+ }
147
+ const flow = buffer.match(/^[ \t]+categories:[ \t]*\[([^\]]*)\]/);
148
+ if (flow) {
149
+ return flow[1]
150
+ .split(',')
151
+ .map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
152
+ .filter((s) => s.length > 0);
153
+ }
154
+ }
155
+
156
+ // Block style: categories:\n - a\n - b
157
+ if (/^[ \t]+categories:[ \t]*$/.test(line)) return parseBlockItems(lines, j + 1);
158
+ }
123
159
  }
124
160
 
125
161
  return null;
126
162
  }
127
163
 
164
+ /**
165
+ * Collect `- item` lines following a bare `categories:` key.
166
+ *
167
+ * Returns `null` when there is not a single dash item, mirroring the old block
168
+ * regex's `+` quantifier failing to match: a `categories:` key with nothing
169
+ * under it is unconfigured ("sync everything"), NOT an empty selection ("sync
170
+ * nothing"). Conflating those would strip every skill from a consumer who left
171
+ * the key dangling (Rule #2).
172
+ *
173
+ * @param {string[]} lines
174
+ * @param {number} start - index of the first candidate item line
175
+ * @returns {string[]|null}
176
+ */
177
+ function parseBlockItems(lines, start) {
178
+ const items = [];
179
+ let sawItem = false;
180
+
181
+ for (let k = start; k < lines.length; k++) {
182
+ const item = lines[k].match(/^[ \t]*-[ \t]*(.+?)[ \t]*$/);
183
+ if (!item) break;
184
+ sawItem = true;
185
+ const value = item[1].replace(/^['"]|['"]$/g, '').trim();
186
+ if (value.length > 0) items.push(value);
187
+ }
188
+
189
+ return sawItem ? items : null;
190
+ }
191
+
128
192
  /**
129
193
  * Resolve the set of top-level skill directory names the launcher must NOT
130
194
  * sync, given a selection.