@pareto4/pp-core 1.11.0 → 1.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pp-core",
3
3
  "displayName": "PP Core",
4
- "version": "1.11.0",
4
+ "version": "1.11.1",
5
5
  "description": "PP Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
6
6
  "author": {
7
7
  "name": "pareto4",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pp-core",
3
- "version": "1.11.0",
3
+ "version": "1.11.1",
4
4
  "description": "PP Core — a meta-prompting, context engineering, and spec-driven development system for AI coding agents. Loads pp-core's operating context into every Gemini CLI session.",
5
5
  "contextFileName": "GEMINI.md"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pareto4/pp-core",
3
- "version": "1.11.0",
3
+ "version": "1.11.1",
4
4
  "description": "Pareto pp-core — The Pareto Project's AI-assisted engineering framework: meta-prompting, context engineering, and spec-driven development for AI coding agents.",
5
5
  "bin": {
6
6
  "pp-core": "bin/install.js",
@@ -46,6 +46,7 @@ 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;
49
50
  exports.composeBindings = composeBindings;
50
51
  exports.cmdComposeBindings = cmdComposeBindings;
51
52
  const node_fs_1 = __importDefault(require("node:fs"));
@@ -336,6 +337,22 @@ function readSeed(cwd, opts) {
336
337
  return { values, profile, warnings };
337
338
  }
338
339
  // ─── resolveLatestPlatformTag (D-04) ─────────────────────────────────────────
340
+ /**
341
+ * Per-process memo for the D-04 tag lookup, keyed by the resolved URL, holding
342
+ * the full `{ tag, warnings }` verdict — successes AND failures. One real
343
+ * `pp-tools init` run calls `composeBindings` twice (the dry-run preview in
344
+ * `computeCurrentStep`, then the write-mode call after `compose_confirm`), so
345
+ * without this memo a decline costs a network round-trip for nothing and a
346
+ * failed lookup would print its bounded warning twice.
347
+ */
348
+ const platformTagMemo = new Map();
349
+ /** Test-only seam: clear the per-process platform.tag lookup memo so a test
350
+ * suite that shares a resolved URL across cases sees an empty memo each time
351
+ * (`init-tag-lookup-raw-git-error`). Mirrors the reset-seam convention at
352
+ * `src/active-workstream-store.cts`'s controlling-TTY probe cache. */
353
+ function _resetPlatformTagMemoForTests() {
354
+ platformTagMemo.clear();
355
+ }
339
356
  /**
340
357
  * Resolve the latest STABLE tag for `repo` over the resolved host, never
341
358
  * shell-eval'd (T-34-25): `git ls-remote` is spawned with an argv array via
@@ -344,6 +361,13 @@ function readSeed(cwd, opts) {
344
361
  * else `defaultHostForProvider(provider)` — the host is authoritative and
345
362
  * never derived from the repo string (Phase 31 D-09).
346
363
  *
364
+ * The child's stderr is piped into the parent's buffer rather than inherited
365
+ * — an explicit `stdio: ['ignore', 'pipe', 'pipe']` — and deliberately
366
+ * discarded rather than logged: the bounded warning this function returns on
367
+ * failure is the single user-visible surface for a lookup failure. Inheriting
368
+ * the child's stream let a raw git `fatal:` line reach the terminal during an
369
+ * otherwise successful first-run experience (`init-tag-lookup-raw-git-error`).
370
+ *
347
371
  * Every candidate ref is filtered with `isStableTripletSemver` and ordered
348
372
  * with `compareSemverCore` — the single blessed comparison source, never
349
373
  * reimplemented. An empty result degrades to `{ tag: null, warnings: [] }`
@@ -353,13 +377,34 @@ function readSeed(cwd, opts) {
353
377
  * platform being unpinned, never a compose failure.
354
378
  */
355
379
  function resolveLatestPlatformTag(cwd, repo, provider, opts) {
380
+ let url;
356
381
  try {
357
382
  const hostRes = (0, vault_coordinates_cjs_1.resolvePlatformHost)(cwd);
358
383
  const host = hostRes.host ?? (0, vault_coordinates_cjs_1.defaultHostForProvider)(provider);
359
- const url = `https://${host}/${repo}`;
384
+ url = `https://${host}/${repo}`;
385
+ }
386
+ catch {
387
+ return {
388
+ tag: null,
389
+ warnings: [
390
+ bound('platform.tag could not be resolved — the tag lookup failed or was unreachable; the platform is unpinned.'),
391
+ ],
392
+ };
393
+ }
394
+ const cached = platformTagMemo.get(url);
395
+ if (cached)
396
+ return cached;
397
+ let result;
398
+ try {
360
399
  const lsOutput = typeof opts._lsRemote === 'function'
361
400
  ? 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 });
401
+ : node_child_process_1.default.execFileSync('git', ['ls-remote', '--tags', '--refs', '--sort=-v:refname', url, 'v*'], {
402
+ encoding: 'utf-8',
403
+ timeout: 10_000,
404
+ maxBuffer: 512 * 1024,
405
+ windowsHide: true,
406
+ stdio: ['ignore', 'pipe', 'pipe'],
407
+ });
363
408
  const tags = lsOutput
364
409
  .split('\n')
365
410
  .map((line) => {
@@ -368,23 +413,27 @@ function resolveLatestPlatformTag(cwd, repo, provider, opts) {
368
413
  })
369
414
  .filter((t) => t && (0, semver_compare_cjs_1.isStableTripletSemver)(t));
370
415
  if (tags.length === 0) {
371
- return { tag: null, warnings: [] };
416
+ result = { tag: null, warnings: [] };
372
417
  }
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;
418
+ else {
419
+ let latest = tags[0];
420
+ for (const t of tags) {
421
+ if ((0, semver_compare_cjs_1.compareSemverCore)(t, latest) > 0)
422
+ latest = t;
423
+ }
424
+ result = { tag: latest, warnings: [] };
377
425
  }
378
- return { tag: latest, warnings: [] };
379
426
  }
380
427
  catch {
381
- return {
428
+ result = {
382
429
  tag: null,
383
430
  warnings: [
384
431
  bound('platform.tag could not be resolved — the tag lookup failed or was unreachable; the platform is unpinned.'),
385
432
  ],
386
433
  };
387
434
  }
435
+ platformTagMemo.set(url, result);
436
+ return result;
388
437
  }
389
438
  /**
390
439
  * Read the first readable, semver-shaped `VERSION` file among the installed
@@ -165,6 +165,14 @@ function getDotted(obj, dottedPath) {
165
165
  * `optionalAnswer: true` — that flag describes the ANSWER (pressing Enter
166
166
  * is a complete, valid response), never whether the question is asked.
167
167
  *
168
+ * Question 6 (`org_profile`)'s `preselected` is resolved via
169
+ * `resolveDetectedOrgProfile` — set only when exactly one org-profile-shaped
170
+ * package is installed (INIT-PATCH-C); the prompt text is built from that
171
+ * detected value rather than kept as two hard-coded strings that can drift.
172
+ * `opts._globalRoot` is forwarded to it unchanged, the same test-seam
173
+ * convention `verifyInitInvariants`'s own `_globalRoot` already follows —
174
+ * production callers omit it.
175
+ *
168
176
  * Question 7 (`provider`) carries the fixed, complete option list: every
169
177
  * value in `PROVIDERS` (imported, never retyped — this is what keeps
170
178
  * `azure` from being silently dropped again, T-34-50) plus the three
@@ -173,7 +181,7 @@ function getDotted(obj, dottedPath) {
173
181
  * probe reads (pure config + org-profile consultation, no network) — so
174
182
  * calling it directly here is equivalent, not a second implementation.
175
183
  */
176
- function buildInitQuestions(cwd) {
184
+ function buildInitQuestions(cwd, opts = {}) {
177
185
  let preselected = git_provider_cjs_1.DEFAULT_PROVIDER;
178
186
  try {
179
187
  preselected = (0, git_provider_cjs_1.resolveProvider)(cwd).provider;
@@ -182,6 +190,29 @@ function buildInitQuestions(cwd) {
182
190
  preselected = git_provider_cjs_1.DEFAULT_PROVIDER;
183
191
  }
184
192
  const providerOptions = Object.freeze([...git_provider_cjs_1.PROVIDERS, ...IDENTITY_ONLY_PROVIDER_OPTIONS]);
193
+ const detectedOrgProfile = resolveDetectedOrgProfile(cwd, opts);
194
+ // The template example is written as an angle-bracket placeholder, not a
195
+ // literal package name. `tests/org-profile.test.cjs`'s client-confidentiality
196
+ // guard scans this file for any concrete scope-slash-org-prefix package
197
+ // identifier, so even a generic literal placeholder trips it — the template
198
+ // keeps the naming convention visible while staying clean, and the guard
199
+ // stays maximally strict, no allowlist entry was added for it. The detected
200
+ // value itself is never a literal in this file — it is a runtime string
201
+ // read from an installed package, interpolated into the prompt below.
202
+ const orgProfileQuestion = isUsableString(detectedOrgProfile)
203
+ ? {
204
+ id: 'org_profile',
205
+ prompt: `Org profile package (optional — a single installed org profile was detected: "${detectedOrgProfile}". ` +
206
+ 'Press Enter to use it, type "none" to skip it, or type a different package name.)',
207
+ optionalAnswer: true,
208
+ preselected: detectedOrgProfile,
209
+ preselectedLabel: `${detectedOrgProfile} (detected installed profile)`,
210
+ }
211
+ : {
212
+ id: 'org_profile',
213
+ prompt: 'Org profile package (optional — press Enter to skip, e.g. "@<your-scope>/org-<name>")',
214
+ optionalAnswer: true,
215
+ };
185
216
  return [
186
217
  { id: 'project_name', prompt: 'Project name (human-readable, e.g. "Acme Dashboard")', optionalAnswer: false },
187
218
  { id: 'project_slug', prompt: 'Project slug (kebab-case, e.g. "acme-dashboard")', optionalAnswer: false },
@@ -196,17 +227,7 @@ function buildInitQuestions(cwd) {
196
227
  optionalAnswer: false,
197
228
  },
198
229
  { id: 'description', prompt: 'One-line description (what this project does)', optionalAnswer: false },
199
- {
200
- id: 'org_profile',
201
- // The example is written as an angle-bracket TEMPLATE, not a literal package
202
- // name. `tests/org-profile.test.cjs`'s client-confidentiality guard scans this
203
- // file for any concrete scope-slash-org-prefix package identifier, so even a
204
- // generic literal placeholder trips it. The template keeps the naming
205
- // convention visible while staying clean, and the guard stays maximally
206
- // strict — no allowlist entry was added for it.
207
- prompt: 'Org profile package (optional — press Enter to skip, e.g. "@<your-scope>/org-<name>")',
208
- optionalAnswer: true,
209
- },
230
+ orgProfileQuestion,
210
231
  {
211
232
  id: 'provider',
212
233
  prompt: 'Provider (gitlab / azure / github / other / none)',
@@ -249,30 +270,47 @@ function resolveGlobalNodeModulesRoot() {
249
270
  return null;
250
271
  }
251
272
  }
273
+ /** Conservative npm-package-name shape check (bare `name` or scoped
274
+ * `@scope/name`; lowercase, digits, `.`/`_`/`-`, never leading `.`/`_` in
275
+ * either segment). A directory name that does not match is never offered as
276
+ * a detected default (T-QP-02): it cannot be a resolvable package pointer,
277
+ * so rejecting it here keeps a hostile directory name (control characters,
278
+ * terminal escapes) from ever reaching a rendered prompt. */
279
+ const NPM_PACKAGE_NAME_SHAPE_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
252
280
  /**
253
- * True when `nodeModulesDir` contains at least one package directory (one
254
- * level, or one level inside an `@scope/` directory) carrying a file named
255
- * `ORG_PROFILE_DATA_FILE` at its root — the same fixed filename
256
- * `resolveOrgProfileDocument` reads, imported rather than retyped. Bounded
257
- * at `NODE_MODULES_SCAN_CAP` total directory entries visited (T-34-54: this
258
- * check must never hang on a pathological `node_modules` tree).
281
+ * Walk `nodeModulesDir` exactly like the boolean check below (one level, or
282
+ * one level inside an `@scope/` directory), collecting the name of every
283
+ * package directory carrying `ORG_PROFILE_DATA_FILE` at its root instead of
284
+ * stopping at the first hit. `opts.limit`, when set, stops the walk once
285
+ * that many names have been collected — passing `1` reproduces the boolean
286
+ * check's early-exit behaviour exactly. Bounded at `NODE_MODULES_SCAN_CAP`
287
+ * total directory entries visited (T-34-54: this check must never hang on a
288
+ * pathological `node_modules` tree).
259
289
  *
260
290
  * This is a heuristic, not a resolution: it answers "a package shaped like
261
291
  * an org profile is installed here", not "this exact profile would
262
- * resolve" — `verifyInitInvariants` never claims more than that.
292
+ * resolve" — callers never claim more than that.
263
293
  */
264
- function containsOrgProfileShapedPackage(nodeModulesDir) {
294
+ function collectOrgProfileShapedPackages(nodeModulesDir, opts = {}) {
295
+ const names = [];
265
296
  let visited = 0;
297
+ const limitReached = () => opts.limit !== undefined && names.length >= opts.limit;
298
+ const addIfShaped = (name) => {
299
+ if (NPM_PACKAGE_NAME_SHAPE_RE.test(name))
300
+ names.push(name);
301
+ };
266
302
  let entries;
267
303
  try {
268
304
  entries = node_fs_1.default.readdirSync(nodeModulesDir, { withFileTypes: true });
269
305
  }
270
306
  catch {
271
- return false;
307
+ return { names, capped: false };
272
308
  }
273
309
  for (const entry of entries) {
310
+ if (limitReached())
311
+ return { names, capped: false };
274
312
  if (visited >= NODE_MODULES_SCAN_CAP)
275
- return false;
313
+ return { names, capped: true };
276
314
  visited += 1;
277
315
  if (!entry.isDirectory())
278
316
  continue;
@@ -286,14 +324,17 @@ function containsOrgProfileShapedPackage(nodeModulesDir) {
286
324
  continue;
287
325
  }
288
326
  for (const scopedEntry of scoped) {
327
+ if (limitReached())
328
+ return { names, capped: false };
289
329
  if (visited >= NODE_MODULES_SCAN_CAP)
290
- return false;
330
+ return { names, capped: true };
291
331
  visited += 1;
292
332
  if (!scopedEntry.isDirectory())
293
333
  continue;
294
334
  try {
295
- if (node_fs_1.default.existsSync(node_path_1.default.join(scopeDir, scopedEntry.name, org_profile_cjs_1.ORG_PROFILE_DATA_FILE)))
296
- return true;
335
+ if (node_fs_1.default.existsSync(node_path_1.default.join(scopeDir, scopedEntry.name, org_profile_cjs_1.ORG_PROFILE_DATA_FILE))) {
336
+ addIfShaped(`${entry.name}/${scopedEntry.name}`);
337
+ }
297
338
  }
298
339
  catch {
299
340
  continue;
@@ -302,15 +343,27 @@ function containsOrgProfileShapedPackage(nodeModulesDir) {
302
343
  }
303
344
  else {
304
345
  try {
305
- if (node_fs_1.default.existsSync(node_path_1.default.join(nodeModulesDir, entry.name, org_profile_cjs_1.ORG_PROFILE_DATA_FILE)))
306
- return true;
346
+ if (node_fs_1.default.existsSync(node_path_1.default.join(nodeModulesDir, entry.name, org_profile_cjs_1.ORG_PROFILE_DATA_FILE))) {
347
+ addIfShaped(entry.name);
348
+ }
307
349
  }
308
350
  catch {
309
351
  continue;
310
352
  }
311
353
  }
312
354
  }
313
- return false;
355
+ return { names, capped: false };
356
+ }
357
+ /**
358
+ * True when `nodeModulesDir` contains at least one package directory (one
359
+ * level, or one level inside an `@scope/` directory) carrying a file named
360
+ * `ORG_PROFILE_DATA_FILE` at its root — the same fixed filename
361
+ * `resolveOrgProfileDocument` reads, imported rather than retyped. Reduced to
362
+ * the collecting walk above with `limit: 1`, preserving its early-exit
363
+ * behaviour exactly.
364
+ */
365
+ function containsOrgProfileShapedPackage(nodeModulesDir) {
366
+ return collectOrgProfileShapedPackages(nodeModulesDir, { limit: 1 }).names.length > 0;
314
367
  }
315
368
  function orgProfilePackageResolvable(cwd, opts) {
316
369
  try {
@@ -328,6 +381,44 @@ function orgProfilePackageResolvable(cwd, opts) {
328
381
  return false;
329
382
  }
330
383
  }
384
+ /**
385
+ * Detect a single, unambiguous installed org-profile-shaped package
386
+ * (INIT-PATCH-C): collects from the project's `node_modules` and — using the
387
+ * same `opts._globalRoot` override convention `orgProfilePackageResolvable`
388
+ * already follows — the global root, de-duplicates by package name across
389
+ * both roots, and returns that single name only when exactly one distinct
390
+ * name was found and NEITHER scan reported `capped`. A capped scan is
391
+ * inconclusive and must never yield a default (T-QP-03). Two or more
392
+ * distinct names, or none, returns null. Never throws.
393
+ */
394
+ function resolveDetectedOrgProfile(cwd, opts) {
395
+ try {
396
+ const names = new Set();
397
+ let anyCapped = false;
398
+ const projectNodeModules = node_path_1.default.join(cwd, 'node_modules');
399
+ if (node_fs_1.default.existsSync(projectNodeModules)) {
400
+ const projectResult = collectOrgProfileShapedPackages(projectNodeModules);
401
+ if (projectResult.capped)
402
+ anyCapped = true;
403
+ for (const name of projectResult.names)
404
+ names.add(name);
405
+ }
406
+ const globalRoot = opts._globalRoot !== undefined ? opts._globalRoot : resolveGlobalNodeModulesRoot();
407
+ if (globalRoot) {
408
+ const globalResult = collectOrgProfileShapedPackages(globalRoot);
409
+ if (globalResult.capped)
410
+ anyCapped = true;
411
+ for (const name of globalResult.names)
412
+ names.add(name);
413
+ }
414
+ if (anyCapped)
415
+ return null;
416
+ return names.size === 1 ? [...names][0] : null;
417
+ }
418
+ catch {
419
+ return null;
420
+ }
421
+ }
331
422
  /** Read `.planning/config.json` at `cwd`, distinguishing "file absent" from
332
423
  * "file present but empty/malformed" — `INIT_IDENTITY_MISSING` only fires
333
424
  * on the latter (see `verifyInitInvariants`'s own doc comment for why an
@@ -654,7 +745,7 @@ function computeCurrentStep(cwd, session, opts) {
654
745
  // the convention: any failure anywhere in this body degrades to a
655
746
  // `write_failed` verdict instead of propagating.
656
747
  try {
657
- const questions = buildInitQuestions(cwd);
748
+ const questions = buildInitQuestions(cwd, { _globalRoot: opts._globalRoot });
658
749
  for (const q of questions) {
659
750
  if (!Object.prototype.hasOwnProperty.call(session.answers, q.id)) {
660
751
  return { kind: 'question', question: q };
@@ -168,7 +168,26 @@ async function runInitInteractive(cwd, prompter, opts = {}) {
168
168
  preselectedLabel: state.preselectedLabel,
169
169
  optionalAnswer: state.optionalAnswer,
170
170
  });
171
- const result = (0, init_contract_cjs_1.initSessionAnswer)(cwd, stepId, answer, sessionOpts);
171
+ // INIT-PATCH-C: Enter accepts a carried default, "none" clears it.
172
+ // Scoped to an OPTIONAL step carrying a usable `preselected` — today
173
+ // that is only `org_profile` when exactly one org-profile-shaped
174
+ // package was detected. `provider` also carries a `preselected` but is
175
+ // NOT `optionalAnswer`, so the `state.optionalAnswer === true` guard
176
+ // below leaves it untouched: a blank answer there still falls through
177
+ // to `initSessionAnswer` verbatim, which still rejects it and re-asks
178
+ // exactly as today.
179
+ const hasUsableDefault = state.optionalAnswer === true && typeof state.preselected === 'string' && state.preselected.trim() !== '';
180
+ let submittedAnswer = answer;
181
+ if (hasUsableDefault) {
182
+ const trimmedLower = answer.trim().toLowerCase();
183
+ if (answer.trim() === '') {
184
+ submittedAnswer = state.preselected;
185
+ }
186
+ else if (trimmedLower === 'none') {
187
+ submittedAnswer = '';
188
+ }
189
+ }
190
+ const result = (0, init_contract_cjs_1.initSessionAnswer)(cwd, stepId, submittedAnswer, sessionOpts);
172
191
  if (result.ok) {
173
192
  if (stepId === 'compose_confirm') {
174
193
  wroteBinding = readBindingsContent(cwd) !== preBindings;
@@ -725,9 +725,17 @@ function createReadlinePrompter() {
725
725
  )
726
726
  .join(' / ');
727
727
  rendered += `\n Options: ${optionsRendered}`;
728
+ } else if (question.preselected) {
729
+ // INIT-PATCH-C: an option-less question carrying a `preselected`
730
+ // (e.g. a detected org profile) rendered no default at all today —
731
+ // render one, naming it via `preselectedLabel` when present.
732
+ rendered += `\n Default: ${question.preselectedLabel || question.preselected} (press Enter to accept)`;
728
733
  }
729
734
  if (question.optionalAnswer) {
730
- rendered += '\n (press Enter to skip)';
735
+ // The unconditional "Enter skips" hint would be a lie once a
736
+ // default is carried — Enter now ACCEPTS the default above, so the
737
+ // skip hint must point at "none" instead.
738
+ rendered += question.preselected ? '\n (type "none" to skip)' : '\n (press Enter to skip)';
731
739
  }
732
740
  return new Promise((resolve) => {
733
741
  rl.question(`${rendered}\n> `, (answer) => resolve(answer));
@@ -1497,6 +1505,8 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
1497
1505
  if (!process.stdin.isTTY) {
1498
1506
  error(
1499
1507
  '"pp-tools init" requires an interactive terminal (stdin is not a TTY) and will not prompt. ' +
1508
+ 'This command cannot run inside an AI-agent shell or a piped session — open a regular ' +
1509
+ 'terminal window (e.g. Terminal, iTerm, or Windows Terminal) and run it there. ' +
1500
1510
  'For a non-interactive/CI-driven session, use "pp-tools query init-contract --session-next" / ' +
1501
1511
  '"--session-answer <stepId>=<value>" directly instead.',
1502
1512
  ERROR_REASON.INIT_NON_INTERACTIVE,