@pareto4/pp-core 1.9.0 → 1.11.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 (48) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/pp-code-fixer.md +11 -0
  3. package/agents/pp-code-reviewer.md +77 -1
  4. package/agents/pp-executor.md +34 -1
  5. package/agents/pp-planner.md +24 -103
  6. package/commands/pp-ns-manage.md +2 -1
  7. package/commands/pp-vault-promote.md +31 -0
  8. package/gemini-extension.json +1 -1
  9. package/package.json +5 -3
  10. package/pareto-skills/pp-brand/SKILL.md +1 -1
  11. package/pp-core/bin/lib/capability-registry.cjs +160 -3
  12. package/pp-core/bin/lib/capability-state.cjs +41 -3
  13. package/pp-core/bin/lib/check-command-router.cjs +473 -3
  14. package/pp-core/bin/lib/clusters.cjs +3 -0
  15. package/pp-core/bin/lib/compose-bindings.cjs +736 -0
  16. package/pp-core/bin/lib/git-provider.cjs +279 -0
  17. package/pp-core/bin/lib/init-contract.cjs +989 -0
  18. package/pp-core/bin/lib/init-interactive.cjs +183 -0
  19. package/pp-core/bin/lib/io.cjs +9 -0
  20. package/pp-core/bin/lib/org-profile.cjs +696 -0
  21. package/pp-core/bin/lib/platform-bindings.cjs +268 -64
  22. package/pp-core/bin/lib/practices-review-check.cjs +938 -0
  23. package/pp-core/bin/lib/vault-command-router.cjs +239 -2
  24. package/pp-core/bin/lib/vault-coordinates.cjs +485 -0
  25. package/pp-core/bin/lib/vault-practices.cjs +982 -0
  26. package/pp-core/bin/lib/vault-write-back.cjs +842 -0
  27. package/pp-core/bin/lib/vault.cjs +170 -51
  28. package/pp-core/bin/pp-tools.cjs +225 -2
  29. package/pp-core/references/planner-checkpoints.md +72 -0
  30. package/pp-core/references/planner-tdd.md +55 -0
  31. package/pp-core/references/planner-vault-practices.md +67 -0
  32. package/pp-core/templates/practices/README.md +122 -0
  33. package/pp-core/templates/practices/error-handling.md +18 -0
  34. package/pp-core/templates/practices/minimal-comments.md +14 -0
  35. package/pp-core/templates/practices/naming-convention.md +18 -0
  36. package/pp-core/templates/practices/practice-template.md +23 -0
  37. package/pp-core/workflows/code-review.md +74 -18
  38. package/pp-core/workflows/discovery-sprint.md +1 -1
  39. package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +1 -1
  40. package/pp-core/workflows/init.md +137 -76
  41. package/pp-core/workflows/ship.md +1 -0
  42. package/pp-core/workflows/vault-write-back.md +213 -0
  43. package/scripts/gen-capability-registry.cjs +68 -8
  44. package/scripts/gen-platform-bindings-schema.cjs +148 -0
  45. package/scripts/lint-allow-test-rule-refs.allowlist.json +8 -1
  46. package/scripts/lint-descriptions.cjs +3 -1
  47. package/scripts/lint-legacy-dir-name.cjs +5 -0
  48. package/scripts/lint-test-file-count.allowlist.json +10 -1
