@pareto4/pp-core 1.11.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +31 -0
  3. package/agents/pp-code-fixer.md +1 -1
  4. package/agents/pp-debug-session-manager.md +1 -1
  5. package/agents/pp-debugger.md +1 -1
  6. package/agents/pp-executor.md +20 -10
  7. package/agents/pp-intel-updater.md +1 -1
  8. package/agents/pp-phase-researcher.md +1 -1
  9. package/agents/pp-plan-checker.md +5 -5
  10. package/agents/pp-planner.md +10 -24
  11. package/agents/pp-project-researcher.md +1 -1
  12. package/agents/pp-research-synthesizer.md +1 -1
  13. package/agents/pp-ui-researcher.md +1 -1
  14. package/agents/pp-verifier.md +8 -4
  15. package/bin/install.js +429 -82
  16. package/commands/pp-autonomous.md +8 -6
  17. package/commands/pp-discovery-to-build.md +1 -1
  18. package/commands/pp-discovery.md +3 -2
  19. package/commands/pp-discuss-phase.md +2 -2
  20. package/commands/pp-execute-phase.md +3 -1
  21. package/commands/pp-graphify.md +5 -5
  22. package/commands/pp-import.md +1 -1
  23. package/commands/pp-map-codebase.md +1 -1
  24. package/commands/pp-ns-project.md +2 -3
  25. package/commands/pp-ns-workflow.md +1 -1
  26. package/commands/pp-plan-phase.md +2 -1
  27. package/commands/pp-verify-work.md +2 -1
  28. package/gemini-extension.json +1 -1
  29. package/hooks/dist/pp-cursor-session-start.js +2 -2
  30. package/hooks/dist/pp-session-state.sh +1 -1
  31. package/hooks/dist/pp-statusline.js +21 -2
  32. package/hooks/pp-cursor-session-start.js +2 -2
  33. package/hooks/pp-session-state.sh +1 -1
  34. package/hooks/pp-statusline.js +21 -2
  35. package/package.json +18 -5
  36. package/pareto-skills/pp-project-setup/SKILL.md +5 -63
  37. package/pp-core/bin/detect-build-commands.sh +182 -0
  38. package/pp-core/bin/lib/capability-registry.cjs +65 -7
  39. package/pp-core/bin/lib/check-command-router.cjs +79 -38
  40. package/pp-core/bin/lib/clusters.cjs +2 -3
  41. package/pp-core/bin/lib/command-aliases.cjs +36 -0
  42. package/pp-core/bin/lib/command-arg-projection.cjs +99 -1
  43. package/pp-core/bin/lib/commands.cjs +21 -6
  44. package/pp-core/bin/lib/compose-bindings.cjs +128 -15
  45. package/pp-core/bin/lib/config-schema.cjs +16 -0
  46. package/pp-core/bin/lib/config.cjs +142 -27
  47. package/pp-core/bin/lib/core-utils.cjs +43 -7
  48. package/pp-core/bin/lib/discovery-input-check.cjs +218 -0
  49. package/pp-core/bin/lib/discovery-rebaseline.cjs +1372 -0
  50. package/pp-core/bin/lib/discovery-research-track.cjs +979 -0
  51. package/pp-core/bin/lib/discovery-rollup.cjs +476 -0
  52. package/pp-core/bin/lib/discovery-screens.cjs +1995 -0
  53. package/pp-core/bin/lib/discovery-sweep.cjs +390 -0
  54. package/pp-core/bin/lib/discovery-table.cjs +98 -0
  55. package/pp-core/bin/lib/dispatch-plan.cjs +472 -0
  56. package/pp-core/bin/lib/frontmatter.cjs +17 -2
  57. package/pp-core/bin/lib/init-command-router.cjs +8 -5
  58. package/pp-core/bin/lib/init-contract.cjs +213 -33
  59. package/pp-core/bin/lib/init-interactive.cjs +20 -1
  60. package/pp-core/bin/lib/init.cjs +124 -134
  61. package/pp-core/bin/lib/install-profiles.cjs +5 -0
  62. package/pp-core/bin/lib/install-run.cjs +536 -0
  63. package/pp-core/bin/lib/io.cjs +15 -0
  64. package/pp-core/bin/lib/loop-resolver.cjs +24 -0
  65. package/pp-core/bin/lib/milestone.cjs +345 -48
  66. package/pp-core/bin/lib/phase-command-router.cjs +23 -1
  67. package/pp-core/bin/lib/phase-locator.cjs +15 -6
  68. package/pp-core/bin/lib/phase-scope.cjs +91 -0
  69. package/pp-core/bin/lib/phase-tier.cjs +467 -0
  70. package/pp-core/bin/lib/phase.cjs +247 -21
  71. package/pp-core/bin/lib/phases-command-router.cjs +2 -0
  72. package/pp-core/bin/lib/plan-graph.cjs +368 -0
  73. package/pp-core/bin/lib/planning-workspace.cjs +53 -0
  74. package/pp-core/bin/lib/platform-bindings.cjs +32 -2
  75. package/pp-core/bin/lib/profile-output.cjs +11 -3
  76. package/pp-core/bin/lib/prohibition-enforcement.cjs +112 -0
  77. package/pp-core/bin/lib/roadmap-parser.cjs +366 -99
  78. package/pp-core/bin/lib/roadmap.cjs +141 -39
  79. package/pp-core/bin/lib/runtime-hooks-surface.cjs +1 -1
  80. package/pp-core/bin/lib/shell-command-projection.cjs +24 -0
  81. package/pp-core/bin/lib/state-command-router.cjs +82 -13
  82. package/pp-core/bin/lib/state-document.cjs +124 -7
  83. package/pp-core/bin/lib/state.cjs +784 -257
  84. package/pp-core/bin/lib/template.cjs +22 -6
  85. package/pp-core/bin/lib/vault-command-router.cjs +57 -6
  86. package/pp-core/bin/lib/vault-practices.cjs +6 -36
  87. package/pp-core/bin/lib/vault-write-back.cjs +20 -4
  88. package/pp-core/bin/lib/vault.cjs +555 -23
  89. package/pp-core/bin/lib/verify.cjs +53 -31
  90. package/pp-core/bin/lib/workstream.cjs +1 -1
  91. package/pp-core/bin/lib/worktree-safety.cjs +26 -15
  92. package/pp-core/bin/portable-timeout.sh +89 -0
  93. package/pp-core/bin/pp-tools.cjs +158 -8
  94. package/pp-core/bin/shared/config-defaults.manifest.json +7 -0
  95. package/pp-core/bin/shared/config-schema.manifest.json +3 -0
  96. package/pp-core/references/autonomous-best-effort.md +461 -0
  97. package/pp-core/references/autonomous-lifecycle.md +146 -0
  98. package/pp-core/references/autonomous-smart-discuss.md +16 -12
  99. package/pp-core/references/checkpoints.md +1 -1
  100. package/pp-core/references/discovery-id-conventions.md +202 -0
  101. package/pp-core/references/discovery-rebaseline.md +254 -0
  102. package/pp-core/references/discovery-research-track.md +283 -0
  103. package/pp-core/references/discovery-traceability.md +400 -0
  104. package/pp-core/references/loop-hook-dispatch.md +7 -0
  105. package/pp-core/references/mandatory-initial-read.md +1 -1
  106. package/pp-core/references/planner-gap-closure.md +1 -0
  107. package/pp-core/references/planner-human-verify-mode.md +1 -1
  108. package/pp-core/references/planner-load-graph-context.md +1 -1
  109. package/pp-core/references/planner-plan-graph.md +66 -0
  110. package/pp-core/references/planner-tdd.md +1 -0
  111. package/pp-core/references/planning-config.md +8 -0
  112. package/pp-core/references/sketch-tooling.md +4 -0
  113. package/pp-core/references/tdd.md +1 -0
  114. package/pp-core/templates/claude-md.md +1 -1
  115. package/pp-core/templates/phase-prompt.md +6 -2
  116. package/pp-core/templates/prototype-review/review.js +303 -0
  117. package/pp-core/templates/state.md +6 -0
  118. package/pp-core/workflows/_runtime-launcher.snippet.sh +1 -1
  119. package/pp-core/workflows/add-backlog.md +1 -1
  120. package/pp-core/workflows/add-phase.md +1 -1
  121. package/pp-core/workflows/add-tests.md +1 -1
  122. package/pp-core/workflows/add-todo.md +1 -1
  123. package/pp-core/workflows/ai-integration-phase.md +2 -2
  124. package/pp-core/workflows/audit-fix.md +12 -16
  125. package/pp-core/workflows/audit-milestone.md +1 -1
  126. package/pp-core/workflows/audit-uat.md +1 -1
  127. package/pp-core/workflows/autonomous.md +68 -306
  128. package/pp-core/workflows/check-todos.md +1 -1
  129. package/pp-core/workflows/cleanup.md +1 -1
  130. package/pp-core/workflows/code-review-fix.md +1 -1
  131. package/pp-core/workflows/code-review.md +19 -7
  132. package/pp-core/workflows/complete-milestone.md +41 -60
  133. package/pp-core/workflows/debug.md +1 -1
  134. package/pp-core/workflows/diagnose-issues.md +1 -1
  135. package/pp-core/workflows/discovery-sprint/gap-analysis.md +140 -0
  136. package/pp-core/workflows/discovery-sprint/input-package.md +140 -0
  137. package/pp-core/workflows/discovery-sprint/prefill.md +92 -0
  138. package/pp-core/workflows/discovery-sprint/prototype.md +257 -0
  139. package/pp-core/workflows/discovery-sprint/rebaseline.md +276 -0
  140. package/pp-core/workflows/discovery-sprint/session-13-gates.md +24 -0
  141. package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +92 -0
  142. package/pp-core/workflows/discovery-sprint/sessions/02-as-is.md +98 -0
  143. package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +215 -0
  144. package/pp-core/workflows/discovery-sprint/sessions/04-ux-design.md +146 -0
  145. package/pp-core/workflows/discovery-sprint/sessions/05-architecture.md +98 -0
  146. package/pp-core/workflows/discovery-sprint/sessions/06-security.md +107 -0
  147. package/pp-core/workflows/discovery-sprint/sessions/07-identity.md +98 -0
  148. package/pp-core/workflows/discovery-sprint/sessions/08-integration.md +85 -0
  149. package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +109 -0
  150. package/pp-core/workflows/discovery-sprint/sessions/10-infrastructure.md +119 -0
  151. package/pp-core/workflows/discovery-sprint/sessions/11-roadmap.md +92 -0
  152. package/pp-core/workflows/discovery-sprint/sessions/12-delivery-model.md +98 -0
  153. package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +205 -0
  154. package/pp-core/workflows/discovery-sprint/templates/context-template.md +266 -0
  155. package/pp-core/workflows/discovery-sprint.md +79 -1714
  156. package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +5 -5
  157. package/pp-core/workflows/discovery-to-build/steps/screen-inventory-gate.md +70 -0
  158. package/pp-core/workflows/discovery-to-build/templates/discovery-references.md +54 -0
  159. package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +184 -0
  160. package/pp-core/workflows/discovery-to-build.md +21 -238
  161. package/pp-core/workflows/discuss-phase/modes/advisor.md +1 -1
  162. package/pp-core/workflows/discuss-phase/modes/auto.md +1 -1
  163. package/pp-core/workflows/discuss-phase/modes/chain.md +1 -1
  164. package/pp-core/workflows/discuss-phase-assumptions.md +1 -1
  165. package/pp-core/workflows/discuss-phase.md +9 -2
  166. package/pp-core/workflows/do.md +4 -4
  167. package/pp-core/workflows/docs-update.md +1 -1
  168. package/pp-core/workflows/edit-phase.md +1 -1
  169. package/pp-core/workflows/eval-review.md +1 -1
  170. package/pp-core/workflows/execute-phase/steps/checkpoint-handling.md +45 -0
  171. package/pp-core/workflows/execute-phase/steps/client-context-gate.md +1 -1
  172. package/pp-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  173. package/pp-core/workflows/execute-phase/steps/cross-ai-delegation.md +90 -0
  174. package/pp-core/workflows/execute-phase/steps/install-run.md +29 -0
  175. package/pp-core/workflows/execute-phase/steps/phase-close-rebaseline.md +23 -0
  176. package/pp-core/workflows/execute-phase/steps/post-merge-gate.md +76 -77
  177. package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +72 -0
  178. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +5 -5
  179. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +19 -112
  180. package/pp-core/workflows/execute-phase.md +102 -216
  181. package/pp-core/workflows/execute-plan.md +6 -2
  182. package/pp-core/workflows/explore.md +1 -1
  183. package/pp-core/workflows/extract-learnings.md +1 -1
  184. package/pp-core/workflows/forensics.md +4 -4
  185. package/pp-core/workflows/graduation.md +1 -1
  186. package/pp-core/workflows/health.md +1 -1
  187. package/pp-core/workflows/help/modes/brief.md +1 -0
  188. package/pp-core/workflows/help/modes/default.md +2 -1
  189. package/pp-core/workflows/help/modes/full.md +12 -5
  190. package/pp-core/workflows/import.md +1 -1
  191. package/pp-core/workflows/ingest-docs.md +1 -1
  192. package/pp-core/workflows/init.md +18 -2
  193. package/pp-core/workflows/insert-phase.md +1 -1
  194. package/pp-core/workflows/list-workspaces.md +1 -1
  195. package/pp-core/workflows/manager.md +3 -3
  196. package/pp-core/workflows/map-codebase.md +2 -2
  197. package/pp-core/workflows/milestone-summary.md +4 -4
  198. package/pp-core/workflows/mvp-phase.md +1 -1
  199. package/pp-core/workflows/new-milestone.md +7 -8
  200. package/pp-core/workflows/new-project.md +6 -1
  201. package/pp-core/workflows/new-workspace.md +1 -1
  202. package/pp-core/workflows/next.md +2 -2
  203. package/pp-core/workflows/note.md +2 -2
  204. package/pp-core/workflows/pause-work.md +1 -1
  205. package/pp-core/workflows/plan-milestone-gaps.md +1 -1
  206. package/pp-core/workflows/plan-phase/steps/chunked-planning.md +111 -0
  207. package/pp-core/workflows/plan-phase/steps/phase-tier.md +34 -0
  208. package/pp-core/workflows/plan-phase.md +43 -167
  209. package/pp-core/workflows/plan-review-convergence.md +2 -2
  210. package/pp-core/workflows/plant-seed.md +1 -1
  211. package/pp-core/workflows/pr-branch.md +1 -1
  212. package/pp-core/workflows/profile-user.md +1 -1
  213. package/pp-core/workflows/progress.md +3 -3
  214. package/pp-core/workflows/quick.md +3 -1
  215. package/pp-core/workflows/remove-phase.md +1 -1
  216. package/pp-core/workflows/remove-workspace.md +1 -1
  217. package/pp-core/workflows/resume-project.md +6 -12
  218. package/pp-core/workflows/review.md +1 -1
  219. package/pp-core/workflows/scan.md +1 -1
  220. package/pp-core/workflows/secure-phase.md +1 -1
  221. package/pp-core/workflows/settings-advanced.md +1 -1
  222. package/pp-core/workflows/settings-integrations.md +1 -1
  223. package/pp-core/workflows/settings.md +1 -1
  224. package/pp-core/workflows/ship.md +25 -8
  225. package/pp-core/workflows/sketch-wrap-up.md +1 -1
  226. package/pp-core/workflows/sketch.md +2 -1
  227. package/pp-core/workflows/spec-phase.md +1 -1
  228. package/pp-core/workflows/spike-wrap-up.md +1 -1
  229. package/pp-core/workflows/spike.md +1 -1
  230. package/pp-core/workflows/stats.md +2 -2
  231. package/pp-core/workflows/thread.md +1 -1
  232. package/pp-core/workflows/transition.md +5 -9
  233. package/pp-core/workflows/ui-phase.md +23 -2
  234. package/pp-core/workflows/ui-review.md +1 -1
  235. package/pp-core/workflows/ultraplan-phase.md +2 -2
  236. package/pp-core/workflows/validate-phase.md +1 -1
  237. package/pp-core/workflows/vault-write-back.md +1 -1
  238. package/pp-core/workflows/verify-phase.md +29 -26
  239. package/pp-core/workflows/verify-work.md +14 -10
  240. package/scripts/base64-scan.sh +7 -41
  241. package/scripts/changeset/cli.cjs +25 -5
  242. package/scripts/dev-env.cjs +462 -0
  243. package/scripts/lib/allowlist-ratchet.cjs +103 -1
  244. package/scripts/lib/size-budget.cjs +311 -0
  245. package/scripts/lint-neutrality-names.cjs +694 -0
  246. package/scripts/lint-org-coordinates.allowlist.json +35 -0
  247. package/scripts/lint-org-coordinates.cjs +426 -0
  248. package/scripts/lint-skill-deps.cjs +48 -9
  249. package/scripts/lint-test-file-count.cjs +17 -2
  250. package/scripts/prompt-injection-scan.sh +41 -20
  251. package/scripts/sync-manifest-versions.cjs +41 -7
  252. package/scripts/sync-runtime-launcher.cjs +106 -1
  253. package/scripts/update-size-baseline.cjs +274 -30
  254. package/scripts/workflow-size.cjs +61 -9
  255. package/commands/pp-build-handoff.md +0 -46
  256. package/pp-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +0 -94
