@pareto4/pp-core 1.13.0 → 1.18.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 (204) hide show
  1. package/.claude-plugin/plugin.json +3 -3
  2. package/README.md +31 -12
  3. package/agents/pp-code-fixer.md +0 -11
  4. package/agents/pp-code-reviewer.md +1 -77
  5. package/agents/pp-debug-session-manager.md +9 -6
  6. package/agents/pp-executor.md +12 -29
  7. package/agents/pp-plan-checker.md +4 -4
  8. package/agents/pp-planner.md +34 -20
  9. package/agents/pp-vault-reviewer.md +100 -23
  10. package/agents/pp-verifier.md +1 -3
  11. package/bin/install.js +10 -3
  12. package/commands/pp-execute-phase.md +1 -3
  13. package/commands/pp-ns-manage.md +1 -1
  14. package/commands/pp-ns-review.md +1 -1
  15. package/commands/pp-plan-phase.md +1 -2
  16. package/commands/pp-vault-promote.md +7 -8
  17. package/commands/pp-vault-review.md +6 -5
  18. package/commands/pp-verify-work.md +1 -2
  19. package/gemini-extension.json +1 -1
  20. package/hooks/dist/pp-check-update.js +0 -0
  21. package/hooks/dist/pp-cursor-session-start.js +1 -1
  22. package/hooks/dist/pp-statusline.js +1 -1
  23. package/hooks/dist/pp-update-banner.js +0 -0
  24. package/hooks/pp-check-update.js +0 -0
  25. package/hooks/pp-cursor-session-start.js +1 -1
  26. package/hooks/pp-statusline.js +1 -1
  27. package/hooks/pp-update-banner.js +0 -0
  28. package/package.json +10 -18
  29. package/pareto-skills/_shared/create-project-board.sh +0 -0
  30. package/pareto-skills/pp-project-setup/SKILL.md +1 -1
  31. package/pp-core/bin/check-latest-version.cjs +0 -0
  32. package/pp-core/bin/detect-build-commands.sh +2 -2
  33. package/pp-core/bin/lib/capability-activation.cjs +83 -9
  34. package/pp-core/bin/lib/capability-registry.cjs +201 -229
  35. package/pp-core/bin/lib/capability-state.cjs +93 -1
  36. package/pp-core/bin/lib/capability-writer.cjs +3 -3
  37. package/pp-core/bin/lib/check-command-router.cjs +1906 -1514
  38. package/pp-core/bin/lib/clusters.cjs +3 -0
  39. package/pp-core/bin/lib/command-aliases.cjs +0 -35
  40. package/pp-core/bin/lib/commands.cjs +9 -4
  41. package/pp-core/bin/lib/compose-bindings.cjs +251 -88
  42. package/pp-core/bin/lib/config-loader.cjs +7 -103
  43. package/pp-core/bin/lib/config-schema.cjs +1 -1
  44. package/pp-core/bin/lib/config.cjs +151 -62
  45. package/pp-core/bin/lib/decisions.cjs +4 -4
  46. package/pp-core/bin/lib/discovery-vault-check.cjs +246 -0
  47. package/pp-core/bin/lib/frontmatter.cjs +1 -38
  48. package/pp-core/bin/lib/git-base-branch.cjs +11 -12
  49. package/pp-core/bin/lib/git-provider.cjs +9 -10
  50. package/pp-core/bin/lib/init-command-router.cjs +5 -8
  51. package/pp-core/bin/lib/init-contract.cjs +620 -738
  52. package/pp-core/bin/lib/init-interactive.cjs +62 -4
  53. package/pp-core/bin/lib/init.cjs +24 -51
  54. package/pp-core/bin/lib/install-profiles.cjs +1 -1
  55. package/pp-core/bin/lib/io.cjs +4 -2
  56. package/pp-core/bin/lib/loop-resolver.cjs +165 -129
  57. package/pp-core/bin/lib/org-profile.cjs +402 -161
  58. package/pp-core/bin/lib/package-identity.cjs +3 -3
  59. package/pp-core/bin/lib/phase-command-router.cjs +1 -29
  60. package/pp-core/bin/lib/phase-scope.cjs +1 -1
  61. package/pp-core/bin/lib/phase.cjs +11 -253
  62. package/pp-core/bin/lib/platform-bindings.cjs +40 -19
  63. package/pp-core/bin/lib/profile-output.cjs +1 -1
  64. package/pp-core/bin/lib/shell-command-projection.cjs +104 -1
  65. package/pp-core/bin/lib/state.cjs +11 -85
  66. package/pp-core/bin/lib/template.cjs +1 -1
  67. package/pp-core/bin/lib/vault-command-router.cjs +1682 -477
  68. package/pp-core/bin/lib/vault-coordinates.cjs +16 -7
  69. package/pp-core/bin/lib/vault-index.cjs +656 -0
  70. package/pp-core/bin/lib/vault-seal.cjs +1851 -0
  71. package/pp-core/bin/lib/vault-standards-promote.cjs +577 -0
  72. package/pp-core/bin/lib/vault-standards-review.cjs +293 -0
  73. package/pp-core/bin/lib/vault-standards.cjs +1504 -0
  74. package/pp-core/bin/lib/vault-trace.cjs +155 -2778
  75. package/pp-core/bin/lib/vault-update.cjs +10 -59
  76. package/pp-core/bin/lib/vault.cjs +2570 -950
  77. package/pp-core/bin/lib/verify.cjs +0 -11
  78. package/pp-core/bin/portable-timeout.sh +1 -1
  79. package/pp-core/bin/pp-tools.cjs +157 -54
  80. package/pp-core/bin/shared/config-defaults.manifest.json +0 -7
  81. package/pp-core/bin/shared/config-schema.manifest.json +0 -3
  82. package/pp-core/bin/shared/model-catalog.json +2 -2
  83. package/pp-core/bin/verify-reapply-patches.cjs +0 -0
  84. package/pp-core/org-profile.json +26 -0
  85. package/pp-core/references/autonomous-best-effort.md +8 -7
  86. package/pp-core/references/loop-hook-dispatch.md +0 -25
  87. package/pp-core/references/planner-gap-closure.md +0 -1
  88. package/pp-core/references/planner-tdd.md +0 -1
  89. package/pp-core/references/planner-vault-standards.md +311 -0
  90. package/pp-core/references/planning-config.md +0 -8
  91. package/pp-core/references/tdd.md +0 -1
  92. package/pp-core/templates/README.md +1 -0
  93. package/pp-core/templates/VAULT-REVIEW.md +56 -0
  94. package/pp-core/templates/phase-prompt.md +2 -6
  95. package/pp-core/workflows/_runtime-launcher.snippet.sh +0 -0
  96. package/pp-core/workflows/add-tests.md +55 -4
  97. package/pp-core/workflows/audit-fix.md +63 -2
  98. package/pp-core/workflows/audit-milestone.md +1 -1
  99. package/pp-core/workflows/autonomous.md +85 -11
  100. package/pp-core/workflows/check-todos.md +13 -2
  101. package/pp-core/workflows/code-review-fix.md +59 -3
  102. package/pp-core/workflows/code-review.md +10 -57
  103. package/pp-core/workflows/complete-milestone.md +1 -1
  104. package/pp-core/workflows/debug.md +54 -4
  105. package/pp-core/workflows/discovery-sprint/prefill.md +19 -7
  106. package/pp-core/workflows/discovery-sprint/prototype.md +46 -49
  107. package/pp-core/workflows/discovery-sprint/session-13-gates.md +2 -0
  108. package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +1 -3
  109. package/pp-core/workflows/discovery-sprint/sessions/02-as-is.md +1 -3
  110. package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +1 -3
  111. package/pp-core/workflows/discovery-sprint/sessions/04-ux-design.md +20 -24
  112. package/pp-core/workflows/discovery-sprint/sessions/05-architecture.md +1 -3
  113. package/pp-core/workflows/discovery-sprint/sessions/06-security.md +1 -3
  114. package/pp-core/workflows/discovery-sprint/sessions/07-identity.md +1 -3
  115. package/pp-core/workflows/discovery-sprint/sessions/08-integration.md +1 -3
  116. package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +1 -3
  117. package/pp-core/workflows/discovery-sprint/sessions/10-infrastructure.md +1 -6
  118. package/pp-core/workflows/discovery-sprint/sessions/11-roadmap.md +1 -3
  119. package/pp-core/workflows/discovery-sprint/sessions/12-delivery-model.md +1 -3
  120. package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +1 -3
  121. package/pp-core/workflows/discovery-sprint/templates/context-template.md +1 -1
  122. package/pp-core/workflows/discovery-sprint/vault-context.md +207 -0
  123. package/pp-core/workflows/discovery-sprint.md +7 -3
  124. package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +4 -4
  125. package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +43 -1
  126. package/pp-core/workflows/discovery-to-build.md +10 -2
  127. package/pp-core/workflows/discuss-phase.md +3 -10
  128. package/pp-core/workflows/execute-phase/steps/check-query-gate.md +327 -0
  129. package/pp-core/workflows/execute-phase/steps/client-context-gate.md +3 -2
  130. package/pp-core/workflows/execute-phase/steps/cross-ai-delegation.md +1 -1
  131. package/pp-core/workflows/execute-phase/steps/execute-pre-dispatch.md +78 -0
  132. package/pp-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +94 -0
  133. package/pp-core/workflows/execute-phase/steps/post-merge-gate.md +6 -8
  134. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +115 -14
  135. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +219 -20
  136. package/pp-core/workflows/execute-phase/steps/verify-post-gate.md +147 -0
  137. package/pp-core/workflows/execute-phase.md +152 -128
  138. package/pp-core/workflows/execute-plan.md +54 -6
  139. package/pp-core/workflows/fast.md +53 -4
  140. package/pp-core/workflows/help/modes/full.md +3 -3
  141. package/pp-core/workflows/init/steps/client-context-capture.md +101 -0
  142. package/pp-core/workflows/init/steps/gitlab-provisioning.md +130 -0
  143. package/pp-core/workflows/init/steps/project-md-deferral-narration.md +66 -0
  144. package/pp-core/workflows/init/steps/reachability-outcome.md +70 -0
  145. package/pp-core/workflows/init.md +232 -272
  146. package/pp-core/workflows/new-project.md +31 -23
  147. package/pp-core/workflows/plan-phase.md +98 -45
  148. package/pp-core/workflows/quick.md +117 -3
  149. package/pp-core/workflows/secure-phase.md +1 -1
  150. package/pp-core/workflows/ship.md +57 -28
  151. package/pp-core/workflows/undo.md +1 -1
  152. package/pp-core/workflows/update.md +3 -3
  153. package/pp-core/workflows/validate-phase.md +58 -3
  154. package/pp-core/workflows/vault-review.md +161 -23
  155. package/pp-core/workflows/vault-write-back.md +73 -110
  156. package/pp-core/workflows/verify-work.md +12 -14
  157. package/scripts/changeset/cli.cjs +6 -5
  158. package/scripts/changeset/lint.cjs +0 -0
  159. package/scripts/changeset/new.cjs +33 -50
  160. package/scripts/dev-env.cjs +25 -85
  161. package/scripts/gen-capability-registry.cjs +120 -13
  162. package/scripts/lib/allowlist-ratchet.cjs +1 -103
  163. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -2
  164. package/scripts/lint-docs-required.cjs +0 -0
  165. package/scripts/lint-pr-check-project-dir.cjs +1 -3
  166. package/scripts/lint-test-file-count.allowlist.json +3 -19
  167. package/scripts/lint-test-file-count.cjs +5 -6
  168. package/scripts/prompt-injection-scan.sh +19 -17
  169. package/scripts/release-channel.cjs +0 -0
  170. package/scripts/sync-manifest-versions.cjs +5 -4
  171. package/scripts/sync-rulesets.sh +1 -1
  172. package/scripts/update-size-baseline.cjs +30 -274
  173. package/scripts/verify-opengsd-sweep.cjs +3 -5
  174. package/scripts/workflow-size.cjs +9 -61
  175. package/pp-core/bin/lib/dispatch-plan.cjs +0 -483
  176. package/pp-core/bin/lib/install-run.cjs +0 -536
  177. package/pp-core/bin/lib/loop-gates.cjs +0 -213
  178. package/pp-core/bin/lib/phase-manifest.cjs +0 -527
  179. package/pp-core/bin/lib/phase-tier.cjs +0 -480
  180. package/pp-core/bin/lib/plan-graph.cjs +0 -368
  181. package/pp-core/bin/lib/practices-review-check.cjs +0 -938
  182. package/pp-core/bin/lib/vault-audit.cjs +0 -2070
  183. package/pp-core/bin/lib/vault-practices.cjs +0 -1990
  184. package/pp-core/bin/lib/vault-review.cjs +0 -2359
  185. package/pp-core/bin/lib/vault-write-back.cjs +0 -877
  186. package/pp-core/references/executor-vault-trace.md +0 -47
  187. package/pp-core/references/planner-plan-graph.md +0 -66
  188. package/pp-core/references/planner-vault-practices.md +0 -112
  189. package/pp-core/templates/practices/README.md +0 -251
  190. package/pp-core/templates/practices/error-handling.md +0 -18
  191. package/pp-core/templates/practices/minimal-comments.md +0 -14
  192. package/pp-core/templates/practices/naming-convention.md +0 -18
  193. package/pp-core/templates/practices/practice-template.md +0 -23
  194. package/pp-core/workflows/discovery-sprint/vault-fetch.md +0 -37
  195. package/pp-core/workflows/execute-phase/steps/checkpoint-handling.md +0 -45
  196. package/pp-core/workflows/execute-phase/steps/install-run.md +0 -29
  197. package/pp-core/workflows/execute-phase/steps/phase-close-rebaseline.md +0 -23
  198. package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +0 -73
  199. package/pp-core/workflows/execute-phase/steps/vault-door-gate.md +0 -19
  200. package/pp-core/workflows/plan-phase/steps/phase-tier.md +0 -43
  201. package/scripts/lib/size-budget.cjs +0 -311
  202. package/scripts/lint-neutrality-names.cjs +0 -694
  203. package/scripts/lint-org-coordinates.allowlist.json +0 -35
  204. package/scripts/lint-org-coordinates.cjs +0 -426