@@ -0,0 +1,736 @@
1
+ "use strict";
2
+ /**
3
+ * src/compose-bindings.cts — the bindings composer (milestone v1.11.0, Phase 34-03).
4
+ *
5
+ * ADR-457 build-at-publish: this TypeScript file is the SOURCE OF TRUTH. It
6
+ * compiles to the git-ignored `pp-core/bin/lib/compose-bindings.cjs`; do NOT
7
+ * hand-edit the compiled output. Behaviour is locked by
8
+ * `tests/compose-bindings.test.cjs`.
9
+ *
10
+ * This module reads the D-01 seed section an org profile package may carry
11
+ * (`bindings.*`, read via `resolveOrgProfileDocument` — never
12
+ * `readProfileValue`, whose allowlist gate deliberately excludes the seed
13
+ * namespace) plus the project answers `/pp-init` already captured in
14
+ * `.planning/config.json`, composes a `.platform-bindings.json` candidate,
15
+ * delegates every shape rule to `validateBindings` (never a second regex for
16
+ * `platform.repo`/`client.vaultRepo`/`client.slug`/`project.slug`/`platform.tag`),
17
+ * pins `platform.tag` to the seeded platform's latest stable tag over the
18
+ * resolved host, merges absent keys only against any existing file, and
19
+ * writes only when explicitly told to (`opts.write === true`). Dry-run is the
20
+ * no-flag default (D-10) — `composeBindings` and `cmdComposeBindings` share
21
+ * this exact code path with no second implementation for the `/pp-init` call
22
+ * site.
23
+ *
24
+ * D-05 puts this composition in a CLI arm rather than `init.md` shell: the
25
+ * CLI-writer + `.md` shell-glue seam is a known blindspot in this repo —
26
+ * `node:test` covers the writer while the heredoc JSON and the parsed-key
27
+ * round-trip go untested, and that is exactly how the
28
+ * `generate-client-context` CR-02 bug shipped. A file that governs the vault
29
+ * write guard must not be assembled where the suite cannot reach it. Every
30
+ * JSON object this module writes is built in compiled code via
31
+ * `JSON.stringify`/object literals — never by `.md` shell string
32
+ * concatenation.
33
+ *
34
+ * This module sits at the top of the CommonJS graph — nothing imports it —
35
+ * so its top-level imports of `./org-profile.cjs`, `./platform-bindings.cjs`,
36
+ * `./git-provider.cjs`, `./vault-coordinates.cjs`, `./io.cjs`,
37
+ * `./project-root.cjs`, `./package-identity.cjs` and
38
+ * `./shell-command-projection.cjs` create no require cycle: none of those
39
+ * modules imports this one, at load time or lazily.
40
+ *
41
+ * Like `src/org-profile.cts`, this file's own prose and code deliberately
42
+ * never name a concrete client or a concrete org profile package.
43
+ */
44
+ var __importDefault = (this && this.__importDefault) || function (mod) {
45
+ return (mod && mod.__esModule) ? mod : { "default": mod };
46
+ };
47
+ Object.defineProperty(exports, "__esModule", { value: true });
48
+ exports.COMPOSE_BINDINGS_FLAGS = exports.SEED_VALUE_MAX_LENGTH = void 0;
49
+ exports.composeBindings = composeBindings;
50
+ exports.cmdComposeBindings = cmdComposeBindings;
51
+ const node_fs_1 = __importDefault(require("node:fs"));
52
+ const node_path_1 = __importDefault(require("node:path"));
53
+ const node_child_process_1 = __importDefault(require("node:child_process"));
54
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
55
+ const io = require("./io.cjs");
56
+ const { output } = io;
57
+ const project_root_cjs_1 = require("./project-root.cjs");
58
+ const package_identity_cjs_1 = require("./package-identity.cjs");
59
+ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
60
+ const semver_compare_cjs_1 = require("./semver-compare.cjs");
61
+ const org_profile_cjs_1 = require("./org-profile.cjs");
62
+ const platform_bindings_cjs_1 = require("./platform-bindings.cjs");
63
+ const git_provider_cjs_1 = require("./git-provider.cjs");
64
+ const vault_coordinates_cjs_1 = require("./vault-coordinates.cjs");
65
+ // ─── Constants ───────────────────────────────────────────────────────────────
66
+ /**
67
+ * The maximum accepted length of a seed value. Rejecting a value past this
68
+ * bound is preferred to truncating it: a truncated coordinate that still got
69
+ * written would be a silently wrong clone target.
70
+ */
71
+ exports.SEED_VALUE_MAX_LENGTH = 200;
72
+ /**
73
+ * D-03's required core: any of these failing means compose nothing. Every
74
+ * other field `validateBindings` might reject is OPTIONAL — dropped with a
75
+ * warning rather than blocking the whole compose.
76
+ */
77
+ const REQUIRED_ERROR_PREFIXES = Object.freeze(['root', 'schemaVersion', 'platform', 'platform.repo']);
78
+ /** Maps each D-01 seed key (fully dotted, `ORG_PROFILE_SEED_SECTION`-prefixed)
79
+ * to the short name this module carries it under internally. */
80
+ const SEED_KEY_SHORT_NAMES = Object.freeze({
81
+ [`${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.platform.repo`]: 'platformRepo',
82
+ [`${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.client.vaultRepo`]: 'clientVaultRepo',
83
+ [`${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.client.vaultRef`]: 'clientVaultRef',
84
+ [`${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.client.vaultHost`]: 'clientVaultHost',
85
+ [`${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.provisioning.group`]: 'provisioningGroup',
86
+ });
87
+ // Compiled once at module load, without the global flag (a global regex
88
+ // carries `lastIndex` state across `test` calls) — the same convention
89
+ // `src/git-provider.cts` and `src/org-profile.cts` both use for their own
90
+ // copies of this array. Imported, never re-typed (T-34-21).
91
+ const compiledRejectedCloneTargetPatterns = org_profile_cjs_1.PROFILE_REJECTED_CLONE_TARGET_PATTERNS.map((source) => new RegExp(source));
92
+ /** The recognized flag set for the `compose-bindings` CLI arm (34-04's
93
+ * fail-fast unrecognized-flag guard reads this). */
94
+ exports.COMPOSE_BINDINGS_FLAGS = Object.freeze(['--write', '--offline']);
95
+ // ─── Private helpers (deliberate per-module duplication, canonical originals
96
+ // named in each comment) ──────────────────────────────────────────────────────
97
+ function isObject(v) {
98
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
99
+ }
100
+ function isUsableString(v) {
101
+ return typeof v === 'string' && v.trim() !== '';
102
+ }
103
+ /** Byte-for-byte mirror of `sanitizeForWarning` in `src/git-provider.cts`
104
+ * (lines 98-100): strip CR/LF, collapse internal whitespace, trim, truncate
105
+ * to 40 characters. */
106
+ function sanitizeForWarning(value) {
107
+ return value.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 40);
108
+ }
109
+ /** Bound a whole warning string to 200 characters, mirroring `bound` in
110
+ * `src/org-profile.cts` (line 196-249). */
111
+ function bound(warning) {
112
+ return warning.slice(0, 200);
113
+ }
114
+ /** Byte-for-byte mirror of `cfgGet` in `src/git-provider.cts` (lines 78-89).
115
+ * Written inline rather than imported for the same require-cycle reason
116
+ * every sibling seam module inlines this reader. */
117
+ function cfgGet(cwd, key, fallback) {
118
+ try {
119
+ const cfg = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(cwd, '.planning', 'config.json'), 'utf-8'));
120
+ const v = key.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), cfg);
121
+ return v === undefined ? fallback : v;
122
+ }
123
+ catch {
124
+ return fallback;
125
+ }
126
+ }
127
+ /** Append only strings not already present in `target` — mirrors
128
+ * `pushDeduped` in `src/vault-coordinates.cts` (lines 200-204). This module
129
+ * can consult the org profile and the host resolver multiple times per call;
130
+ * D-09 promises one bounded warning per rejected thing, not several copies. */
131
+ function pushDeduped(target, incoming) {
132
+ for (const w of incoming) {
133
+ if (!target.includes(w))
134
+ target.push(w);
135
+ }
136
+ }
137
+ // ─── Dotted-path object helpers (bounded, depth matches the schema's own
138
+ // shallow shape — top-level namespace, one leaf level) ────────────────────────
139
+ function hasNestedOwn(obj, dottedPath) {
140
+ if (!obj)
141
+ return false;
142
+ const parts = dottedPath.split('.');
143
+ let cur = obj;
144
+ for (const p of parts) {
145
+ if (!isObject(cur) || !Object.prototype.hasOwnProperty.call(cur, p))
146
+ return false;
147
+ cur = cur[p];
148
+ }
149
+ return true;
150
+ }
151
+ function getNested(obj, dottedPath) {
152
+ if (!obj)
153
+ return undefined;
154
+ const parts = dottedPath.split('.');
155
+ let cur = obj;
156
+ for (const p of parts) {
157
+ if (!isObject(cur))
158
+ return undefined;
159
+ cur = cur[p];
160
+ }
161
+ return cur;
162
+ }
163
+ function setNested(obj, dottedPath, value) {
164
+ const parts = dottedPath.split('.');
165
+ let cur = obj;
166
+ for (let i = 0; i < parts.length - 1; i++) {
167
+ const key = parts[i];
168
+ if (!isObject(cur[key]))
169
+ cur[key] = {};
170
+ cur = cur[key];
171
+ }
172
+ cur[parts[parts.length - 1]] = value;
173
+ }
174
+ function deleteDottedField(obj, dottedPath) {
175
+ const parts = dottedPath.split('.');
176
+ if (parts.length === 1) {
177
+ delete obj[parts[0]];
178
+ return;
179
+ }
180
+ const parent = obj[parts[0]];
181
+ if (isObject(parent)) {
182
+ delete parent[parts[1]];
183
+ if (Object.keys(parent).length === 0)
184
+ delete obj[parts[0]];
185
+ }
186
+ }
187
+ function deepCloneJsonSafe(value) {
188
+ return JSON.parse(JSON.stringify(value));
189
+ }
190
+ function valuesEqual(a, b) {
191
+ if (a === b)
192
+ return true;
193
+ try {
194
+ return JSON.stringify(a) === JSON.stringify(b);
195
+ }
196
+ catch {
197
+ return false;
198
+ }
199
+ }
200
+ /** Flatten a plain object to dotted leaf paths, bounded at depth 6 (the
201
+ * schema's own real depth is 2; the bound exists only so a pathological
202
+ * hand-edited file cannot recurse unbounded). */
203
+ function walkLeaves(obj, prefix, depthCap, out) {
204
+ if (depthCap <= 0)
205
+ return;
206
+ for (const k of Object.keys(obj)) {
207
+ const dotted = prefix ? `${prefix}.${k}` : k;
208
+ const v = obj[k];
209
+ if (isObject(v)) {
210
+ walkLeaves(v, dotted, depthCap - 1, out);
211
+ }
212
+ else {
213
+ out.push({ path: dotted, value: v });
214
+ }
215
+ }
216
+ }
217
+ function hasAnyNewKey(composed, existing) {
218
+ const leaves = [];
219
+ walkLeaves(composed, '', 6, leaves);
220
+ return leaves.some((leaf) => !hasNestedOwn(existing, leaf.path));
221
+ }
222
+ // ─── dispositionSeedValue (D-03 seed guard) ──────────────────────────────────
223
+ /**
224
+ * Guard a single D-01 seed value, never throwing. Scalar guard first
225
+ * (mirroring `profileValueDisposition` in `src/org-profile.cts`): must be a
226
+ * string; the trimmed form non-empty; the UNTRIMMED value carries no ASCII
227
+ * control character; the TRIMMED value carries no whitespace; the trimmed
228
+ * value does not begin with `-`; the trimmed length is at most
229
+ * `SEED_VALUE_MAX_LENGTH`. Then a per-key disposition built entirely from
230
+ * imported constants — this function authors no new pattern strings.
231
+ *
232
+ * `seedKey` is the FULLY DOTTED key (`ORG_PROFILE_SEED_SECTION`-prefixed,
233
+ * e.g. `bindings.client.vaultHost`), matching the entries of
234
+ * `ORG_PROFILE_SEED_KEYS`.
235
+ */
236
+ function dispositionSeedValue(seedKey, value) {
237
+ try {
238
+ if (typeof value !== 'string') {
239
+ return { ok: false, reason: 'value must be a string' };
240
+ }
241
+ const trimmed = value.trim();
242
+ if (trimmed === '') {
243
+ return { ok: false, reason: 'value must not be blank' };
244
+ }
245
+ if (/[\x00-\x1f\x7f]/.test(value)) {
246
+ return { ok: false, reason: 'value must not contain control characters' };
247
+ }
248
+ if (/\s/.test(trimmed)) {
249
+ return { ok: false, reason: 'value must not contain whitespace' };
250
+ }
251
+ if (trimmed.startsWith('-')) {
252
+ return { ok: false, reason: 'value must not begin with "-"' };
253
+ }
254
+ if (trimmed.length > exports.SEED_VALUE_MAX_LENGTH) {
255
+ return { ok: false, reason: `value exceeds the maximum accepted length of ${exports.SEED_VALUE_MAX_LENGTH} characters` };
256
+ }
257
+ if (seedKey === `${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.client.vaultHost`) {
258
+ // Authority-escape guard (T-34-20) — genuinely new surface:
259
+ // `profileValueDisposition` only ever runs inside the allowlist-gated
260
+ // walk and gives a seed key nothing for free.
261
+ for (const ch of org_profile_cjs_1.HOST_FORBIDDEN_CHARS) {
262
+ if (trimmed.includes(ch)) {
263
+ return {
264
+ ok: false,
265
+ reason: `a host must not contain ${ch} — it must be a bare host, optionally with a port`,
266
+ };
267
+ }
268
+ }
269
+ return { ok: true, value: trimmed };
270
+ }
271
+ if (seedKey === `${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.platform.repo` ||
272
+ seedKey === `${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.client.vaultRepo` ||
273
+ seedKey === `${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.provisioning.group`) {
274
+ // T-34-21: a machine-specific clone target is not accepted from a
275
+ // package — a human chose one deliberately and a package did not.
276
+ if (compiledRejectedCloneTargetPatterns.some((re) => re.test(trimmed))) {
277
+ return { ok: false, reason: 'a machine-specific path is not accepted from an org profile' };
278
+ }
279
+ if (seedKey === `${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.provisioning.group`) {
280
+ const segments = trimmed.split('/');
281
+ if (trimmed.startsWith('/') || segments.includes('..')) {
282
+ return { ok: false, reason: 'value must not begin with "/" or contain a ".." path segment' };
283
+ }
284
+ }
285
+ return { ok: true, value: trimmed };
286
+ }
287
+ // bindings.client.vaultRef: the scalar guard alone.
288
+ return { ok: true, value: trimmed };
289
+ }
290
+ catch {
291
+ return { ok: false, reason: 'value could not be validated' };
292
+ }
293
+ }
294
+ /**
295
+ * Read the D-01 seed section through `resolveOrgProfileDocument` — never
296
+ * `readProfileValue`, whose allowlist gate would return `undefined` with no
297
+ * filesystem access for a seed key and would misrepresent the boundary.
298
+ * Walks exactly the members of `ORG_PROFILE_SEED_KEYS` (never an open-ended
299
+ * walk), running each present value through `dispositionSeedValue`.
300
+ */
301
+ function readSeed(cwd, opts) {
302
+ const doc = (0, org_profile_cjs_1.resolveOrgProfileDocument)(cwd, opts);
303
+ const profile = {
304
+ resolved: doc.resolved,
305
+ packageName: doc.packageName,
306
+ version: doc.version,
307
+ source: doc.source,
308
+ dataPath: doc.dataPath,
309
+ };
310
+ const warnings = [...doc.warnings];
311
+ const values = {};
312
+ const seedSectionRaw = doc.document[org_profile_cjs_1.ORG_PROFILE_SEED_SECTION];
313
+ if (!doc.resolved) {
314
+ return { values, profile, warnings };
315
+ }
316
+ if (!isObject(seedSectionRaw)) {
317
+ if (seedSectionRaw !== undefined) {
318
+ warnings.push(bound(`org profile "${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}" section is not a JSON object — no coordinates seeded.`));
319
+ }
320
+ return { values, profile, warnings };
321
+ }
322
+ for (const dottedKey of org_profile_cjs_1.ORG_PROFILE_SEED_KEYS) {
323
+ const relPath = dottedKey.slice(org_profile_cjs_1.ORG_PROFILE_SEED_SECTION.length + 1);
324
+ if (!hasNestedOwn(seedSectionRaw, relPath))
325
+ continue;
326
+ const rawValue = getNested(seedSectionRaw, relPath);
327
+ const disposition = dispositionSeedValue(dottedKey, rawValue);
328
+ const shortName = SEED_KEY_SHORT_NAMES[dottedKey];
329
+ if (disposition.ok) {
330
+ values[shortName] = disposition.value;
331
+ }
332
+ else {
333
+ warnings.push(bound(`org profile seed key "${dottedKey}" rejected — ${disposition.reason}.`));
334
+ }
335
+ }
336
+ return { values, profile, warnings };
337
+ }
338
+ // ─── resolveLatestPlatformTag (D-04) ─────────────────────────────────────────
339
+ /**
340
+ * Resolve the latest STABLE tag for `repo` over the resolved host, never
341
+ * shell-eval'd (T-34-25): `git ls-remote` is spawned with an argv array via
342
+ * `childProcess.execFileSync`, bounded by `timeout`/`maxBuffer`/`windowsHide`
343
+ * (T-34-26). The host is `resolvePlatformHost(cwd).host` when it resolves,
344
+ * else `defaultHostForProvider(provider)` — the host is authoritative and
345
+ * never derived from the repo string (Phase 31 D-09).
346
+ *
347
+ * Every candidate ref is filtered with `isStableTripletSemver` and ordered
348
+ * with `compareSemverCore` — the single blessed comparison source, never
349
+ * reimplemented. An empty result degrades to `{ tag: null, warnings: [] }`
350
+ * (tagless is valid, D-01/D-02); any thrown fault (offline, unreachable,
351
+ * DNS failure, non-zero git exit) degrades to `{ tag: null, warnings: [one
352
+ * bounded warning] }` — offline, unreachable and tagless all resolve to the
353
+ * platform being unpinned, never a compose failure.
354
+ */
355
+ function resolveLatestPlatformTag(cwd, repo, provider, opts) {
356
+ try {
357
+ const hostRes = (0, vault_coordinates_cjs_1.resolvePlatformHost)(cwd);
358
+ const host = hostRes.host ?? (0, vault_coordinates_cjs_1.defaultHostForProvider)(provider);
359
+ const url = `https://${host}/${repo}`;
360
+ const lsOutput = typeof opts._lsRemote === 'function'
361
+ ? 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 });
363
+ const tags = lsOutput
364
+ .split('\n')
365
+ .map((line) => {
366
+ const m = line.match(/\brefs\/tags\/([^\s^]+)$/);
367
+ return m ? m[1].trim() : '';
368
+ })
369
+ .filter((t) => t && (0, semver_compare_cjs_1.isStableTripletSemver)(t));
370
+ if (tags.length === 0) {
371
+ return { tag: null, warnings: [] };
372
+ }
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;
377
+ }
378
+ return { tag: latest, warnings: [] };
379
+ }
380
+ catch {
381
+ return {
382
+ tag: null,
383
+ warnings: [
384
+ bound('platform.tag could not be resolved — the tag lookup failed or was unreachable; the platform is unpinned.'),
385
+ ],
386
+ };
387
+ }
388
+ }
389
+ /**
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.
393
+ */
394
+ function resolveCreatedWith() {
395
+ const candidates = [node_path_1.default.join(__dirname, '..', 'VERSION'), node_path_1.default.join(__dirname, '..', '..', '..', 'VERSION')];
396
+ for (const candidate of candidates) {
397
+ try {
398
+ const raw = node_fs_1.default.readFileSync(candidate, 'utf-8').trim();
399
+ if (/^\d+\.\d+\.\d+/.test(raw)) {
400
+ return `${package_identity_cjs_1.binName}@${raw}`;
401
+ }
402
+ }
403
+ catch {
404
+ // try the next candidate
405
+ }
406
+ }
407
+ return package_identity_cjs_1.binName;
408
+ }
409
+ /**
410
+ * Build the composed candidate object from the seed, the project answers
411
+ * `/pp-init` already captured, the resolved platform host, the D-04 tag pin,
412
+ * the package identity and the contract version. Omits any field whose
413
+ * source value is absent, blank or non-string — never writes an empty
414
+ * string. Every emitted field is one the schema's `additionalProperties:
415
+ * false` property sets already allow.
416
+ */
417
+ function composeCandidate(cwd, seed, provider, opts) {
418
+ const candidate = {};
419
+ const provenance = {};
420
+ const warnings = [];
421
+ const setField = (field, value, source) => {
422
+ if (!isUsableString(value))
423
+ return;
424
+ setNested(candidate, field, value);
425
+ provenance[field] = source;
426
+ };
427
+ // schemaVersion (contract) — always present, never a second '1.1.0' literal.
428
+ candidate.schemaVersion = platform_bindings_cjs_1.CONTRACT_SCHEMA_VERSION;
429
+ provenance.schemaVersion = 'contract';
430
+ // platform.repo (profile-seed)
431
+ setField('platform.repo', seed.platformRepo, 'profile-seed');
432
+ // platform.host (resolved-host) — one of the five runtime-allowlisted keys;
433
+ // sourced from the runtime resolver rather than duplicated into the seed
434
+ // section.
435
+ const hostRes = (0, vault_coordinates_cjs_1.resolvePlatformHost)(cwd);
436
+ pushDeduped(warnings, hostRes.warnings);
437
+ setField('platform.host', hostRes.host ?? undefined, 'resolved-host');
438
+ // platform.tag (resolved-tag, D-04) — only attempted when platform.repo
439
+ // composed and the caller did not opt out via offline.
440
+ const platformObj = candidate.platform;
441
+ const platformRepoValue = isObject(platformObj) ? platformObj.repo : undefined;
442
+ if (isUsableString(platformRepoValue) && opts.offline !== true) {
443
+ const tagResult = resolveLatestPlatformTag(cwd, platformRepoValue, provider, opts);
444
+ pushDeduped(warnings, tagResult.warnings);
445
+ if (tagResult.tag) {
446
+ setField('platform.tag', tagResult.tag, 'resolved-tag');
447
+ }
448
+ }
449
+ // client.name / client.slug (project-answer)
450
+ setField('client.name', cfgGet(cwd, 'project.client_name', undefined), 'project-answer');
451
+ setField('client.slug', cfgGet(cwd, 'project.client_slug', undefined), 'project-answer');
452
+ // client.vaultRepo / vaultRef / vaultHost (profile-seed)
453
+ setField('client.vaultRepo', seed.clientVaultRepo, 'profile-seed');
454
+ setField('client.vaultRef', seed.clientVaultRef, 'profile-seed');
455
+ setField('client.vaultHost', seed.clientVaultHost, 'profile-seed');
456
+ // project.name / project.slug (project-answer)
457
+ setField('project.name', cfgGet(cwd, 'project.name', undefined), 'project-answer');
458
+ setField('project.slug', cfgGet(cwd, 'project.slug', undefined), 'project-answer');
459
+ // project.createdWith (package-identity)
460
+ setField('project.createdWith', resolveCreatedWith(), 'package-identity');
461
+ return { candidate, provenance, warnings };
462
+ }
463
+ // ─── validateAndDispose (D-03) ────────────────────────────────────────────────
464
+ function leadingToken(errorMsg) {
465
+ const idx = errorMsg.indexOf(':');
466
+ return idx === -1 ? errorMsg : errorMsg.slice(0, idx);
467
+ }
468
+ /**
469
+ * Implement D-03 exactly. Every shape rule comes from `validateBindings`;
470
+ * this function contains no second regex for any binding field.
471
+ *
472
+ * - Valid as-is: composable, unchanged.
473
+ * - Any REQUIRED field failing (`root`, `schemaVersion`, `platform`,
474
+ * `platform.repo`): compose nothing. This asymmetry is load-bearing — the
475
+ * presence of `.platform-bindings.json` is itself the gate that flips
476
+ * `/pp-init` and `execute-phase` into Pareto mode, so writing a file with
477
+ * an unusable `platform.repo` would flip that gate into a state nothing
478
+ * downstream can satisfy.
479
+ * - Otherwise (only OPTIONAL fields failing): a field whose provenance is
480
+ * `existing-file` is NEVER deleted (T-30-08) — when only such fields fail,
481
+ * report `composable: false` naming them. Otherwise delete each failing
482
+ * optional field (and its parent when it becomes empty), warn once per
483
+ * field, and re-validate ONCE. If still invalid, `composable: false`.
484
+ */
485
+ function validateAndDispose(candidate, provenanceByField) {
486
+ const first = (0, platform_bindings_cjs_1.validateBindings)(candidate);
487
+ if (first.valid) {
488
+ return { composable: true, composed: candidate, warnings: [] };
489
+ }
490
+ const failingFields = first.errors.map(leadingToken);
491
+ const requiredFailures = failingFields.filter((f) => REQUIRED_ERROR_PREFIXES.includes(f));
492
+ if (requiredFailures.length > 0) {
493
+ const requiredErrorText = first.errors
494
+ .filter((e) => REQUIRED_ERROR_PREFIXES.includes(leadingToken(e)))
495
+ .join('; ');
496
+ return {
497
+ composable: false,
498
+ composed: null,
499
+ warnings: [bound(`binding could not be composed — required field(s) invalid: ${requiredErrorText}`)],
500
+ };
501
+ }
502
+ const uneditableFields = failingFields.filter((f) => provenanceByField[f] === 'existing-file');
503
+ if (uneditableFields.length > 0) {
504
+ return {
505
+ composable: false,
506
+ composed: null,
507
+ warnings: [
508
+ bound(`binding could not be composed — the existing .platform-bindings.json has invalid value(s) for ${sanitizeForWarning(uneditableFields.join(', '))}; fix the committed file directly.`),
509
+ ],
510
+ };
511
+ }
512
+ const mutated = deepCloneJsonSafe(candidate);
513
+ const warnings = [];
514
+ for (let i = 0; i < first.errors.length; i++) {
515
+ const field = failingFields[i];
516
+ warnings.push(bound(`${field} dropped from composed binding — ${first.errors[i]}`));
517
+ deleteDottedField(mutated, field);
518
+ }
519
+ const second = (0, platform_bindings_cjs_1.validateBindings)(mutated);
520
+ if (!second.valid) {
521
+ return {
522
+ composable: false,
523
+ composed: null,
524
+ warnings: [...warnings, bound('binding still invalid after dropping optional fields — composing nothing.')],
525
+ };
526
+ }
527
+ return { composable: true, composed: mutated, warnings };
528
+ }
529
+ /**
530
+ * Read `<cwd>/.platform-bindings.json`. Absent → `present: false`. Present
531
+ * and a parseable JSON object → `present: true, malformed: false`. Present
532
+ * but malformed JSON or not a JSON object → `present: true, malformed: true`
533
+ * with one bounded warning; this deliberately does NOT adopt
534
+ * `cmdPlatformBindings`' hard-error-on-malformed-JSON behaviour — this arm
535
+ * reports a verdict as data on exit 0, and an unreadable file is a reason to
536
+ * do nothing rather than a reason to crash a `/pp-init` run.
537
+ */
538
+ function readExistingBindings(cwd) {
539
+ const bindingsPath = node_path_1.default.join(cwd, '.platform-bindings.json');
540
+ if (!node_fs_1.default.existsSync(bindingsPath)) {
541
+ return { present: false, malformed: false, data: {}, warnings: [] };
542
+ }
543
+ try {
544
+ const raw = node_fs_1.default.readFileSync(bindingsPath, 'utf-8');
545
+ const parsed = JSON.parse(raw);
546
+ if (!isObject(parsed)) {
547
+ return {
548
+ present: true,
549
+ malformed: true,
550
+ data: {},
551
+ warnings: [bound('existing .platform-bindings.json does not contain a JSON object — left untouched.')],
552
+ };
553
+ }
554
+ return { present: true, malformed: false, data: parsed, warnings: [] };
555
+ }
556
+ catch {
557
+ return {
558
+ present: true,
559
+ malformed: true,
560
+ data: {},
561
+ warnings: [bound('existing .platform-bindings.json could not be parsed — left untouched.')],
562
+ };
563
+ }
564
+ }
565
+ /**
566
+ * D-08's absent-only merge. `merged` starts as a deep clone of `existing` —
567
+ * every field the existing file already carries (including the provisional
568
+ * `modules`/`ci` namespaces the composer never produces) is preserved
569
+ * untouched. For each field the composer actually composed (the keys of
570
+ * `candidateProvenance`): if `existing` lacks that key, fill it in from the
571
+ * candidate; if `existing` already has it, the merged value stays the
572
+ * existing one (never overwritten) and — when the candidate's value differs
573
+ * — one drift entry is recorded. A present `(repo, host)` pair can therefore
574
+ * never be redirected by a package; Phase 33 D-05's threat stays closed by
575
+ * construction, not by policy.
576
+ */
577
+ function mergeAbsentOnly(candidate, candidateProvenance, existing) {
578
+ const merged = deepCloneJsonSafe(existing);
579
+ const provenance = [];
580
+ const provenanceByField = {};
581
+ const drift = [];
582
+ const managedFields = Object.keys(candidateProvenance);
583
+ for (const field of managedFields) {
584
+ const candidateValue = getNested(candidate, field);
585
+ if (hasNestedOwn(existing, field)) {
586
+ const existingValue = getNested(existing, field);
587
+ provenance.push({ field, value: existingValue, source: 'existing-file' });
588
+ provenanceByField[field] = 'existing-file';
589
+ if (!valuesEqual(candidateValue, existingValue)) {
590
+ drift.push({ key: field, profileValue: candidateValue, effectiveValue: existingValue });
591
+ }
592
+ }
593
+ else {
594
+ setNested(merged, field, candidateValue);
595
+ const source = candidateProvenance[field];
596
+ provenance.push({ field, value: candidateValue, source });
597
+ provenanceByField[field] = source;
598
+ }
599
+ }
600
+ // Provenance-tag (never overwrite) every leaf the existing file carries
601
+ // that the composer does not manage at all.
602
+ const managedSet = new Set(managedFields);
603
+ const existingLeaves = [];
604
+ walkLeaves(existing, '', 6, existingLeaves);
605
+ for (const leaf of existingLeaves) {
606
+ if (managedSet.has(leaf.path))
607
+ continue;
608
+ provenance.push({ field: leaf.path, value: leaf.value, source: 'existing-file' });
609
+ provenanceByField[leaf.path] = 'existing-file';
610
+ }
611
+ return { merged, provenance, provenanceByField, drift };
612
+ }
613
+ // ─── composeBindings ──────────────────────────────────────────────────────────
614
+ const ABSENT_PROFILE_META = Object.freeze({
615
+ resolved: false,
616
+ packageName: null,
617
+ version: null,
618
+ source: 'none',
619
+ dataPath: null,
620
+ });
621
+ /**
622
+ * Compose a `.platform-bindings.json` candidate for the project at `cwd`,
623
+ * validate it, merge it against any existing file, and write only when
624
+ * `opts.write === true` and the result is composable. Never throws and
625
+ * never exits non-zero — every failure mode produces a bounded warning and
626
+ * continues (D-09).
627
+ */
628
+ function composeBindings(cwd, opts = {}) {
629
+ const warnings = [];
630
+ try {
631
+ const { provider, warnings: providerWarnings } = (0, git_provider_cjs_1.resolveProvider)(cwd);
632
+ pushDeduped(warnings, providerWarnings);
633
+ const seedResult = readSeed(cwd, { _globalRoot: opts._globalRoot });
634
+ pushDeduped(warnings, seedResult.warnings);
635
+ const candidateResult = composeCandidate(cwd, seedResult.values, provider, opts);
636
+ pushDeduped(warnings, candidateResult.warnings);
637
+ const existing = readExistingBindings(cwd);
638
+ pushDeduped(warnings, existing.warnings);
639
+ if (existing.malformed) {
640
+ return {
641
+ profile: seedResult.profile,
642
+ provider,
643
+ seed: seedResult.values,
644
+ present: existing.present,
645
+ composable: false,
646
+ composed: null,
647
+ provenance: [],
648
+ drift: [],
649
+ action: 'none',
650
+ wrote: false,
651
+ warnings,
652
+ };
653
+ }
654
+ const mergeResult = mergeAbsentOnly(candidateResult.candidate, candidateResult.provenance, existing.data);
655
+ const disposeResult = validateAndDispose(mergeResult.merged, mergeResult.provenanceByField);
656
+ pushDeduped(warnings, disposeResult.warnings);
657
+ if (!disposeResult.composable || disposeResult.composed === null) {
658
+ return {
659
+ profile: seedResult.profile,
660
+ provider,
661
+ seed: seedResult.values,
662
+ present: existing.present,
663
+ composable: false,
664
+ composed: null,
665
+ provenance: mergeResult.provenance,
666
+ drift: mergeResult.drift,
667
+ action: 'none',
668
+ wrote: false,
669
+ warnings,
670
+ };
671
+ }
672
+ const composed = disposeResult.composed;
673
+ let action;
674
+ if (!existing.present) {
675
+ action = 'create';
676
+ }
677
+ else if (hasAnyNewKey(composed, existing.data)) {
678
+ action = 'update';
679
+ }
680
+ else {
681
+ action = 'unchanged';
682
+ }
683
+ let wrote = false;
684
+ if (opts.write === true && action !== 'unchanged') {
685
+ const bindingsPath = node_path_1.default.join(cwd, '.platform-bindings.json');
686
+ (0, shell_command_projection_cjs_1.platformWriteSync)(bindingsPath, JSON.stringify(composed, null, 2) + '\n');
687
+ wrote = true;
688
+ }
689
+ return {
690
+ profile: seedResult.profile,
691
+ provider,
692
+ seed: seedResult.values,
693
+ present: existing.present,
694
+ composable: true,
695
+ composed,
696
+ provenance: mergeResult.provenance,
697
+ drift: mergeResult.drift,
698
+ action,
699
+ wrote,
700
+ warnings,
701
+ };
702
+ }
703
+ catch {
704
+ return {
705
+ profile: ABSENT_PROFILE_META,
706
+ provider: git_provider_cjs_1.DEFAULT_PROVIDER,
707
+ seed: {},
708
+ present: false,
709
+ composable: false,
710
+ composed: null,
711
+ provenance: [],
712
+ drift: [],
713
+ action: 'none',
714
+ wrote: false,
715
+ warnings: [...warnings, 'binding composition failed unexpectedly.'],
716
+ };
717
+ }
718
+ }
719
+ // ─── cmdComposeBindings ───────────────────────────────────────────────────────
720
+ /**
721
+ * CLI adapter for `pp-tools query compose-bindings`. Resolves the project
722
+ * root via `findProjectRoot(cwd) || cwd`, exactly as `cmdPlatformBindings`
723
+ * does, then calls `composeBindings` and emits the result as data — a
724
+ * verdict is never a process error.
725
+ */
726
+ function cmdComposeBindings(cwd, options, raw) {
727
+ let root;
728
+ try {
729
+ root = (0, project_root_cjs_1.findProjectRoot)(cwd) || cwd;
730
+ }
731
+ catch {
732
+ root = cwd;
733
+ }
734
+ const result = composeBindings(root, { write: options.write === true, offline: options.offline === true });
735
+ output(result, raw);
736
+ }