@@ -46,7 +46,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
46
46
  };
47
47
  Object.defineProperty(exports, "__esModule", { value: true });
48
48
  exports.COMPOSE_BINDINGS_FLAGS = exports.SEED_VALUE_MAX_LENGTH = void 0;
49
+ exports._resetPlatformTagMemoForTests = _resetPlatformTagMemoForTests;
50
+ exports.resolveCreatedWithFrom = resolveCreatedWithFrom;
49
51
  exports.composeBindings = composeBindings;
52
+ exports.redactComposeResultForEmission = redactComposeResultForEmission;
50
53
  exports.cmdComposeBindings = cmdComposeBindings;
51
54
  const node_fs_1 = __importDefault(require("node:fs"));
52
55
  const node_path_1 = __importDefault(require("node:path"));
@@ -336,6 +339,22 @@ function readSeed(cwd, opts) {
336
339
  return { values, profile, warnings };
337
340
  }
338
341
  // ─── resolveLatestPlatformTag (D-04) ─────────────────────────────────────────
342
+ /**
343
+ * Per-process memo for the D-04 tag lookup, keyed by the resolved URL, holding
344
+ * the full `{ tag, warnings }` verdict — successes AND failures. One real
345
+ * `pp-tools init` run calls `composeBindings` twice (the dry-run preview in
346
+ * `computeCurrentStep`, then the write-mode call after `compose_confirm`), so
347
+ * without this memo a decline costs a network round-trip for nothing and a
348
+ * failed lookup would print its bounded warning twice.
349
+ */
350
+ const platformTagMemo = new Map();
351
+ /** Test-only seam: clear the per-process platform.tag lookup memo so a test
352
+ * suite that shares a resolved URL across cases sees an empty memo each time
353
+ * (`init-tag-lookup-raw-git-error`). Mirrors the reset-seam convention at
354
+ * `src/active-workstream-store.cts`'s controlling-TTY probe cache. */
355
+ function _resetPlatformTagMemoForTests() {
356
+ platformTagMemo.clear();
357
+ }
339
358
  /**
340
359
  * Resolve the latest STABLE tag for `repo` over the resolved host, never
341
360
  * shell-eval'd (T-34-25): `git ls-remote` is spawned with an argv array via
@@ -344,6 +363,13 @@ function readSeed(cwd, opts) {
344
363
  * else `defaultHostForProvider(provider)` — the host is authoritative and
345
364
  * never derived from the repo string (Phase 31 D-09).
346
365
  *
366
+ * The child's stderr is piped into the parent's buffer rather than inherited
367
+ * — an explicit `stdio: ['ignore', 'pipe', 'pipe']` — and deliberately
368
+ * discarded rather than logged: the bounded warning this function returns on
369
+ * failure is the single user-visible surface for a lookup failure. Inheriting
370
+ * the child's stream let a raw git `fatal:` line reach the terminal during an
371
+ * otherwise successful first-run experience (`init-tag-lookup-raw-git-error`).
372
+ *
347
373
  * Every candidate ref is filtered with `isStableTripletSemver` and ordered
348
374
  * with `compareSemverCore` — the single blessed comparison source, never
349
375
  * reimplemented. An empty result degrades to `{ tag: null, warnings: [] }`
@@ -353,13 +379,34 @@ function readSeed(cwd, opts) {
353
379
  * platform being unpinned, never a compose failure.
354
380
  */
