@pareto4/pp-core 1.8.1 → 1.9.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 (37) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/NOTICE +23 -0
  3. package/README.md +10 -1
  4. package/bin/install.js +34 -1
  5. package/gemini-extension.json +1 -1
  6. package/package.json +8 -3
  7. package/pareto-skills/pp-project-setup/SKILL.md +32 -440
  8. package/pp-core/bin/lib/capability-state.cjs +13 -7
  9. package/pp-core/bin/lib/config.cjs +7 -22
  10. package/pp-core/bin/lib/init.cjs +4 -4
  11. package/pp-core/bin/lib/installer-migration-report.cjs +1 -1
  12. package/pp-core/bin/lib/loop-resolver.cjs +6 -0
  13. package/pp-core/bin/lib/profile-output.cjs +4 -2
  14. package/pp-core/bin/lib/runtime-name-policy.cjs +0 -1
  15. package/pp-core/bin/lib/surface.cjs +2 -2
  16. package/pp-core/bin/shared/config-schema.manifest.json +4 -0
  17. package/pp-core/references/workstream-flag.md +13 -0
  18. package/pp-core/workflows/discovery-sprint.md +409 -16
  19. package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +250 -0
  20. package/pp-core/workflows/discovery-to-build.md +218 -48
  21. package/pp-core/workflows/discuss-phase/modes/chain.md +5 -0
  22. package/pp-core/workflows/discuss-phase.md +4 -0
  23. package/pp-core/workflows/execute-phase.md +4 -0
  24. package/pp-core/workflows/new-milestone.md +4 -0
  25. package/pp-core/workflows/plan-phase.md +4 -0
  26. package/pp-core/workflows/progress.md +4 -0
  27. package/pp-core/workflows/quick.md +4 -0
  28. package/pp-core/workflows/resume-project.md +4 -0
  29. package/pp-core/workflows/ship.md +4 -0
  30. package/pp-core/workflows/transition.md +4 -0
  31. package/pp-core/workflows/validate-phase.md +4 -0
  32. package/scripts/changeset/lint.cjs +20 -5
  33. package/scripts/lint-docs-required.cjs +10 -3
  34. package/scripts/release-channel.cjs +58 -0
  35. package/scripts/release-tarball-smoke.cjs +4 -0
  36. package/scripts/verify-opengsd-sweep.cjs +12 -0
  37. package/scripts/verify-rename-guard.cjs +333 -0
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * release-channel.cjs
6
+ *
7
+ * Pure decision logic mapping a git release tag to an npm dist-tag publish
8
+ * channel. Given a tag like `v1.9.0-rc.1` returns `'next'`; given a clean
9
+ * `v1.8.1` returns `'latest'`. Fails closed (returns `null` / CLI exits 1) on
10
+ * any unrecognized or malformed input instead of defaulting to `'latest'` —
11
+ * the production-facing channel (ASVS V5 tampering concern: `CI_COMMIT_TAG`
12
+ * is attacker-influenceable if a bad tag is pushed). Matching is exhaustive
13
+ * and full-string anchored: anything that isn't an exact `X.Y.Z` release or
14
+ * an exact `X.Y.Z-(rc|beta|alpha).N` prerelease returns null — no partial or
15
+ * heuristic match ever resolves to `'latest'`.
16
+ *
17
+ * Usage:
18
+ * node scripts/release-channel.cjs "$CI_COMMIT_TAG" # prints channel, exit 0
19
+ * node scripts/release-channel.cjs # reads CI_COMMIT_TAG env
20
+ * argv (process.argv[2]) is preferred over the CI_COMMIT_TAG env var so the
21
+ * module is deterministic under its own tests even when CI_COMMIT_TAG is
22
+ * set in the ambient environment (as it always is on a real GitLab tag
23
+ * pipeline).
24
+ * (unrecognized/empty tag) -> stderr message, exit 1, no stdout
25
+ */
26
+
27
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
28
+
29
+ // Exact release version: no prerelease suffix, no build metadata, no trailing
30
+ // garbage.
31
+ const RELEASE_RE = /^\d+\.\d+\.\d+$/;
32
+
33
+ // Exact prerelease version: matches upstream gsd-core's own prerelease convention: -rc.N / -beta.N / -alpha.N — gsd-allow-legacy-name
34
+ const PRERELEASE_RE = /^\d+\.\d+\.\d+-(?:rc|beta|alpha)\.\d+$/;
35
+
36
+ function channelForTag(tag) {
37
+ if (typeof tag !== 'string' || tag.length === 0) return null;
38
+ const version = tag.replace(/^v/, '');
39
+ if (RELEASE_RE.test(version)) return 'latest';
40
+ if (PRERELEASE_RE.test(version)) return 'next';
41
+ return null;
42
+ }
43
+
44
+ module.exports = { channelForTag, PRERELEASE_RE };
45
+
46
+ function cliMain() {
47
+ const tag = process.argv[2] || process.env.CI_COMMIT_TAG || '';
48
+ const channel = channelForTag(tag);
49
+ if (!channel) {
50
+ throw new ExitError(1, `release-channel: unrecognized tag "${tag}"`);
51
+ }
52
+ process.stdout.write(channel + '\n');
53
+ return 0;
54
+ }
55
+
56
+ if (require.main === module) {
57
+ runMain(cliMain);
58
+ }
@@ -756,6 +756,10 @@ module.exports = {
756
756
  locateLegacyGsdProgress,
757
757
  driveInstaller,
758
758
  findInstallerBin,
759
+ // 20-02 Task 1: expose the installed pp-tools bin resolver so the
760
+ // REL-02 discuss->plan->execute smoke test can resolve it without
761
+ // re-implementing bin resolution (RESEARCH.md Don't-Hand-Roll).
762
+ findGsdToolsBin, // gsd-allow-legacy-name
759
763
  };