@@ -1,527 +0,0 @@
1
- "use strict";
2
- /**
3
- * Phase manifest — the writer of `.phase-manifest.json`, which `/pp-undo
4
- * --phase` reads (`manifest.phases[NN].commits`, reverted newest first).
5
- *
6
- * Command surface (routed by phase.cts):
7
- * pp-tools phase manifest start <phase> anchor the phase: startCommit = HEAD, write-once
8
- * pp-tools phase manifest record <phase> commits = rev-list --reverse --no-merges <startCommit>..HEAD
9
- *
10
- * execute-phase calls `start` before its first executor and `record` after
11
- * every wave's tracking commit and after the phase completion commit, so the
12
- * list always covers the phase's executor, merge and tracking commits. Both
13
- * are orchestrator writes; no executor calls them.
14
- *
15
- * The manifest sits two levels above the phase directory:
16
- * `.planning/.phase-manifest.json` on the flat layout (the file `/pp-undo`
17
- * reads), `.planning/workstreams/<ws>/.phase-manifest.json` on a partitioned
18
- * one, so two workstreams' `05` entries never share a file. It is local
19
- * execution metadata: the writer adds `.phase-manifest.json` to the
20
- * repository's shared `info/exclude`, so the file never shows as untracked
21
- * (which `/pp-undo`'s clean-tree guard would refuse) and is never committed by
22
- * a broad `git add`.
23
- *
24
- * Never throws; a refusal is `{ ok: false, action, reason }` with a
25
- * `PHASE_MANIFEST_WRITE_REASON` member, and the workflows print the one-line
26
- * rendering and continue — a manifest that could not be written leaves
27
- * `/pp-undo --phase` on its git-log fallback, which is what it had before.
28
- *
29
- * `readPhaseManifestEntry` is the READER: the phase's recorded `startCommit`
30
- * and `commits`, read from exactly the file the writer writes, or the named
31
- * ground on which there is no usable record. `vault audit` scopes its diff
32
- * from it (src/vault-audit.cts).
33
- */
34
- var __importDefault = (this && this.__importDefault) || function (mod) {
35
- return (mod && mod.__esModule) ? mod : { "default": mod };
36
- };
37
- const node_fs_1 = __importDefault(require("node:fs"));
38
- const node_path_1 = __importDefault(require("node:path"));
39
- const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
40
- /** The manifest file `/pp-undo` reads. */
41
- const PHASE_MANIFEST_FILE = '.phase-manifest.json';
42
- /**
43
- * Every sha is interpolated into a git ARGV, where a leading `-` would be read
44
- * as a flag. The manifest is editable planning data, so this is the one shape
45
- * a sha may have.
46
- */
47
- const COMMIT_SHA_SHAPE = /^[0-9a-fA-F]{7,64}$/;
48
- /** The leading `NN` or `NN.M` token of a phase directory slug. */
49
- const PHASE_SLUG_SHAPE = /^(\d+(?:\.\d+)?)/;
50
- const TOKEN_MAX = 120;
51
- /** Why `recordPhaseStart` / `recordPhaseCommits` wrote nothing. */
52
- const PHASE_MANIFEST_WRITE_REASON = Object.freeze({
53
- /** The phase directory is not an existing directory inside the project root. */
54
- PHASE_DIR_UNRESOLVABLE: 'phase-dir-unresolvable',
55
- /** The phase directory's name does not begin with an `NN` or `NN.M` token. */
56
- PHASE_SLUG_UNRECOGNISED: 'phase-slug-unrecognised',
57
- /** git could not name the commit HEAD points at (an unborn branch, or no repository). */
58
- HEAD_UNRESOLVABLE: 'head-unresolvable',
59
- /** The manifest exists and is not a JSON object with an object `phases`; it is left untouched. */
60
- MANIFEST_MALFORMED: 'manifest-malformed',
61
- /** `record` found no start commit for this phase — `start` never ran for it. */
62
- START_NOT_RECORDED: 'start-not-recorded',
63
- /** `record` found a start commit that git does not verify. */
64
- START_UNVERIFIABLE: 'start-unverifiable',
65
- /**
66
- * `start` found no anchor for this phase, and its directory already holds a
67
- * `*-SUMMARY.md`: the phase has been executed, so HEAD sits AFTER its own
68
- * commits and an anchor there would put the phase's work outside its own
69
- * range. Nothing is written.
70
- */
71
- PHASE_ALREADY_EXECUTED: 'phase-already-executed',
72
- /** The manifest could not be written (an errno is in `detail`). */
73
- WRITE_FAILED: 'write-failed',
74
- });
75
- function boundedToken(v) {
76
- let s;
77
- try {
78
- s = typeof v === 'string' ? v : JSON.stringify(v) ?? String(v);
79
- }
80
- catch {
81
- s = String(v);
82
- }
83
- s = s.replace(/[\r\n]+/g, ' ');
84
- return s.length > TOKEN_MAX ? `${s.slice(0, TOKEN_MAX)}…` : s;
85
- }
86
- function errnoOf(e) {
87
- const code = e?.code;
88
- return typeof code === 'string' && code.length > 0 ? boundedToken(code) : 'unknown error';
89
- }
90
- /** Is this resolved absolute path the root itself, or somewhere beneath it? */
91
- function isContainedIn(abs, rootAbs) {
92
- return abs === rootAbs || abs.startsWith(rootAbs + node_path_1.default.sep);
93
- }
94
- /** The root with every symlinked component resolved (a macOS temp root is `/var` and `/private/var` at once). */
95
- function resolvedRootOf(rootAbs) {
96
- try {
97
- return node_fs_1.default.realpathSync(rootAbs);
98
- }
99
- catch {
100
- return rootAbs;
101
- }
102
- }
103
- /**
104
- * Is this manifest entry stamped for a DIFFERENT phase directory? Entries are
105
- * keyed by phase NUMBER, and `/pp-new-milestone --reset-phase-numbers` reuses
106
- * numbers, so a new milestone's `01-new` would otherwise inherit `01-old`'s
107
- * anchor and commits. An entry with NO stamp (legacy or hand-written) is not
108
- * stale.
109
- */
110
- function isStaleEntry(entry, slug) {
111
- return typeof entry.phaseDir === 'string' && entry.phaseDir !== slug;
112
- }
113
- /**
114
- * Where this phase's manifest lives, or the named ground on which it cannot.
115
- * `phaseDir` resolves against `cwd`, then against `root`; the first that is an
116
- * existing directory CONTAINED in the root wins, lexically or with symlinks
117
- * resolved on both sides.
118
- */
119
- function resolveManifestTarget(opts) {
120
- const unresolvable = { ok: false, reason: PHASE_MANIFEST_WRITE_REASON.PHASE_DIR_UNRESOLVABLE };
121
- if (typeof opts?.root !== 'string' || opts.root === '')
122
- return unresolvable;
123
- if (typeof opts?.phaseDir !== 'string' || opts.phaseDir === '' || opts.phaseDir.includes('\0'))
124
- return unresolvable;
125
- const rootLexical = node_path_1.default.resolve(opts.root);
126
- const rootReal = resolvedRootOf(rootLexical);
127
- const bases = [typeof opts.cwd === 'string' && opts.cwd !== '' ? opts.cwd : process.cwd(), rootLexical];
128
- let phaseDirAbs = null;
129
- let rootAbs = rootLexical;
130
- for (const base of bases) {
131
- const abs = node_path_1.default.resolve(base, opts.phaseDir);
132
- try {
133
- if (!node_fs_1.default.statSync(abs).isDirectory())
134
- continue;
135
- }
136
- catch {
137
- continue;
138
- }
139
- if (isContainedIn(abs, rootLexical)) {
140
- phaseDirAbs = abs;
141
- rootAbs = rootLexical;
142
- break;
143
- }
144
- const real = resolvedRootOf(abs);
145
- if (isContainedIn(real, rootReal)) {
146
- phaseDirAbs = real;
147
- rootAbs = rootReal;
148
- break;
149
- }
150
- }
151
- if (phaseDirAbs === null)
152
- return unresolvable;
153
- const slugMatch = PHASE_SLUG_SHAPE.exec(node_path_1.default.basename(phaseDirAbs));
154
- if (!slugMatch)
155
- return { ok: false, reason: PHASE_MANIFEST_WRITE_REASON.PHASE_SLUG_UNRECOGNISED };
156
- const manifestAbs = node_path_1.default.join(node_path_1.default.dirname(node_path_1.default.dirname(phaseDirAbs)), PHASE_MANIFEST_FILE);
157
- if (!isContainedIn(manifestAbs, rootAbs))
158
- return unresolvable;
159
- return {
160
- ok: true,
161
- target: {
162
- rootAbs,
163
- manifestAbs,
164
- manifestPath: node_path_1.default.relative(rootAbs, manifestAbs).split(node_path_1.default.sep).join('/'),
165
- rawToken: slugMatch[1],
166
- phaseDirAbs,
167
- phaseSlug: node_path_1.default.basename(phaseDirAbs),
168
- },
169
- };
170
- }
171
- /**
172
- * The existing manifest, parsed for an ADDITIVE write — or `null` when it must
173
- * not be overwritten: unparseable JSON, a non-object document or a non-object
174
- * `phases`. An absent file is an empty document, not a refusal.
175
- */
176
- function readManifestForWrite(manifestAbs) {
177
- if (!node_fs_1.default.existsSync(manifestAbs))
178
- return { doc: {}, phases: {}, raw: null };
179
- try {
180
- const raw = node_fs_1.default.readFileSync(manifestAbs, 'utf8');
181
- const parsed = JSON.parse(raw);
182
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
183
- return null;
184
- const doc = parsed;
185
- if (doc.phases === undefined)
186
- return { doc, phases: {}, raw };
187
- if (!doc.phases || typeof doc.phases !== 'object' || Array.isArray(doc.phases))
188
- return null;
189
- return { doc, phases: doc.phases, raw };
190
- }
191
- catch {
192
- return null;
193
- }
194
- }
195
- /** The key this phase's entry lives under: an existing spelling wins, else the slug's own token. */
196
- function manifestKeyFor(phases, rawToken) {
197
- const spellings = [rawToken, String(Number(rawToken))];
198
- return spellings.find((key) => Object.prototype.hasOwnProperty.call(phases, key)) ?? rawToken;
199
- }
200
- /** The entry under `key` as a plain object, `{}` when absent, `null` when present and not an object. */
201
- function manifestEntryOf(phases, key) {
202
- if (!Object.prototype.hasOwnProperty.call(phases, key))
203
- return {};
204
- const entry = phases[key];
205
- return entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : null;
206
- }
207
- /** Two-space JSON with a trailing newline — the one serialization, so a re-record is byte-stable. */
208
- function serializeManifest(doc) {
209
- return `${JSON.stringify(doc, null, 2)}\n`;
210
- }
211
- /** Write atomically (sibling temp file, then rename). Returns `null` on success, else an errno. */
212
- function writeManifestAtomic(manifestAbs, serialized) {
213
- const tmp = `${manifestAbs}.${process.pid}.tmp`;
214
- try {
215
- node_fs_1.default.writeFileSync(tmp, serialized, 'utf8');
216
- node_fs_1.default.renameSync(tmp, manifestAbs);
217
- return null;
218
- }
219
- catch (err) {
220
- try {
221
- node_fs_1.default.unlinkSync(tmp);
222
- }
223
- catch {
224
- // Nothing to remove, or it cannot be removed; the refusal stands.
225
- }
226
- return errnoOf(err);
227
- }
228
- }
229
- /** Has this phase already been executed? `null` when the directory cannot be listed. */
230
- function phaseHasSummary(phaseDirAbs) {
231
- try {
232
- return node_fs_1.default.readdirSync(phaseDirAbs).some((name) => name.endsWith('-SUMMARY.md'));
233
- }
234
- catch {
235
- return null;
236
- }
237
- }
238
- /** A verified commit sha from a `rev-parse --verify` probe, or `null`. */
239
- function verifiedSha(exec, rev, cwd) {
240
- const probe = exec(['rev-parse', '--verify', rev], { cwd });
241
- if (probe.exitCode !== 0 || probe.error)
242
- return null;
243
- const sha = String(probe.stdout || '').trim();
244
- return COMMIT_SHA_SHAPE.test(sha) ? sha : null;
245
- }
246
- /** The exclude pattern: the basename, so the flat and the workstream manifests are both covered. */
247
- const EXCLUDE_LINE = PHASE_MANIFEST_FILE;
248
- /**
249
- * Keep the manifest out of git: add `.phase-manifest.json` to the repository's
250
- * SHARED `info/exclude` (the git common dir, so every linked worktree is
251
- * covered), never to the project's own committed `.gitignore`. Best effort:
252
- * returns `added`, `present` or `failed`, and never throws.
253
- */
254
- function ensureManifestExcluded(exec, rootAbs) {
255
- try {
256
- const r = exec(['rev-parse', '--git-common-dir'], { cwd: rootAbs });
257
- if (r.exitCode !== 0 || r.error)
258
- return 'failed';
259
- const common = String(r.stdout || '').trim();
260
- if (common === '')
261
- return 'failed';
262
- const commonAbs = node_path_1.default.resolve(rootAbs, common);
263
- const excludeAbs = node_path_1.default.join(commonAbs, 'info', 'exclude');
264
- let current = '';
265
- try {
266
- current = node_fs_1.default.readFileSync(excludeAbs, 'utf8');
267
- }
268
- catch {
269
- current = '';
270
- }
271
- if (current.split(/\r?\n/).some((line) => line.trim() === EXCLUDE_LINE))
272
- return 'present';
273
- node_fs_1.default.mkdirSync(node_path_1.default.dirname(excludeAbs), { recursive: true });
274
- const sep = current === '' || current.endsWith('\n') ? '' : '\n';
275
- node_fs_1.default.appendFileSync(excludeAbs, `${sep}# pp-core: local phase manifest, read by /pp-undo --phase\n${EXCLUDE_LINE}\n`, 'utf8');
276
- return 'added';
277
- }
278
- catch {
279
- return 'failed';
280
- }
281
- }
282
- /**
283
- * Anchor the phase: `phases[NN].startCommit = HEAD`, WRITE-ONCE.
284
- *
285
- * 1. WRITE-ONCE. An entry for THIS phase directory that already carries a
286
- * start commit is left byte-identical (`unchanged: true`), so a resume or
287
- * a `--gaps-only` run keeps the anchor the phase started from.
288
- * 2. ALREADY EXECUTED. With no usable anchor, a phase directory that already
289
- * holds a `*-SUMMARY.md` is refused (`phase-already-executed`); HEAD on an
290
- * executed phase sits after its own commits.
291
- * 3. STALE. An entry stamped for another phase directory is REPLACED by a
292
- * fresh `{ startCommit, phaseDir }`; its old commits are dropped
293
- * (`replacedStale: true`).
294
- *
295
- * Every write stamps `phaseDir` with the directory basename. Never throws.
296
- */
297
- function recordPhaseStart(opts) {
298
- const action = 'start';
299
- try {
300
- const exec = typeof opts?.exec === 'function' ? opts.exec : shell_command_projection_cjs_1.execGit;
301
- const resolved = resolveManifestTarget(opts);
302
- if (!resolved.ok)
303
- return { ok: false, action, reason: resolved.reason };
304
- const { target } = resolved;
305
- const read = readManifestForWrite(target.manifestAbs);
306
- if (read === null)
307
- return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.MANIFEST_MALFORMED };
308
- const key = manifestKeyFor(read.phases, target.rawToken);
309
- const entry = manifestEntryOf(read.phases, key);
310
- if (entry === null)
311
- return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.MANIFEST_MALFORMED };
312
- const stale = isStaleEntry(entry, target.phaseSlug);
313
- if (!stale && typeof entry.startCommit === 'string' && COMMIT_SHA_SHAPE.test(entry.startCommit)) {
314
- return { ok: true, action, manifestPath: target.manifestPath, phase: key, startCommit: entry.startCommit, unchanged: true };
315
- }
316
- // Checked before HEAD is read: a refusal asks git nothing.
317
- const executed = phaseHasSummary(target.phaseDirAbs);
318
- if (executed === null)
319
- return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.PHASE_DIR_UNRESOLVABLE };
320
- if (executed)
321
- return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.PHASE_ALREADY_EXECUTED };
322
- const head = verifiedSha(exec, 'HEAD^{commit}', target.rootAbs);
323
- if (head === null)
324
- return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.HEAD_UNRESOLVABLE };
325
- const fresh = stale ? { startCommit: head, phaseDir: target.phaseSlug } : { ...entry, startCommit: head, phaseDir: target.phaseSlug };
326
- const doc = { ...read.doc, phases: { ...read.phases, [key]: fresh } };
327
- const failure = writeManifestAtomic(target.manifestAbs, serializeManifest(doc));
328
- if (failure !== null)
329
- return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.WRITE_FAILED, detail: failure };
330
- const excluded = ensureManifestExcluded(exec, target.rootAbs);
331
- return {
332
- ok: true,
333
- action,
334
- manifestPath: target.manifestPath,
335
- phase: key,
336
- startCommit: head,
337
- unchanged: false,
338
- excluded,
339
- ...(stale ? { replacedStale: true } : {}),
340
- };
341
- }
342
- catch (err) {
343
- return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.WRITE_FAILED, detail: errnoOf(err) };
344
- }
345
- }
346
- /**
347
- * Close the phase's range: `phases[NN].commits` =
348
- * `git rev-list --reverse --no-merges <startCommit>..HEAD`, oldest first.
349
- * Recomputed from the anchor on every call, so two consecutive records leave
350
- * the file byte-identical. An empty list is a valid record. No anchor, or an
351
- * entry stamped for another phase directory, answers `start-not-recorded`.
352
- * Never throws.
353
- */
354
- function recordPhaseCommits(opts) {
355
- const action = 'record';
356
- try {
357
- const exec = typeof opts?.exec === 'function' ? opts.exec : shell_command_projection_cjs_1.execGit;
358
- const resolved = resolveManifestTarget(opts);
359
- if (!resolved.ok)
360
- return { ok: false, action, reason: resolved.reason };
361
- const { target } = resolved;
362
- const read = readManifestForWrite(target.manifestAbs);
363
- if (read === null)
364
- return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.MANIFEST_MALFORMED };
365
- const key = manifestKeyFor(read.phases, target.rawToken);
366
- const entry = manifestEntryOf(read.phases, key);
367
- if (entry === null)
368
- return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.MANIFEST_MALFORMED };
369
- if (isStaleEntry(entry, target.phaseSlug)) {
370
- return {
371
- ok: false,
372
- action,
373
- reason: PHASE_MANIFEST_WRITE_REASON.START_NOT_RECORDED,
374
- detail: 'the entry under this phase number was recorded for another phase directory',
375
- };
376
- }
377
- const start = entry.startCommit;
378
- if (typeof start !== 'string' || !COMMIT_SHA_SHAPE.test(start)) {
379
- return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.START_NOT_RECORDED };
380
- }
381
- if (verifiedSha(exec, `${start}^{commit}`, target.rootAbs) === null) {
382
- return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.START_UNVERIFIABLE };
383
- }
384
- const listed = exec(['rev-list', '--reverse', '--no-merges', `${start}..HEAD`], { cwd: target.rootAbs });
385
- if (listed.exitCode !== 0 || listed.error) {
386
- return {
387
- ok: false,
388
- action,
389
- reason: PHASE_MANIFEST_WRITE_REASON.HEAD_UNRESOLVABLE,
390
- detail: 'git rev-list could not list the commits since the start commit',
391
- };
392
- }
393
- const commits = String(listed.stdout || '')
394
- .split('\n')
395
- .map((line) => line.trim())
396
- .filter((line) => COMMIT_SHA_SHAPE.test(line));
397
- const doc = { ...read.doc, phases: { ...read.phases, [key]: { ...entry, commits, phaseDir: target.phaseSlug } } };
398
- const serialized = serializeManifest(doc);
399
- if (serialized !== read.raw) {
400
- const failure = writeManifestAtomic(target.manifestAbs, serialized);
401
- if (failure !== null)
402
- return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.WRITE_FAILED, detail: failure };
403
- }
404
- return { ok: true, action, manifestPath: target.manifestPath, phase: key, startCommit: start, commits, commitCount: commits.length };
405
- }
406
- catch (err) {
407
- return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.WRITE_FAILED, detail: errnoOf(err) };
408
- }
409
- }
410
- /** The one-line operator rendering of a writer result; the workflows print it and continue. */
411
- function renderManifestResult(result) {
412
- const r = (result && typeof result === 'object' ? result : {});
413
- if (r.ok !== true)
414
- return `Phase manifest not written: ${boundedToken(r.reason ?? 'unknown')}`;
415
- const sha7 = typeof r.startCommit === 'string' ? r.startCommit.slice(0, 7) : '';
416
- if (r.action === 'record') {
417
- return `Phase ${boundedToken(r.phase)}: ${Number(r.commitCount)} commit(s) recorded from ${sha7}..HEAD in ${boundedToken(r.manifestPath)}`;
418
- }
419
- return (`Phase ${boundedToken(r.phase)} anchored at ${sha7} in ${boundedToken(r.manifestPath)}` +
420
- (r.unchanged === true ? ' (already anchored; unchanged)' : '') +
421
- (r.replacedStale === true ? ' (replaced a stale entry recorded for another phase directory)' : ''));
422
- }
423
- // ─── The reader ───────────────────────────────────────────────────────────────
424
- /**
425
- * Why a phase has no usable manifest record. The strings are shared with the
426
- * write reasons where the fact is the same (an unkeyable slug, an unparseable
427
- * file), so one fact has one spelling.
428
- */
429
- const PHASE_MANIFEST_READ_MISS = Object.freeze({
430
- /** The phase directory's name does not begin with an `NN` or `NN.M` token. */
431
- PHASE_SLUG_UNRECOGNISED: PHASE_MANIFEST_WRITE_REASON.PHASE_SLUG_UNRECOGNISED,
432
- /** No manifest at the writer's location for this phase directory. */
433
- MANIFEST_NOT_FOUND: 'manifest-not-found',
434
- /** A manifest exists and is not a JSON object with an object `phases`. */
435
- MANIFEST_MALFORMED: PHASE_MANIFEST_WRITE_REASON.MANIFEST_MALFORMED,
436
- /** The manifest parsed and holds no usable entry for this phase under either key spelling. */
437
- PHASE_ENTRY_MISSING: 'phase-entry-missing',
438
- /** The phase's entry exists and records neither a start commit nor any commit. */
439
- NO_COMMITS_RECORDED: 'no-commits-recorded',
440
- });
441
- /** The one sentence a stale entry is disclosed with. */
442
- function staleEntryNote(key, recorded, slug) {
443
- return (`the phase manifest entry "${boundedToken(key)}" is stale: it was recorded for phase directory ` +
444
- `"${boundedToken(recorded)}", not "${boundedToken(slug)}" (a phase number reused after a milestone reset), ` +
445
- 'so its startCommit and commits were ignored');
446
- }
447
- /**
448
- * Read this phase's record from the manifest the writer keeps — the file two
449
- * levels above the phase directory, the same location `recordPhaseStart`
450
- * writes — or name the ground on which there is none.
451
- *
452
- * Both key spellings (`05` and `5`) are read before answering, so an anchor
453
- * under one is not hidden by a commit list under the other. An entry stamped
454
- * for ANOTHER phase directory is stale (a phase number reused after a
455
- * milestone reset): it is no record for this phase, and it is disclosed in
456
- * `notes`. An unstamped (legacy) entry counts as this phase's. A manifest
457
- * location outside `root` is never read. Pure read; never throws.
458
- */
459
- function readPhaseManifestEntry(opts) {
460
- const none = (miss, notes = []) => ({ commits: [], startCommit: null, miss, notes });
461
- try {
462
- const phaseDirAbs = node_path_1.default.resolve(opts.phaseDir);
463
- const slug = node_path_1.default.basename(phaseDirAbs);
464
- const slugMatch = PHASE_SLUG_SHAPE.exec(slug);
465
- if (!slugMatch)
466
- return none(PHASE_MANIFEST_READ_MISS.PHASE_SLUG_UNRECOGNISED);
467
- const manifestAbs = node_path_1.default.join(node_path_1.default.dirname(node_path_1.default.dirname(phaseDirAbs)), PHASE_MANIFEST_FILE);
468
- const rootAbs = node_path_1.default.resolve(opts.root);
469
- if (!isContainedIn(manifestAbs, rootAbs) && !isContainedIn(resolvedRootOf(node_path_1.default.dirname(manifestAbs)), resolvedRootOf(rootAbs))) {
470
- return none(PHASE_MANIFEST_READ_MISS.MANIFEST_NOT_FOUND);
471
- }
472
- if (!node_fs_1.default.existsSync(manifestAbs))
473
- return none(PHASE_MANIFEST_READ_MISS.MANIFEST_NOT_FOUND);
474
- let phases;
475
- try {
476
- const parsed = JSON.parse(node_fs_1.default.readFileSync(manifestAbs, 'utf8'));
477
- const candidate = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed.phases : undefined;
478
- if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate))
479
- return none(PHASE_MANIFEST_READ_MISS.MANIFEST_MALFORMED);
480
- phases = candidate;
481
- }
482
- catch {
483
- return none(PHASE_MANIFEST_READ_MISS.MANIFEST_MALFORMED);
484
- }
485
- const rawToken = slugMatch[1];
486
- const keys = [...new Set([rawToken, String(Number(rawToken))])];
487
- const notes = [];
488
- let present = false;
489
- let startCommit = null;
490
- let commits = null;
491
- for (const key of keys) {
492
- if (!Object.prototype.hasOwnProperty.call(phases, key))
493
- continue;
494
- const entry = phases[key];
495
- if (!entry || typeof entry !== 'object' || Array.isArray(entry))
496
- continue;
497
- const record = entry;
498
- if (isStaleEntry(record, slug)) {
499
- notes.push(staleEntryNote(key, record.phaseDir, slug));
500
- continue;
501
- }
502
- present = true;
503
- if (startCommit === null && typeof record.startCommit === 'string' && COMMIT_SHA_SHAPE.test(record.startCommit)) {
504
- startCommit = record.startCommit;
505
- }
506
- if (commits === null && Array.isArray(record.commits) && record.commits.length > 0) {
507
- commits = record.commits.filter((c) => typeof c === 'string');
508
- }
509
- }
510
- if (commits !== null || startCommit !== null)
511
- return { commits: commits ?? [], startCommit, miss: null, notes };
512
- return none(present ? PHASE_MANIFEST_READ_MISS.NO_COMMITS_RECORDED : PHASE_MANIFEST_READ_MISS.PHASE_ENTRY_MISSING, notes);
513
- }
514
- catch {
515
- return none(PHASE_MANIFEST_READ_MISS.MANIFEST_NOT_FOUND);
516
- }
517
- }
518
- module.exports = {
519
- PHASE_MANIFEST_FILE,
520
- PHASE_MANIFEST_WRITE_REASON,
521
- PHASE_MANIFEST_READ_MISS,
522
- COMMIT_SHA_SHAPE,
523
- readPhaseManifestEntry,
524
- recordPhaseStart,
525
- recordPhaseCommits,
526
- renderManifestResult,
527
- };