355
381
  function resolveLatestPlatformTag(cwd, repo, provider, opts) {
382
+ let url;
356
383
  try {
357
384
  const hostRes = (0, vault_coordinates_cjs_1.resolvePlatformHost)(cwd);
358
385
  const host = hostRes.host ?? (0, vault_coordinates_cjs_1.defaultHostForProvider)(provider);
359
- const url = `https://${host}/${repo}`;
386
+ url = `https://${host}/${repo}`;
387
+ }
388
+ catch {
389
+ return {
390
+ tag: null,
391
+ warnings: [
392
+ bound('platform.tag could not be resolved — the tag lookup failed or was unreachable; the platform is unpinned.'),
393
+ ],
394
+ };
395
+ }
396
+ const cached = platformTagMemo.get(url);
397
+ if (cached)
398
+ return cached;
399
+ let result;
400
+ try {
360
401
  const lsOutput = typeof opts._lsRemote === 'function'
361
402
  ? opts._lsRemote(url)
362
- : node_child_process_1.default.execFileSync('git', ['ls-remote', '--tags', '--refs', '--sort=-v:refname', url, 'v*'], { encoding: 'utf-8', timeout: 10_000, maxBuffer: 512 * 1024, windowsHide: true });
403
+ : node_child_process_1.default.execFileSync('git', ['ls-remote', '--tags', '--refs', '--sort=-v:refname', url, 'v*'], {
404
+ encoding: 'utf-8',
405
+ timeout: 10_000,
406
+ maxBuffer: 512 * 1024,
407
+ windowsHide: true,
408
+ stdio: ['ignore', 'pipe', 'pipe'],
409
+ });
363
410
  const tags = lsOutput
364
411
  .split('\n')
365
412
  .map((line) => {
@@ -368,31 +415,57 @@ function resolveLatestPlatformTag(cwd, repo, provider, opts) {
368
415
  })
369
416
  .filter((t) => t && (0, semver_compare_cjs_1.isStableTripletSemver)(t));
370
417
  if (tags.length === 0) {
371
- return { tag: null, warnings: [] };
418
+ result = { tag: null, warnings: [] };
372
419
  }
373
- let latest = tags[0];
374
- for (const t of tags) {
375
- if ((0, semver_compare_cjs_1.compareSemverCore)(t, latest) > 0)
376
- latest = t;
420
+ else {
421
+ let latest = tags[0];
422
+ for (const t of tags) {
423
+ if ((0, semver_compare_cjs_1.compareSemverCore)(t, latest) > 0)
424
+ latest = t;
425
+ }
426
+ result = { tag: latest, warnings: [] };
377
427
  }
378
- return { tag: latest, warnings: [] };
379
428
  }
380
429
  catch {
381
- return {
430
+ result = {
382
431
  tag: null,
383
432
  warnings: [
384
433
  bound('platform.tag could not be resolved — the tag lookup failed or was unreachable; the platform is unpinned.'),
385
434
  ],
386
435
  };
387
436
  }
437
+ platformTagMemo.set(url, result);
438
+ return result;
388
439
  }
389
440
  /**
390
- * Read the first readable, semver-shaped `VERSION` file among the installed
391
- * `pp-core/VERSION` location and this repo's own root `VERSION`, returning
392
- * `<binName>@<version>`; when neither is readable, `binName` alone.
441
+ * Read the first readable, semver-shaped `VERSION` file among the candidate
442
+ * locations resolved against `baseDir`, returning `<binName>@<version>`.
443
+ *
444
+ * The candidate list is `baseDir`-relative rather than `__dirname`-relative so
445
+ * the layout under test is CHOSEN by the caller instead of inherited from
446
+ * wherever the compiled module happens to sit — an installed tree and this
447
+ * repo's own tree put `VERSION` in different places, and a test that cannot
448
+ * pick a layout cannot tell those two cases apart.
449
+ *
450
+ * Three locations are probed, in order: the `VERSION` sitting beside the bin
451
+ * directory; then the installed `pp-core/VERSION` that the installer writes
452
+ * into a project's runtime home; then this repo's own root `VERSION`. The
453
+ * installed location is consulted BEFORE the repo root so an installed tree
454
+ * reports the version it was actually installed from. In this repo that middle
455
+ * candidate is absent, so resolution falls through to the root file.
456
+ *
457
+ * @param baseDir - directory the candidates are resolved against. Production
458
+ * passes this module's own `__dirname`; a test passes a fixture directory
459
+ * shaped like `pp-core/bin/lib`.
460
+ * @returns `<binName>@<version>` for the first readable, semver-shaped
461
+ * candidate; when no candidate is readable, `binName` alone.
393
462
  */
394
- function resolveCreatedWith() {
395
- const candidates = [node_path_1.default.join(__dirname, '..', 'VERSION'), node_path_1.default.join(__dirname, '..', '..', '..', 'VERSION')];
463
+ function resolveCreatedWithFrom(baseDir) {
464
+ const candidates = [
465
+ node_path_1.default.join(baseDir, '..', 'VERSION'),
466
+ node_path_1.default.join(baseDir, '..', '..', 'VERSION'),
467
+ node_path_1.default.join(baseDir, '..', '..', '..', 'VERSION'),
468
+ ];
396
469
  for (const candidate of candidates) {
397
470
  try {
398
471
  const raw = node_fs_1.default.readFileSync(candidate, 'utf-8').trim();
@@ -406,6 +479,13 @@ function resolveCreatedWith() {
406
479
  }
407
480
  return package_identity_cjs_1.binName;
408
481
  }
482
+ /**
483
+ * Resolve `project.createdWith` for the running installation by probing the
484
+ * candidate layout against this module's own directory.
485
+ */
486
+ function resolveCreatedWith() {
487
+ return resolveCreatedWithFrom(__dirname);
488
+ }
409
489
  /**
410
490
  * Build the composed candidate object from the seed, the project answers
411
491
  * `/pp-init` already captured, the resolved platform host, the D-04 tag pin,
@@ -723,6 +803,39 @@ function composeBindings(cwd, opts = {}) {
723
803
  * does, then calls `composeBindings` and emits the result as data — a
724
804
  * verdict is never a process error.
725
805
  */
806
+ /**
807
+ * Pack 6 D-07: the display-safe copy of a compose result, for EMISSION only.
808
+ * Every remote coordinate field (`platform.repo`, `client.vaultRepo`) in
809
+ * `composed`, `seed`, `provenance` and `drift` goes through the one owner
810
+ * (`redactCloneTarget`, src/vault.cts); warnings go through the prose
811
+ * redactor. `composeBindings` itself, its drift/action comparison and every
812
+ * write keep the RAW values — never write or compare the result of this.
813
+ */
814
+ function redactComposeResultForEmission(result) {
815
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
816
+ const v = require('./vault.cjs');
817
+ const COORD = new Set(['platform.repo', 'client.vaultRepo']);
818
+ const safe = (x) => (typeof x === 'string' ? v.redactCloneTarget(x) : x);
819
+ const out = JSON.parse(JSON.stringify(result));
820
+ if (out.composed && typeof out.composed === 'object') {
821
+ for (const field of COORD) {
822
+ const [ns, key] = field.split('.');
823
+ const parent = out.composed[ns];
824
+ if (parent && typeof parent === 'object' && typeof parent[key] === 'string')
825
+ parent[key] = safe(parent[key]);
826
+ }
827
+ }
828
+ if (out.seed) {
829
+ if (typeof out.seed.platformRepo === 'string')
830
+ out.seed.platformRepo = safe(out.seed.platformRepo);
831
+ if (typeof out.seed.clientVaultRepo === 'string')
832
+ out.seed.clientVaultRepo = safe(out.seed.clientVaultRepo);
833
+ }
834
+ out.provenance = (out.provenance ?? []).map((p) => (COORD.has(p.field) ? { ...p, value: safe(p.value) } : p));
835
+ out.drift = (out.drift ?? []).map((d) => COORD.has(d.key) ? { ...d, profileValue: safe(d.profileValue), effectiveValue: safe(d.effectiveValue) } : d);
836
+ out.warnings = (out.warnings ?? []).map((w) => (typeof w === 'string' ? v.redactRemoteText(w) : w));
837
+ return out;
838
+ }
726
839
  function cmdComposeBindings(cwd, options, raw) {
727
840
  let root;
728
841
  try {
@@ -732,5 +845,5 @@ function cmdComposeBindings(cwd, options, raw) {
732
845
  root = cwd;
733
846
  }
734
847
  const result = composeBindings(root, { write: options.write === true, offline: options.offline === true });
735
- output(result, raw);
848
+ output(redactComposeResultForEmission(result), raw);
736
849
  }
@@ -26,6 +26,21 @@ function isCapabilityConfigKey(keyPath) {
26
26
  return false;
27
27
  return Object.prototype.hasOwnProperty.call(schema, keyPath);
28
28
  }
29
+ /**
30
+ * The declared `values` of a capability `type: "enum"` config key, or `null`
31
+ * when the key is not a capability key, is not an enum, or carries no value
32
+ * list. 13-05 / D-07: this is the ONE lookup of enum values —
33
+ * `config-set` asks it rather than restating any list, so the registry stays
34
+ * the single source of what an enum key may hold.
35
+ */
36
+ function capabilityEnumValues(keyPath) {
37
+ if (!isCapabilityConfigKey(keyPath))
38
+ return null;
39
+ const entry = capabilityRegistry.configSchema[keyPath];
40
+ if (!entry || typeof entry !== 'object')
41
+ return null;
42
+ return entry.type === 'enum' && Array.isArray(entry.values) ? entry.values : null;
43
+ }
29
44
  /**
30
45
  * Returns true for keys owned by the central schema adapter rather than a
31
46
  * federated Capability config slice.
@@ -53,6 +68,7 @@ module.exports = {
53
68
  RUNTIME_STATE_KEYS: configuration_cjs_1.RUNTIME_STATE_KEYS,
54
69
  DYNAMIC_KEY_PATTERNS: configuration_cjs_1.DYNAMIC_KEY_PATTERNS,
55
70
  isCapabilityConfigKey,
71
+ capabilityEnumValues,
56
72
  isCentralConfigKey,
57
73
  isValidConfigKey,
58
74
  };
@@ -27,10 +27,11 @@ const modelProfiles = require("./model-profiles.cjs");
27
27
  const { VALID_PROFILES, getAgentToModelMapForProfile, formatAgentToModelMapAsTable } = modelProfiles;
28
28
  // eslint-disable-next-line @typescript-eslint/no-require-imports
29
29
  const configSchema = require("./config-schema.cjs");
30
- const { VALID_CONFIG_KEYS, isValidConfigKey } = configSchema;
30
+ const { VALID_CONFIG_KEYS, isValidConfigKey, capabilityEnumValues } = configSchema;
31
31
  const secrets_cjs_1 = require("./secrets.cjs");
32
32
  const review_reviewer_selection_cjs_1 = require("./review-reviewer-selection.cjs");
33
33
  const configuration_cjs_1 = require("./configuration.cjs");
34
+ const phase_tier_cjs_1 = require("./phase-tier.cjs");
34
35
  // ─── Constants ────────────────────────────────────────────────────────────────
35
36
  const CONFIG_KEY_SUGGESTIONS = {
36
37
  'workflow.nyquist_validation_enabled': 'workflow.nyquist_validation',
@@ -464,6 +465,53 @@ function setConfigValues(cwd, entries) {
464
465
  }
465
466
  });
466
467
  }
468
+ /**
469
+ * The ONE membership test every enum guard in `cmdConfigSet` asks (13-REVIEW
470
+ * CR-01). The raw value is parsed before the guards — `true`/`false`/numbers
471
+ * coerced, and a value starting with `[` or `{` JSON-parsed — and
472
+ * `String(['dev']) === 'dev'`, so a membership test on `String(parsedValue)`
473
+ * let a one-element array through and wrote it. Every value list here is
474
+ * strings, so only a plain string can be a member.
475
+ */
476
+ function isStringMember(list, value) {
477
+ return typeof value === 'string' && list.includes(value);
478
+ }
479
+ /**
480
+ * Config keys `pp-tools config-set` accepts ONLY a real JSON boolean for
481
+ * (07-14 / REV-CR-01).
482
+ *
483
+ * The parse above coerces exactly the two lowercase words `true` and `false`;
484
+ * every other spelling an operator plausibly types becomes a truthy string
485
+ * (`"FALSE"`, `"no"`, `"off"`) or a number. `capability-activation`'s
486
+ * `_resolveActivationValue` ends in `Boolean(r.value)`, so those strings read as
487
+ * ON — an operator typing `config-set vault.enabled FALSE` to switch the vault
488
+ * off instead ARMED every step `capabilities/vault` hangs on that key, against
489
+ * the constraint that a `vault.enabled=false` project stays completely inert.
490
+ *
491
+ * The list, not ten copies of the same `if`: the first seven are capability
492
+ * activation keys read through that `Boolean()` (the vault switches plus every
493
+ * key that gates a blocking hook in the shipped registry). The last three were already guarded individually and are folded in here
494
+ * unchanged, message included, so there is one mechanism in this file rather
495
+ * than two.
496
+ *
497
+ * REFUSE, do not normalise: `FALSE` plainly means false, but `1`, `enabled` and
498
+ * `on` are guesses, and a config-set that silently rewrites the operator's word
499
+ * is the same class of silent-wrong-answer this phase exists to end. A typed
500
+ * usage error names the two accepted values.
501
+ */
502
+ const BOOLEAN_ONLY_CONFIG_KEYS = Object.freeze([
503
+ 'vault.enabled',
504
+ 'vault.required',
505
+ 'vault.practices_enabled',
506
+ 'workflow.schema_drift_gate',
507
+ 'workflow.nyquist_validation',
508
+ 'workflow.security_enforcement',
509
+ 'workflow.ui_safety_gate',
510
+ 'pareto.enabled',
511
+ 'git.create_tag',
512
+ 'workflow.post_planning_gaps',
513
+ 'plan_review.source_grounding',
514
+ ]);
467
515
  /**
468
516
  * Command to set a value in the config file, allowing nested values via dot notation (e.g.,
469
517
  * "workflow.research").
@@ -508,12 +556,12 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
508
556
  catch { /* keep as string */ }
509
557
  }
510
558
  const VALID_CONTEXT_VALUES = ['dev', 'research', 'review'];
511
- if (kp === 'context' && !VALID_CONTEXT_VALUES.includes(String(parsedValue))) {
559
+ if (kp === 'context' && !isStringMember(VALID_CONTEXT_VALUES, parsedValue)) {
512
560
  error(`Invalid context value '${val}'. Valid values: ${VALID_CONTEXT_VALUES.join(', ')}`);
513
561
  }
514
562
  // Codebase drift detector (#2003)
515
563
  const VALID_DRIFT_ACTIONS = ['warn', 'auto-remap'];
516
- if (kp === 'workflow.drift_action' && !VALID_DRIFT_ACTIONS.includes(String(parsedValue))) {
564
+ if (kp === 'workflow.drift_action' && !isStringMember(VALID_DRIFT_ACTIONS, parsedValue)) {
517
565
  error(`Invalid workflow.drift_action '${val}'. Valid values: ${VALID_DRIFT_ACTIONS.join(', ')}`);
518
566
  }
519
567
  if (kp === 'workflow.drift_threshold') {
@@ -521,51 +569,62 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
521
569
  error(`Invalid workflow.drift_threshold '${val}'. Must be a positive integer.`);
522
570
  }
523
571
  }
524
- // Post-planning gap checker (#2493)
525
- if (kp === 'workflow.post_planning_gaps') {
526
- if (typeof parsedValue !== 'boolean') {
527
- error(`Invalid workflow.post_planning_gaps '${val}'. Must be a boolean (true or false).`);
528
- }
529
- }
530
- // #3086 — git.create_tag: boolean only
531
- if (kp === 'git.create_tag') {
532
- if (typeof parsedValue !== 'boolean') {
533
- error(`Invalid git.create_tag '${val}'. Must be a boolean (true or false).`);
534
- }
572
+ // Boolean-only keys, in ONE place (07-14 / REV-CR-01; absorbs the individual
573
+ // guards #2493 workflow.post_planning_gaps, #3086 git.create_tag and #22
574
+ // plan_review.source_grounding, whose message this reproduces verbatim).
575
+ if (BOOLEAN_ONLY_CONFIG_KEYS.includes(kp) && typeof parsedValue !== 'boolean') {
576
+ error(`Invalid ${kp} '${val}'. Must be a boolean (true or false).`, ERROR_REASON.USAGE);
535
577
  }
536
578
  if (kp === 'ship.pr_body_sections') {
537
579
  validateShipPrBodySections(parsedValue);
538
580
  }
581
+ // Phase ceremony tier (pack 9) — enum, src/phase-tier.cts owns the list
582
+ if (kp === 'phase.tier' && !isStringMember(phase_tier_cjs_1.PHASE_TIERS, parsedValue)) {
583
+ error(`Invalid phase.tier '${val}'. Valid values: ${phase_tier_cjs_1.PHASE_TIERS.join(', ')}`, ERROR_REASON.CONFIG_INVALID_VALUE);
584
+ }
539
585
  // Human verification checkpoint mode (#3309)
540
586
  const VALID_HUMAN_VERIFY_MODES = ['mid-flight', 'end-of-phase'];
541
- if (kp === 'workflow.human_verify_mode' && !VALID_HUMAN_VERIFY_MODES.includes(String(parsedValue))) {
587
+ if (kp === 'workflow.human_verify_mode' && !isStringMember(VALID_HUMAN_VERIFY_MODES, parsedValue)) {
542
588
  error(`Invalid workflow.human_verify_mode '${val}'. Valid values: ${VALID_HUMAN_VERIFY_MODES.join(', ')}`);
543
589
  }
544
590
  // Context position enum validation (#2937)
545
591
  const VALID_CONTEXT_POSITIONS = ['front', 'end'];
546
- if (kp === 'statusline.context_position' && !VALID_CONTEXT_POSITIONS.includes(String(parsedValue))) {
592
+ if (kp === 'statusline.context_position' && !isStringMember(VALID_CONTEXT_POSITIONS, parsedValue)) {
547
593
  error(`Invalid statusline.context_position '${val}'. Valid values: ${VALID_CONTEXT_POSITIONS.join(', ')}`);
548
594
  }
549
595
  // Fallow scope + profile enum validation (#3424)
550
596
  const VALID_FALLOW_SCOPES = ['phase', 'repo'];
551
- if (kp === 'code_quality.fallow.scope' && !VALID_FALLOW_SCOPES.includes(String(parsedValue))) {
597
+ if (kp === 'code_quality.fallow.scope' && !isStringMember(VALID_FALLOW_SCOPES, parsedValue)) {
552
598
  error(`Invalid code_quality.fallow.scope '${val}'. Valid values: ${VALID_FALLOW_SCOPES.join(', ')}`);
553
599
  }
554
600
  const VALID_FALLOW_PROFILES = ['minimal', 'standard', 'strict'];
555
- if (kp === 'code_quality.fallow.profile' && !VALID_FALLOW_PROFILES.includes(String(parsedValue))) {
601
+ if (kp === 'code_quality.fallow.profile' && !isStringMember(VALID_FALLOW_PROFILES, parsedValue)) {
556
602
  error(`Invalid code_quality.fallow.profile '${val}'. Valid values: ${VALID_FALLOW_PROFILES.join(', ')}`);
557
603
  }
558
- // plan_review.source_grounding (#22) — boolean only
559
- if (kp === 'plan_review.source_grounding') {
560
- if (typeof parsedValue !== 'boolean') {
561
- error(`Invalid plan_review.source_grounding '${val}'. Must be a boolean (true or false).`);
562
- }
563
- }
564
604
  // plan_review.source_grounding_authority (#22) — enum
565
605
  const VALID_SOURCE_GROUNDING_AUTHORITIES = ['grep', 'intel', 'treesitter', 'lsp', 'scip'];
566
- if (kp === 'plan_review.source_grounding_authority' && !VALID_SOURCE_GROUNDING_AUTHORITIES.includes(String(parsedValue))) {
606
+ if (kp === 'plan_review.source_grounding_authority' && !isStringMember(VALID_SOURCE_GROUNDING_AUTHORITIES, parsedValue)) {
567
607
  error(`Invalid plan_review.source_grounding_authority '${val}'. Valid values: ${VALID_SOURCE_GROUNDING_AUTHORITIES.join(', ')}`);
568
608
  }
609
+ // Capability enum keys, in ONE place (13-05, D-07). Measured 2026-10-09:
610
+ // `config-set workflow.code_review_depth bogus` wrote at exit 0, as did every
611
+ // other enum key, because no guard consulted the registry's declared `values`. This runs AFTER every
612
+ // hand-coded guard above, so a key that already has its own guard
613
+ // (`workflow.drift_action`) keeps its message byte-identical; the
614
+ // registry, through `capabilityEnumValues`, is the only list consulted
615
+ // here. The refused value is NOT echoed (T-13-03): it is operator text and
616
+ // may carry terminal control sequences. Only the WRITE path changes: how an
617
+ // existing enum key is READ is deliberately unchanged in this phase (D-07).
618
+ // The TYPE is checked before membership (13-REVIEW CR-01): a value starting
619
+ // with `[` is JSON-parsed above, and `String(['standards']) === 'standards'`,
620
+ // so a membership-only test let a one-element array through and wrote it —
621
+ // which `federated-config` silently reads back as the default for an enum
622
+ // key. Every declared capability enum value is a string, so a non-string
623
+ // parse (array, object, number, boolean) can never be a member.
624
+ const enumValues = capabilityEnumValues(kp);
625
+ if (enumValues && !isStringMember(enumValues, parsedValue)) {
626
+ error(`Invalid ${kp}. Valid values: ${enumValues.join(', ')}`, ERROR_REASON.CONFIG_INVALID_VALUE);
627
+ }
569
628
  if (kp === 'review.default_reviewers') {
570
629
  const normalized = (0, review_reviewer_selection_cjs_1.normalizeConfiguredDefaultReviewers)(parsedValue);
571
630
  if (normalized.errors.length > 0) {
@@ -592,7 +651,58 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
592
651
  output(maskedResult, raw, `${kp}=${masked}`);
593
652
  return;
594
653
  }
595
- output(setConfigValueResult, raw, `${kp}=${String(parsedValue)}`);
654
+ // Pack 6 D-07: the WRITTEN value stays raw; its echo (and the value it
655
+ // replaced) is display-safe.
656
+ const shownValue = redactConfigCoordinates(kp, parsedValue);
657
+ const shownResult = {
658
+ ...setConfigValueResult,
659
+ value: redactConfigCoordinates(kp, setConfigValueResult.value),
660
+ previousValue: redactConfigCoordinates(kp, setConfigValueResult.previousValue),
661
+ };
662
+ output(shownResult, raw, `${kp}=${String(shownValue)}`);
663
+ }
664
+ /**
665
+ * Pack 6 D-07: the display-safe form of a config value about to be EMITTED,
666
+ * for the config keys that hold a remote repo coordinate (the shared-tier repo,
667
+ * `SHARED_REPO_CONFIG_KEY`). `keyPath` is the dotted path the value was read
668
+ * at (`''` for the whole config): the key itself, or any ancestor object that
669
+ * carries it, is projected through the one owner (`redactCloneTarget` in
670
+ * src/vault.cts); every other value is returned untouched (and a
671
+ * credential-free coordinate is unchanged by the owner). The file on disk and
672
+ * every in-process reader keep the raw value. Lazily required, so this
673
+ * module's load graph is unchanged.
674
+ */
675
+ function redactConfigCoordinates(keyPath, value) {
676
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
677
+ const { SHARED_REPO_CONFIG_KEY } = require('./vault-coordinates.cjs');
678
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
679
+ const { redactCloneTarget } = require('./vault.cjs');
680
+ const safe = (v) => (typeof v === 'string' && v !== '' ? (redactCloneTarget(v) ?? v) : v);
681
+ const target = SHARED_REPO_CONFIG_KEY.split('.');
682
+ const at = keyPath === '' ? [] : keyPath.split('.');
683
+ if (at.length > target.length || at.some((seg, i) => seg !== target[i]))
684
+ return value;
685
+ const rest = target.slice(at.length);
686
+ if (rest.length === 0)
687
+ return safe(value);
688
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
689
+ return value;
690
+ const copy = JSON.parse(JSON.stringify(value));
691
+ // The flat form: the whole dotted key written as one top-level property.
692
+ if (at.length === 0 && typeof copy[SHARED_REPO_CONFIG_KEY] === 'string') {
693
+ copy[SHARED_REPO_CONFIG_KEY] = safe(copy[SHARED_REPO_CONFIG_KEY]);
694
+ }
695
+ let node = copy;
696
+ for (const seg of rest.slice(0, -1)) {
697
+ const next = node[seg];
698
+ if (next === null || typeof next !== 'object' || Array.isArray(next))
699
+ return copy;
700
+ node = next;
701
+ }
702
+ const leaf = rest[rest.length - 1];
703
+ if (typeof node[leaf] === 'string')
704
+ node[leaf] = safe(node[leaf]);
705
+ return copy;
596
706
  }
597
707
  function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
598
708
  const configPath = node_path_1.default.join(planningDir(cwd), 'config.json');
@@ -665,7 +775,9 @@ function cmdConfigGet(cwd, keyPath, raw, defaultValue) {
665
775
  output(masked, raw, masked);
666
776
  return;
667
777
  }
668
- output(current, raw, String(current));
778
+ // Pack 6 D-07: a remote coordinate (or a section carrying one) is emitted display-safe.
779
+ const shown = redactConfigCoordinates(kp, current);
780
+ output(shown, raw, String(shown));
669
781
  }
670
782
  /**
671
783
  * Command to set the model profile in the config file.
@@ -768,6 +880,7 @@ function cmdMigrateConfig(cwd, raw) {
768
880
  }
769
881
  module.exports = {
770
882
  VALID_CONFIG_KEYS,
883
+ BOOLEAN_ONLY_CONFIG_KEYS,
771
884
  cmdConfigEnsureSection,
772
885
  cmdConfigSet,
773
886
  cmdConfigGet,
@@ -777,5 +890,7 @@ module.exports = {
777
890
  cmdMigrateConfig,
778
891
  // Exported for programmatic use by capability-writer and tests
779
892
  setConfigValue,
893
+ // Pack 6 D-07: `state load` emits the whole config through the same projection.
894
+ redactConfigCoordinates,
780
895
  setConfigValues,
781
896
  };
@@ -61,22 +61,58 @@ function detectSubRepos(cwd) {
61
61
  // ─── Summary body helpers ─────────────────────────────────────────────────
62
62
  /**
63
63
  * Extract a one-liner from the summary body when it's not in frontmatter.
64
+ *
65
+ * The slot is the one the SUMMARY template defines (`template.cts` summary body,
66
+ * `templates/summary.md:51` and its `<one_liner_rules>`): the FIRST paragraph
67
+ * directly after the FIRST `# ` title, written either as a whole bold paragraph
68
+ * (a wrapped one is joined with single spaces, the outer `**` stripped once and
69
+ * inner bold spans kept) or as the `**Label:** prose` line bug #2660 pinned.
70
+ * Anything else — a plain-prose first paragraph, or a bold span under a later
71
+ * heading — yields null. 261007-rly D-3/D-8: the old pattern took the first bold
72
+ * span after ANY heading anywhere in the file, which put fragments such as
73
+ * `Task 1` and `D-08-32 — applied as written.` into 15 of the 34 accomplishment
74
+ * lines of the v1.1 close (todos/done/milestone-complete-writes-a-poor-milestone-record.md).
75
+ * Shared by `milestone complete` and `summary-extract`'s `one_liner`.
64
76
  */
65
77
  function extractOneLinerFromBody(content) {
66
78
  if (!content)
67
79
  return null;
68
80
  const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
69
81
  const body = normalized.replace(/^---\n[\s\S]*?\n---\n*/, '');
70
- const match = body.match(/^#[^\n]*\n+\*\*([^*\n]+)\*\*([^\n]*)/m);
71
- if (!match)
82
+ const lines = body.split('\n');
83
+ const title = lines.findIndex((l) => /^# /.test(l));
84
+ if (title === -1)
85
+ return null;
86
+ let i = title + 1;
87
+ while (i < lines.length && lines[i].trim() === '')
88
+ i++;
89
+ const paragraph = [];
90
+ while (i < lines.length && lines[i].trim() !== '' && !/^#{1,6}\s/.test(lines[i]))
91
+ paragraph.push(lines[i++]);
92
+ if (paragraph.length === 0)
72
93
  return null;
73
- const boldInner = match[1].trim();
74
- const afterBold = match[2];
75
- if (/:\s*$/.test(boldInner)) {
76
- const prose = afterBold.trim();
94
+ // `**Label:** prose` (bug #2660): the prose after the label, null when empty.
95
+ const label = paragraph[0].match(/^\*\*([^*\n]+)\*\*([^\n]*)/);
96
+ if (label && /:\s*$/.test(label[1].trim())) {
97
+ const prose = label[2].trim();
77
98
  return prose.length > 0 ? prose : null;
78
99
  }
79
- return boldInner.length > 0 ? boldInner : null;
100
+ // A whole bold paragraph, single-line or wrapped.
101
+ const first = paragraph[0].trim();
102
+ const last = paragraph[paragraph.length - 1].trim();
103
+ if (!first.startsWith('**'))
104
+ return null;
105
+ let bold = null;
106
+ if (last.endsWith('**'))
107
+ bold = paragraph.map((l) => l.trim()).join(' ');
108
+ // A bold first line with prose glued under it (no blank line between) — the
109
+ // shape tests/core-utils.test.cjs pins; the bold line is still the slot.
110
+ else if (first.length > 4 && first.endsWith('**'))
111
+ bold = first;
112
+ if (bold === null)
113
+ return null;
114
+ const inner = bold.slice(2, -2).trim();
115
+ return inner.length > 0 ? inner : null;
80
116
  }
81
117
  // ─── Misc utilities ───────────────────────────────────────────────────────────
82
118
  function pathExistsInternal(cwd, targetPath) {