760
764
 
761
765
  if (require.main === module) {
@@ -41,6 +41,16 @@ const ROOT = path.resolve(__dirname, '..');
41
41
  /** Matches any recognised OpenGSD spelling in a line of text. */
42
42
  const OPENGSD_RE = /opengsd|open-gsd/i;
43
43
 
44
+ /**
45
+ * MIT-attribution carve-out (line-level): the README's License & Attribution
46
+ * section must name the upstream project — the same D-07 class as
47
+ * LICENSE/NOTICE, but scoped to attribution lines only so the rest of the
48
+ * README stays on the scanned surface.
49
+ */
50
+ const ATTRIBUTION_LINE_RE =
51
+ /hard fork of \[gsd-core\]\(https:\/\/github\.com\/open-gsd\/gsd-core\)/;
52
+ const README_FILE = path.join(__dirname, '..', 'README.md');
53
+
44
54
  /**
45
55
  * Broader brand pattern for npm-facing manifest metadata (package.json
46
56
  * `description` / `author`). Catches the upstream product name ("GSD Core")
@@ -185,6 +195,8 @@ function scanFiles(files) {
185
195
  const lines = content.split('\n');
186
196
  for (let i = 0; i < lines.length; i++) {
187
197
  if (OPENGSD_RE.test(lines[i])) {
198
+ // README attribution line: MIT-required upstream credit (D-07 class).
199
+ if (abs === README_FILE && ATTRIBUTION_LINE_RE.test(lines[i])) continue;
188
200
  hits.push({
189
201
  file: path.relative(ROOT, abs),
190
202
  line: i + 1,
@@ -0,0 +1,333 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * verify-rename-guard.cjs — REBR-04 rename-integrity ratchet guard.
6
+ *
7
+ * The gsd → pp rename is functionally finished (Phase 15/16/17); this guard
8
+ * is a CI ratchet that stops it from regressing — it is explicitly NOT a
9
+ * cosmetic sweep of the ~1,186 files that still legitimately contain `gsd`
10
+ * (runtime contracts, historical corpora, migration provenance, etc.).
11
+ *
12
+ * Design (D-01..D-11, .planning/phases/18-rename-integrity-guard/18-CONTEXT.md):
13
+ * D-01 Baseline ratchet: a committed baseline captures existing gsd hits;
14
+ * CI fails only on a NEW (novel) hit, never on the existing debt.
15
+ * D-02 Line-fingerprint precision: baseline keys on (file, normalized line
16
+ * content), not (file, line NUMBER) — see Pitfall 1 below.
17
+ * D-03 Case-insensitive substring match: RENAME_RE = /gsd/i, deliberately
18
+ * broad; carve-outs and keep-tokens do the narrowing.
19
+ * D-04 Growth-only enforcement: shrinking/removing a baselined gsd
20
+ * reference NEVER fails the guard — it is a harmless upper bound.
21
+ * D-07 Whole tracked tree: scans every git-tracked text file (skip
22
+ * binaries), not a curated subset.
23
+ * D-09 Single-source allowlist authority: tests/fixtures/gsd-keep-tokens.cjs
24
+ * (isFrozen/isAttribution for whole-file carve-outs, isKeepTokenLine
25
+ * for per-line exemptions). Never fork a parallel token list here.
26
+ * D-10 Whole-file/dir carve-outs (installer-migrations, .changeset/archived,
27
+ * the fixture itself) live in gsd-keep-tokens.cjs's FROZEN_PATHS.
28
+ *
29
+ * This plan (18-03) does NOT generate/commit the real-tree baseline — that is
30
+ * plan 04 (wave 2), once the tree reflects REBR-05's functional fixes. A
31
+ * missing baseline file here is treated as an EMPTY baseline, which lets the
32
+ * subprocess test wrapper fully control the baseline via env-var redirection.
33
+ *
34
+ * Exit codes:
35
+ * 0 — no novel gsd reference found (or --update-baseline succeeded)
36
+ * 1 — one or more novel gsd references found outside the baseline
37
+ *
38
+ * Usage:
39
+ * node scripts/verify-rename-guard.cjs
40
+ * node scripts/verify-rename-guard.cjs --update-baseline
41
+ *
42
+ * Env overrides (test-only, mirrors GSD_LINT_LEGACY_REPO_ROOT in
43
+ * scripts/lint-legacy-dir-name.cjs):
44
+ * RENAME_GUARD_REPO_ROOT — redirect the whole-tree scan to a temp git repo
45
+ * RENAME_GUARD_BASELINE_PATH — redirect the baseline file to a temp path
46
+ *
47
+ * Exports:
48
+ * runScan(opts?) → { byFile, hitList, baseline, novel, fileCount, repoRoot, baselinePath }
49
+ */
50
+
51
+ const { execFileSync } = require('node:child_process');
52
+ const fs = require('node:fs');
53
+ const path = require('node:path');
54
+
55
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
56
+ const keepTokens = require('../tests/fixtures/gsd-keep-tokens.cjs');
57
+
58
+ // D-03: deliberately broad case-insensitive substring match — carve-outs and
59
+ // keep-tokens (not a narrower pattern) do the exemption work.
60
+ const RENAME_RE = /gsd/i;
61
+
62
+ // RENAME_GUARD_REPO_ROOT lets tests redirect the guard to a temporary git repo
63
+ // without touching the real working tree (mirrors GSD_LINT_LEGACY_REPO_ROOT).
64
+ const REPO_ROOT = process.env.RENAME_GUARD_REPO_ROOT
65
+ ? path.resolve(process.env.RENAME_GUARD_REPO_ROOT)
66
+ : path.resolve(__dirname, '..');
67
+
68
+ // RENAME_GUARD_BASELINE_PATH lets tests redirect the baseline file to a
69
+ // temporary path. The real default lives under tests/ but is NOT committed
70
+ // by this plan (18-03) — plan 04 generates and commits it against the final
71
+ // post-REBR-05 tree. A missing file is treated as an empty baseline.
72
+ const DEFAULT_BASELINE_PATH = path.join(__dirname, '..', 'tests', 'rename-guard-baseline.json');
73
+ const BASELINE_PATH = process.env.RENAME_GUARD_BASELINE_PATH
74
+ ? path.resolve(process.env.RENAME_GUARD_BASELINE_PATH)
75
+ : DEFAULT_BASELINE_PATH;
76
+
77
+ // A NUL byte between the relative path and the normalized line text — cannot
78
+ // occur in either half of a text-file scan, so it is a safe composite-key
79
+ // separator for the Set-diff below.
80
+ const KEY_SEP = '\u0000';
81
+
82
+ /**
83
+ * isBinary(fullPath) — NUL-byte probe on the first 512 bytes.
84
+ * Copied verbatim from scripts/lint-legacy-dir-name.cjs's isBinary().
85
+ *
86
+ * @param {string} fullPath absolute path to probe
87
+ * @returns {boolean}
88
+ */
89
+ function isBinary(fullPath) {
90
+ let fd;
91
+ try {
92
+ fd = fs.openSync(fullPath, 'r');
93
+ const buf = Buffer.allocUnsafe(512);
94
+ const bytesRead = fs.readSync(fd, buf, 0, 512, 0);
95
+ return buf.subarray(0, bytesRead).includes(0);
96
+ } catch {
97
+ return false;
98
+ } finally {
99
+ if (fd != null) {
100
+ try { fs.closeSync(fd); } catch { /* best-effort */ }
101
+ }
102
+ }
103
+ }
104
+
105
+ /**
106
+ * normalizeLine(line) — content-key normalization (D-02, Pitfall 4).
107
+ *
108
+ * `String.prototype.trimEnd()` strips trailing whitespace AND trailing line
109
+ * terminators (including a lone `\r` left behind when a CRLF file is split
110
+ * on `\n`), so a single trimEnd() call both trims trailing whitespace and
111
+ * neutralizes the CRLF/LF cross-platform baseline-desync pitfall documented
112
+ * in tests/workflow-size-budget.test.cjs's #683 regression.
113
+ *
114
+ * @param {string} line
115
+ * @returns {string}
116
+ */
117
+ function normalizeLine(line) {
118
+ return line.trimEnd();
119
+ }
120
+
121
+ /**
122
+ * collectHits(repoRoot) — enumerate the whole tracked tree and collect every
123
+ * RENAME_RE hit not exempted by a whole-file carve-out (isFrozen/isAttribution)
124
+ * or a per-line keep-token (isKeepTokenLine).
125
+ *
126
+ * @param {string} repoRoot absolute path to scan (git ls-files cwd)
127
+ * @returns {{
128
+ * byFile: Object<string, Set<string>>,
129
+ * hitList: Array<{file: string, line: number, text: string}>,
130
+ * fileCount: number,
131
+ * }}
132
+ */
133
+ function collectHits(repoRoot) {
134
+ let trackedFiles;
135
+ try {
136
+ trackedFiles = execFileSync('git', ['ls-files'], { cwd: repoRoot, encoding: 'utf8' })
137
+ .split('\n')
138
+ .map((f) => f.trim())
139
+ .filter(Boolean);
140
+ } catch (err) {
141
+ throw new ExitError(1, 'ERROR verify-rename-guard: git ls-files failed: ' + err.message);
142
+ }
143
+
144
+ /** @type {Object<string, Set<string>>} */
145
+ const byFile = {};
146
+ /** @type {Array<{file: string, line: number, text: string}>} */
147
+ const hitList = [];
148
+
149
+ for (const relPath of trackedFiles) {
150
+ const fullPath = path.join(repoRoot, relPath);
151
+
152
+ // D-09/D-10 whole-file/dir carve-out layer — single-source authority.
153
+ if (keepTokens.isFrozen(fullPath) || keepTokens.isAttribution(fullPath)) continue;
154
+
155
+ if (isBinary(fullPath)) continue;
156
+
157
+ let content;
158
+ try {
159
+ content = fs.readFileSync(fullPath, 'utf8');
160
+ } catch {
161
+ // Unreadable file (permissions, race with deletion, etc.) — skip silently.
162
+ continue;
163
+ }
164
+
165
+ const lines = content.split('\n');
166
+ for (let i = 0; i < lines.length; i++) {
167
+ const raw = lines[i];
168
+ if (!RENAME_RE.test(raw)) continue;
169
+
170
+ // D-09 per-line exemption — single-source authority.
171
+ if (keepTokens.isKeepTokenLine(raw)) continue;
172
+
173
+ const normalized = normalizeLine(raw);
174
+ if (!byFile[relPath]) byFile[relPath] = new Set();
175
+ // Pitfall 3 (accepted limitation): two identical-content gsd lines in the
176
+ // same file collapse to one Set entry — occurrence count is not tracked.
177
+ // This is an intentional D-02 trade-off, not a bug — do not "fix" it into
178
+ // a fragile per-occurrence counter.
179
+ byFile[relPath].add(normalized);
180
+ hitList.push({ file: relPath, line: i + 1, text: normalized });
181
+ }
182
+ }
183
+
184
+ return { byFile, hitList, fileCount: trackedFiles.length };
185
+ }
186
+
187
+ /**
188
+ * loadBaseline(baselinePath) — read the committed baseline JSON.
189
+ * A missing or unreadable/invalid file is treated as an EMPTY baseline (this
190
+ * plan does not commit a real-tree baseline; plan 04 does).
191
+ *
192
+ * @param {string} baselinePath
193
+ * @returns {Object<string, string[]>}
194
+ */
195
+ function loadBaseline(baselinePath) {
196
+ try {
197
+ const raw = fs.readFileSync(baselinePath, 'utf8');
198
+ return JSON.parse(raw);
199
+ } catch {
200
+ return {};
201
+ }
202
+ }
203
+
204
+ /**
205
+ * serializeBaseline(byFile) — stable sorted serializer for --update-baseline.
206
+ * Mirrors scripts/update-size-baseline.cjs's serializeBaseline() shape:
207
+ * sorted file keys, sorted per-file line arrays, 2-space indent, trailing \n.
208
+ *
209
+ * @param {Object<string, Set<string>|string[]>} byFile
210
+ * @returns {string}
211
+ */
212
+ function serializeBaseline(byFile) {
213
+ const sorted = {};
214
+ for (const key of Object.keys(byFile).sort()) {
215
+ sorted[key] = [...byFile[key]].sort();
216
+ }
217
+ return JSON.stringify(sorted, null, 2) + '\n';
218
+ }
219
+
220
+ /**
221
+ * computeNovel(byFile, baseline) — D-04 growth-only Set-diff.
222
+ *
223
+ * Returns ONLY current-minus-known (novel) composite keys — NEVER
224
+ * known-minus-current (stale/shrink), because shrinking or removing a
225
+ * baselined gsd reference is always safe (D-04). This deliberately does NOT
226
+ * reuse scripts/lib/allowlist-ratchet.cjs's assertWithinAllowlist() unmodified,
227
+ * since that helper also fails on stale entries (see Pitfall 2 in RESEARCH.md).
228
+ *
229
+ * @param {Object<string, Set<string>>} byFile current scan result
230
+ * @param {Object<string, string[]>} baseline loaded baseline
231
+ * @returns {string[]} sorted array of "relPath normalizedLine" keys
232
+ */
233
+ function computeNovel(byFile, baseline) {
234
+ const currentKeys = new Set();
235
+ for (const [file, lines] of Object.entries(byFile)) {
236
+ for (const line of lines) currentKeys.add(file + KEY_SEP + line);
237
+ }
238
+
239
+ const knownKeys = new Set();
240
+ for (const [file, lines] of Object.entries(baseline)) {
241
+ for (const line of lines) knownKeys.add(file + KEY_SEP + line);
242
+ }
243
+
244
+ return [...currentKeys].filter((k) => !knownKeys.has(k)).sort();
245
+ }
246
+
247
+ /**
248
+ * runScan(opts?) — pure scan entry point (no process.exit, no stdout/stderr
249
+ * writes). Consumed by the CLI wrapper (cliMain) and directly by tests.
250
+ *
251
+ * @param {object} [opts]
252
+ * @param {string} [opts.repoRoot] defaults to the module-level REPO_ROOT
253
+ * @param {string} [opts.baselinePath] defaults to the module-level BASELINE_PATH
254
+ * @returns {{
255
+ * byFile: Object<string, Set<string>>,
256
+ * hitList: Array<{file: string, line: number, text: string}>,
257
+ * baseline: Object<string, string[]>,
258
+ * novel: string[],
259
+ * fileCount: number,
260
+ * repoRoot: string,
261
+ * baselinePath: string,
262
+ * }}
263
+ */
264
+ function runScan(opts) {
265
+ const repoRoot = (opts && opts.repoRoot) || REPO_ROOT;
266
+ const baselinePath = (opts && opts.baselinePath) || BASELINE_PATH;
267
+
268
+ const { byFile, hitList, fileCount } = collectHits(repoRoot);
269
+ const baseline = loadBaseline(baselinePath);
270
+ const novel = computeNovel(byFile, baseline);
271
+
272
+ return { byFile, hitList, baseline, novel, fileCount, repoRoot, baselinePath };
273
+ }
274
+
275
+ /**
276
+ * splitKey(key) — inverse of the KEY_SEP composite key used by computeNovel().
277
+ * @param {string} key
278
+ * @returns {{file: string, text: string}}
279
+ */
280
+ function splitKey(key) {
281
+ const idx = key.indexOf(KEY_SEP);
282
+ return { file: key.slice(0, idx), text: key.slice(idx + 1) };
283
+ }
284
+
285
+ // ── CLI entry ────────────────────────────────────────────────────────────────
286
+
287
+ function cliMain() {
288
+ if (process.argv.includes('--update-baseline')) {
289
+ const { byFile, fileCount } = collectHits(REPO_ROOT);
290
+ fs.writeFileSync(BASELINE_PATH, serializeBaseline(byFile));
291
+ process.stdout.write(
292
+ 'ok verify-rename-guard: baseline updated at ' +
293
+ path.relative(REPO_ROOT, BASELINE_PATH) +
294
+ ' (' + fileCount + ' tracked file(s) scanned, ' +
295
+ Object.keys(byFile).length + ' file(s) with gsd reference(s))\n'
296
+ );
297
+ return 0;
298
+ }
299
+
300
+ const { novel, hitList, fileCount } = runScan();
301
+
302
+ if (novel.length === 0) {
303
+ process.stdout.write(
304
+ 'ok verify-rename-guard: ' + fileCount + ' tracked file(s) checked, 0 novel gsd reference(s)\n'
305
+ );
306
+ return 0;
307
+ }
308
+
309
+ process.stderr.write(
310
+ '\nERROR verify-rename-guard: ' + novel.length +
311
+ ' novel gsd reference(s) found (not in the committed baseline)\n\n'
312
+ );
313
+ for (const key of novel) {
314
+ const { file, text } = splitKey(key);
315
+ const hit = hitList.find((h) => h.file === file && h.text === text);
316
+ const lineNo = hit ? hit.line : '?';
317
+ process.stderr.write(' ' + file + ':' + lineNo + ' — ' + JSON.stringify(text) + '\n');
318
+ }
319
+ process.stderr.write('\n');
320
+ process.stderr.write(
321
+ 'Fix at the source, add the `gsd-allow-legacy-name` marker on the line if the\n' +
322
+ 'use is intentional, or run `node scripts/verify-rename-guard.cjs --update-baseline`\n' +
323
+ 'if the reference is legitimately grandfathered.\n\n'
324
+ );
325
+
326
+ throw new ExitError(1);
327
+ }
328
+
329
+ module.exports = { runScan, RENAME_RE, serializeBaseline, collectHits, loadBaseline, computeNovel };
330
+
331
+ if (require.main === module) {
332
+ runMain(cliMain);
333
+ }