@pareto4/pp-core 1.5.4 → 1.6.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.
@@ -0,0 +1,336 @@
1
+ "use strict";
2
+ /**
3
+ * Platform Bindings — reader + hand-rolled validator for .platform-bindings.json
4
+ *
5
+ * ADR-457 build-at-publish: this TypeScript file is the SOURCE OF TRUTH. It
6
+ * compiles to the gitignored `pp-core/bin/lib/platform-bindings.cjs`; do NOT
7
+ * hand-edit the compiled output. Behaviour is LOCKED by
8
+ * `tests/platform-bindings.test.cjs` (the reader behaviour matrix + the
9
+ * schema↔validator coupling assertion).
10
+ *
11
+ * BIND-02 (D-08 single-file schema check, D-03 lenient validation):
12
+ * - `cmdPlatformBindings` reads `.platform-bindings.json` at the repo root
13
+ * (NOT under .planning/). An absent file is INERT — it emits
14
+ * {present:false} on exit 0 and NEVER calls error(). This makes the
15
+ * binding contract opt-in: every downstream Pareto behaviour can read it
16
+ * unconditionally and branch on `present`.
17
+ * - Malformed JSON is the ONLY hard-error path: it calls error() with
18
+ * ERROR_REASON.PLATFORM_BINDINGS_PARSE_FAILED (exit 1).
19
+ * - `--validate` returns the validation RESULT as data ({valid, errors}) on
20
+ * exit 0 — a validation verdict is never a process error.
21
+ * - `validateBindings` is hand-rolled (no ajv, zero new deps). It hard-checks
22
+ * ONLY the load-bearing core (schemaVersion, platform.repo, platform.tag)
23
+ * per D-03; provisional namespaces are validated leniently — an unknown or
24
+ * missing namespace never makes the object invalid.
25
+ *
26
+ * The core regexes below MUST mirror the `pattern` strings in
27
+ * `schemas/platform-bindings.schema.json`; the schema↔validator coupling test
28
+ * machine-enforces this so neither side can drift silently.
29
+ */
30
+ var __importDefault = (this && this.__importDefault) || function (mod) {
31
+ return (mod && mod.__esModule) ? mod : { "default": mod };
32
+ };
33
+ Object.defineProperty(exports, "__esModule", { value: true });
34
+ exports.validateBindings = validateBindings;
35
+ exports.cmdPlatformBindings = cmdPlatformBindings;
36
+ exports.cmdValidateBindings = cmdValidateBindings;
37
+ const node_fs_1 = __importDefault(require("node:fs"));
38
+ const node_path_1 = __importDefault(require("node:path"));
39
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
40
+ const io = require("./io.cjs");
41
+ const { output, error, ERROR_REASON } = io;
42
+ const project_root_cjs_1 = require("./project-root.cjs");
43
+ const semver_compare_cjs_1 = require("./semver-compare.cjs");
44
+ // Core load-bearing patterns (D-01). These MUST mirror the `pattern` fields in
45
+ // schemas/platform-bindings.schema.json — the coupling test fails if they drift.
46
+ const SCHEMA_VERSION_RE = /^\d+\.\d+\.\d+$/;
47
+ const PLATFORM_REPO_RE = /^[\w.-]+\/[\w.-]+$/;
48
+ const PLATFORM_TAG_RE = /^v\d+\.\d+\.\d+/;
49
+ // Provisional namespace pattern (lenient): only enforced if client.slug is present.
50
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
51
+ function isObject(v) {
52
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
53
+ }
54
+ /**
55
+ * Hand-rolled validator (no ajv). Hard-checks ONLY the frozen core per D-03;
56
+ * provisional namespaces are validated leniently. Returns the verdict as data.
57
+ *
58
+ * @param o - The parsed binding object (untrusted shape).
59
+ * @returns `{ valid, errors }` — `errors` names each failing core field.
60
+ */
61
+ function validateBindings(o) {
62
+ const errors = [];
63
+ if (!isObject(o)) {
64
+ return { valid: false, errors: ['root: expected a JSON object'] };
65
+ }
66
+ // schemaVersion — required, semver triplet.
67
+ if (typeof o.schemaVersion !== 'string' || !SCHEMA_VERSION_RE.test(o.schemaVersion)) {
68
+ errors.push('schemaVersion: missing or not a semver triplet (e.g. "1.0.0")');
69
+ }
70
+ // platform — required object with repo + tag.
71
+ if (!isObject(o.platform)) {
72
+ errors.push('platform: missing or not an object');
73
+ }
74
+ else {
75
+ const platform = o.platform;
76
+ if (typeof platform.repo !== 'string' || !PLATFORM_REPO_RE.test(platform.repo)) {
77
+ errors.push('platform.repo: missing or not a "group/project" path');
78
+ }
79
+ if (typeof platform.tag !== 'string' || !PLATFORM_TAG_RE.test(platform.tag)) {
80
+ errors.push('platform.tag: missing or not a "v<semver>" tag');
81
+ }
82
+ }
83
+ // Provisional namespaces — lenient. A missing/unknown namespace is fine; only
84
+ // a PRESENT slug field that is malformed is flagged (it never gates on absence).
85
+ if (isObject(o.client) && o.client.slug !== undefined) {
86
+ if (typeof o.client.slug !== 'string' || !SLUG_RE.test(o.client.slug)) {
87
+ errors.push('client.slug: present but not a url-safe lowercase slug');
88
+ }
89
+ }
90
+ // IN-03: project.slug — symmetric lenient check matching client.slug behaviour.
91
+ // The schema defines the same ^[a-z0-9][a-z0-9-]*$ pattern for both fields.
92
+ if (isObject(o.project) && o.project.slug !== undefined) {
93
+ if (typeof o.project.slug !== 'string' || !SLUG_RE.test(o.project.slug)) {
94
+ errors.push('project.slug: present but not a url-safe lowercase slug');
95
+ }
96
+ }
97
+ return { valid: errors.length === 0, errors };
98
+ }
99
+ /**
100
+ * Reader handler for `pp-tools query platform-bindings`.
101
+ *
102
+ * Behaviour matrix (locked by tests/platform-bindings.test.cjs):
103
+ * absent file → output({present:false}) exit 0 (inert)
104
+ * present + valid JSON → output({present:true, ...bindings}) exit 0
105
+ * present + --validate → output(validateBindings(obj)) exit 0
106
+ * malformed JSON → error(..., PARSE_FAILED) exit 1
107
+ *
108
+ * @param cwd - Working directory; resolved to repo root via findProjectRoot.
109
+ * @param args - Handler-local args (already sliced past the subcommand name).
110
+ * @param raw - Raw output mode (passed through to output()).
111
+ */
112
+ function cmdPlatformBindings(cwd, args, raw) {
113
+ let root;
114
+ try {
115
+ root = (0, project_root_cjs_1.findProjectRoot)(cwd) || cwd;
116
+ }
117
+ catch {
118
+ root = cwd;
119
+ }
120
+ const bindingsPath = node_path_1.default.join(root, '.platform-bindings.json');
121
+ // Absent → inert. NEVER error() here; this is the opt-in default path.
122
+ if (!node_fs_1.default.existsSync(bindingsPath)) {
123
+ output({ present: false }, raw);
124
+ return;
125
+ }
126
+ let parsed;
127
+ try {
128
+ parsed = JSON.parse(node_fs_1.default.readFileSync(bindingsPath, 'utf-8'));
129
+ }
130
+ catch (err) {
131
+ // IN-02: TOCTOU guard — if the file was deleted between existsSync and readFileSync,
132
+ // route to the inert absent path rather than emitting a misleading parse-failure error.
133
+ if (err.code === 'ENOENT') {
134
+ output({ present: false }, raw);
135
+ return;
136
+ }
137
+ error('Failed to parse .platform-bindings.json: ' + err.message, ERROR_REASON.PLATFORM_BINDINGS_PARSE_FAILED);
138
+ return; // unreachable — error() exits; kept for type-narrowing clarity.
139
+ }
140
+ if (args.includes('--validate')) {
141
+ output(validateBindings(parsed), raw);
142
+ return;
143
+ }
144
+ // present + valid → echo the bindings under a present flag.
145
+ output(Object.assign({ present: true }, parsed), raw);
146
+ }
147
+ // Anchored regex for `?ref=` values in module source lines (T-07-04: untrusted text,
148
+ // no YAML dependency, no shell-eval).
149
+ const GITLAB_CI_MODULE_REF_RE = /\?ref=([^\s'"&)\]]+)/g;
150
+ /**
151
+ * Extracts `ref:` values that belong to the top-level `include:` block in a
152
+ * .gitlab-ci.yml file, using a stateful line-scan.
153
+ *
154
+ * APPROXIMATION: Parses YAML structure via indentation tracking — handles the
155
+ * common block-sequence form (`include: [- project: ... ref: ...]`). It does NOT
156
+ * capture `ref:` values from `trigger:`, `needs:`, or other non-include stanzas,
157
+ * because those appear under job-level keys (indent = 0) which close the include
158
+ * context. May mis-classify exotic YAML (flow mappings, anchors, multi-document
159
+ * files). Uses no YAML parser to avoid runtime dependencies (T-07-04).
160
+ *
161
+ * @param content - Raw string content of the .gitlab-ci.yml file.
162
+ * @returns Array of `ref:` values found inside the `include:` block.
163
+ */
164
+ function extractGitlabCiIncludeRefs(content) {
165
+ const refs = [];
166
+ const lines = content.split('\n');
167
+ let inIncludeBlock = false;
168
+ const REF_LINE_RE = /^ref:\s+['"]?([^\s'"#,\]]+)['"]?/;
169
+ for (const line of lines) {
170
+ const trimmed = line.trimStart();
171
+ // Skip blank lines and comment lines — they do not change YAML context.
172
+ if (trimmed.length === 0 || trimmed.startsWith('#'))
173
+ continue;
174
+ const indent = line.length - trimmed.length;
175
+ // A new top-level key (indent = 0) either enters or exits the include block.
176
+ if (indent === 0) {
177
+ inIncludeBlock = trimmed === 'include:' || trimmed.startsWith('include:');
178
+ continue;
179
+ }
180
+ // Within the include block, capture any `ref: <value>` line.
181
+ if (inIncludeBlock) {
182
+ const m = trimmed.match(REF_LINE_RE);
183
+ if (m) {
184
+ refs.push(m[1].trim());
185
+ }
186
+ }
187
+ }
188
+ return refs;
189
+ }
190
+ /**
191
+ * Pin-site consistency check for `pp-tools query validate-bindings` (BIND-03).
192
+ *
193
+ * Reads the REAL on-disk `.gitlab-ci.yml` include `ref:` and module `?ref=` values (D-06),
194
+ * cross-checks them against `platform.tag` in `.platform-bindings.json`.
195
+ *
196
+ * Behaviour matrix (locked by tests/platform-bindings.test.cjs):
197
+ * absent .platform-bindings.json → output({present:false}) exit 0 (inert, D-07)
198
+ * internal mismatch (>1 distinct) → output({..., status:'block'}) exit 0 (D-04)
199
+ * all-agree, behind --latest-tag → output({..., status:'warn'}) exit 0 (D-05)
200
+ * all-agree, at/ahead of latest → output({..., status:'pass'}) exit 0
201
+ *
202
+ * Threat mitigations:
203
+ * T-07-04 Anchored regex on `ref:`/`?ref=`; file content is untrusted text — never shell-eval.
204
+ * T-07-05 Unresolved/offline remote is a soft note, never a hard block; caller passes
205
+ * `--latest-tag <tag>` when a network lookup succeeded; absent = skip drift check.
206
+ * T-07-06 `compareSemverCore === 0` normalises v-prefix differences; literal diff surfaced in `sites`.
207
+ *
208
+ * @param cwd - Working directory; resolved to repo root via findProjectRoot.
209
+ * @param args - Handler-local args (already sliced past the subcommand name).
210
+ * Accepts optional `--latest-tag <tag>` for D-05 drift classification.
211
+ * @param raw - Raw output mode (passed through to output()).
212
+ */
213
+ function cmdValidateBindings(cwd, args, raw) {
214
+ let root;
215
+ try {
216
+ root = (0, project_root_cjs_1.findProjectRoot)(cwd) || cwd;
217
+ }
218
+ catch {
219
+ root = cwd;
220
+ }
221
+ const bindingsPath = node_path_1.default.join(root, '.platform-bindings.json');
222
+ // D-07: absent → inert. NEVER error() here; this is the opt-in default path.
223
+ if (!node_fs_1.default.existsSync(bindingsPath)) {
224
+ output({ present: false }, raw);
225
+ return;
226
+ }
227
+ // Parse the bindings file (malformed → hard error, same rule as the reader).
228
+ let parsed;
229
+ try {
230
+ parsed = JSON.parse(node_fs_1.default.readFileSync(bindingsPath, 'utf-8'));
231
+ }
232
+ catch (err) {
233
+ // IN-02: TOCTOU guard — if the file was deleted between existsSync and readFileSync,
234
+ // route to the inert absent path rather than emitting a misleading parse-failure error.
235
+ if (err.code === 'ENOENT') {
236
+ output({ present: false }, raw);
237
+ return;
238
+ }
239
+ error('Failed to parse .platform-bindings.json: ' + err.message, ERROR_REASON.PLATFORM_BINDINGS_PARSE_FAILED);
240
+ return; // unreachable — error() exits; kept for type-narrowing clarity.
241
+ }
242
+ // Extract canonical platform.tag (T) as the first pin-site value.
243
+ const bindingsObj = parsed;
244
+ const platform = isObject(bindingsObj.platform) ? bindingsObj.platform : null;
245
+ const bindingsTag = typeof platform?.tag === 'string' ? platform.tag.trim() : '';
246
+ // WR-01: guard on a missing/empty canonical tag — without it, the function
247
+ // would fall through to emit status:'pass' with zero pin-sites, misleadingly
248
+ // signalling a correct pin on a binding that validateBindings would reject.
249
+ const sites = {};
250
+ if (!bindingsTag) {
251
+ output({
252
+ present: true,
253
+ consistent: false,
254
+ status: 'block',
255
+ message: 'platform.tag is missing or empty in .platform-bindings.json — run --validate to diagnose',
256
+ sites,
257
+ }, raw);
258
+ return;
259
+ }
260
+ sites['platform.tag'] = bindingsTag;
261
+ // D-06: read the REAL on-disk .gitlab-ci.yml and extract ref values.
262
+ const gitlabCiPath = node_path_1.default.join(root, '.gitlab-ci.yml');
263
+ if (node_fs_1.default.existsSync(gitlabCiPath)) {
264
+ const ciContent = node_fs_1.default.readFileSync(gitlabCiPath, 'utf-8');
265
+ // Extract `ref:` values from include blocks using a stateful line-scan (WR-02).
266
+ // Avoids false positives from `trigger:`, `needs:`, and other non-include stanzas.
267
+ const includeRefs = extractGitlabCiIncludeRefs(ciContent);
268
+ for (let refIdx = 0; refIdx < includeRefs.length; refIdx++) {
269
+ const key = refIdx === 0
270
+ ? '.gitlab-ci.yml include ref'
271
+ : `.gitlab-ci.yml include ref[${refIdx + 1}]`;
272
+ sites[key] = includeRefs[refIdx];
273
+ }
274
+ // Extract `?ref=` values from module source lines.
275
+ const moduleRefRe = new RegExp(GITLAB_CI_MODULE_REF_RE.source, GITLAB_CI_MODULE_REF_RE.flags);
276
+ let m;
277
+ let modIdx = 0;
278
+ while ((m = moduleRefRe.exec(ciContent)) !== null) {
279
+ modIdx++;
280
+ const key = modIdx === 1
281
+ ? '.gitlab-ci.yml module ?ref'
282
+ : `.gitlab-ci.yml module ?ref[${modIdx}]`;
283
+ sites[key] = m[1].trim();
284
+ }
285
+ }
286
+ // Build distinct semver set across all pin-sites.
287
+ // `compareSemverCore === 0` normalises v-prefix differences (T-07-06).
288
+ const tagValues = Object.values(sites).filter(Boolean);
289
+ const distinctTags = [];
290
+ for (const tag of tagValues) {
291
+ const isDuplicate = distinctTags.some((existing) => (0, semver_compare_cjs_1.compareSemverCore)(tag, existing) === 0);
292
+ if (!isDuplicate) {
293
+ distinctTags.push(tag);
294
+ }
295
+ }
296
+ // D-04: hard BLOCK when >1 semver-distinct element across the pin-sites.
297
+ if (distinctTags.length > 1) {
298
+ const siteDiff = Object.entries(sites)
299
+ .map(([k, v]) => `${k}=${v}`)
300
+ .join(', ');
301
+ output({
302
+ present: true,
303
+ consistent: false,
304
+ status: 'block',
305
+ message: `Pin-site tags disagree: ${siteDiff}`,
306
+ sites,
307
+ }, raw);
308
+ return;
309
+ }
310
+ // D-05: soft WARN when all sites agree but are behind the latest stable tag.
311
+ // The caller passes `--latest-tag <tag>` when a network ls-remote succeeded.
312
+ // Absent or unresolved remote → skip drift check (T-07-05: never hard-block).
313
+ const latestTagArgIdx = args.indexOf('--latest-tag');
314
+ const latestTag = latestTagArgIdx >= 0 ? args[latestTagArgIdx + 1] : undefined;
315
+ if (latestTag && (0, semver_compare_cjs_1.isStableTripletSemver)(latestTag) && bindingsTag) {
316
+ const drift = (0, semver_compare_cjs_1.compareSemverCore)(bindingsTag, latestTag);
317
+ if (drift < 0) {
318
+ // Strictly behind latest stable.
319
+ output({
320
+ present: true,
321
+ consistent: true,
322
+ status: 'warn',
323
+ message: `All pin-sites agree on ${bindingsTag}, but latest stable is ${latestTag}`,
324
+ sites,
325
+ }, raw);
326
+ return;
327
+ }
328
+ }
329
+ // All sites agree, not strictly behind latest → pass.
330
+ output({
331
+ present: true,
332
+ consistent: true,
333
+ status: 'pass',
334
+ sites,
335
+ }, raw);
336
+ }
@@ -1061,13 +1061,14 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
1061
1061
  (0, shell_command_projection_cjs_1.platformEnsureDir)(node_path_1.default.dirname(outputPath));
1062
1062
  (0, shell_command_projection_cjs_1.platformWriteSync)(outputPath, existingContent);
1063
1063
  }
1064
- else if (!/<!-- PP:[a-z]+-start/.test(existingContent) && !options.force) {
1064
+ else if (!/<!-- PP:[a-z][a-z-]*-start/.test(existingContent) && !options.force) {
1065
1065
  // #1098: the target instruction file already exists and contains NO GSD
1066
1066
  // section markers — it is a hand-crafted CLAUDE.md/AGENTS.md, not a
1067
1067
  // GSD-managed one. Do NOT clobber it with generated project documentation
1068
1068
  // (broad project detail belongs in PROJECT.md / REQUIREMENTS.md, which GSD
1069
1069
  // already owns). Leave the file untouched and report a skip; `--force`
1070
1070
  // overwrites intentionally.
1071
+ // Note: [a-z][a-z-]* matches section names with hyphens (e.g. client-context)
1071
1072
  output({
1072
1073
  claude_md_path: outputPath,
1073
1074
  action: 'skipped',
@@ -1153,12 +1154,226 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
1153
1154
  };
1154
1155
  output(result, raw, undefined);
1155
1156
  }
1157
+ // ─── Client Context Block ─────────────────────────────────────────────────────
1158
+ /**
1159
+ * Sanitize a field value so pasted content cannot break the managed block.
1160
+ * T-09-01: Neutralizes:
1161
+ * - any `<!-- PP:` marker sequence (any section name — start or end fragment)
1162
+ * - any line starting with `### ` (would forge a field subheading)
1163
+ *
1164
+ * WR-01: Broadened from `PP:client-context` to `PP:[a-z][a-z-]*` so that a
1165
+ * pasted value containing e.g. `<!-- PP:project-end -->` cannot corrupt another
1166
+ * section's `updateSection` / `generate-claude-md` call later.
1167
+ *
1168
+ * Neutralization strategy for `<!-- PP:` sequences: a zero-width space (U+200B)
1169
+ * is inserted INSIDE the comment opener (`<` + ZWS + `!--`) rather than prepended
1170
+ * before it. This breaks the literal substring `<!--` so that `String.indexOf` and
1171
+ * `String.includes` can no longer find `<!-- PP:...` in the neutralized value —
1172
+ * which is required because `extractSectionContent` / `updateSection` use
1173
+ * `fileContent.indexOf(endMarker)` to locate section boundaries. A ZWS *prefix*
1174
+ * before `<!--` (approach used by earlier code) still left `<!-- PP:...` as a
1175
+ * findable substring, allowing the injection to truncate block reads.
1176
+ *
1177
+ * The inserted ZWS is invisible in rendered text and does not match the replacement
1178
+ * pattern on a subsequent sanitization pass, so round-trip re-writes are idempotent.
1179
+ *
1180
+ * Neutralization for `### ` uses a ZWS *prefix* (before `### `), which is the
1181
+ * correct approach because `parseClientContextFields` checks for lines that EQUAL
1182
+ * the heading string exactly — `​### Client` !== `### Client`.
1183
+ */
1184
+ function sanitizeClientContextField(value) {
1185
+ // Neutralize any PP managed-block marker: break `<!--` by inserting ZWS inside it
1186
+ // so the literal substring `<!-- PP:...` no longer appears in the neutralized value.
1187
+ let result = value.replace(/(<!--)(\s*PP:[a-z][a-z-]*)/gi, '<​!--$2');
1188
+ // Neutralize lines beginning with `### ` (subheading injection)
1189
+ result = result.replace(/^(### )/gm, '​$1');
1190
+ return result;
1191
+ }
1192
+ /**
1193
+ * Parse the five field values from an extracted client-context block body.
1194
+ * Uses `### ` anchor scan on the block content (T-09-03: never whole-file).
1195
+ * Returns an object with the five field keys, or null if the block is empty.
1196
+ *
1197
+ * CR-01: The `### Client` section is stored under `client_name_slug` for the
1198
+ * combined block, but `client_name` and `client_slug` are also extracted as
1199
+ * separate keys from the `**Name:**` / `**Slug:**` lines within that section
1200
+ * so that `init.md` edit-on-re-run can read them via `o.client_name` /
1201
+ * `o.client_slug` without breakage.
1202
+ */
1203
+ function parseClientContextFields(blockContent) {
1204
+ const SUBHEADINGS = [
1205
+ { key: 'client_name_slug', heading: '### Client' },
1206
+ { key: 'coding_standards', heading: '### Coding Standards' },
1207
+ { key: 'architecture_patterns', heading: '### Architecture Patterns' },
1208
+ { key: 'ui_brand_conventions', heading: '### UI / Brand Conventions' },
1209
+ { key: 'definition_of_done', heading: '### Definition of Done' },
1210
+ ];
1211
+ const result = {};
1212
+ // WR-03: strip trailing \r so CRLF files don't break exact-equality heading matches
1213
+ const lines = blockContent.split('\n').map(l => l.replace(/\r$/, ''));
1214
+ for (let i = 0; i < SUBHEADINGS.length; i++) {
1215
+ const { key, heading } = SUBHEADINGS[i];
1216
+ const nextHeading = i + 1 < SUBHEADINGS.length ? SUBHEADINGS[i + 1].heading : null;
1217
+ const startIdx = lines.findIndex(l => l === heading);
1218
+ if (startIdx === -1) {
1219
+ result[key] = '';
1220
+ continue;
1221
+ }
1222
+ const sectionLines = [];
1223
+ for (let j = startIdx + 1; j < lines.length; j++) {
1224
+ if (nextHeading && lines[j] === nextHeading)
1225
+ break;
1226
+ if (!nextHeading && /^<!-- PP:client-context-end/.test(lines[j]))
1227
+ break;
1228
+ sectionLines.push(lines[j]);
1229
+ }
1230
+ result[key] = sectionLines.join('\n').trim();
1231
+ // CR-01: for the ### Client section, also extract client_name and client_slug
1232
+ // as separate keys by parsing the **Name:** / **Slug:** lines within it.
1233
+ if (key === 'client_name_slug') {
1234
+ const nameLine = sectionLines.find(l => l.startsWith('**Name:**'));
1235
+ result['client_name'] = nameLine ? nameLine.replace(/^\*\*Name:\*\*\s*/, '').trim() : '';
1236
+ const slugLine = sectionLines.find(l => l.startsWith('**Slug:**'));
1237
+ result['client_slug'] = slugLine ? slugLine.replace(/^\*\*Slug:\*\*\s*/, '').trim() : '';
1238
+ }
1239
+ }
1240
+ // WR-02: return null when no recognized subheadings were found so the caller's
1241
+ // `if (!fields)` guard correctly emits { present: false } for empty/hand-edited blocks
1242
+ const hasAnyField = Object.values(result).some(v => v !== '');
1243
+ return hasAnyField ? result : null;
1244
+ }
1245
+ function cmdGenerateClientContext(cwd, options, raw) {
1246
+ // Resolve target path using same config-driven logic as cmdGenerateClaudeMd
1247
+ let configClaudeMdPath = './.claude/CLAUDE.md';
1248
+ let config = {};
1249
+ try {
1250
+ config = loadConfig(cwd);
1251
+ if (config['claude_md_path'])
1252
+ configClaudeMdPath = config['claude_md_path'];
1253
+ // Runtime-awareness: Codex projects write to AGENTS.md, not CLAUDE.md
1254
+ const effectiveRuntime = (0, runtime_name_policy_cjs_1.resolveRuntimeNameFromCandidates)(process.env['GSD_RUNTIME'], config['runtime']);
1255
+ if (!options.output && effectiveRuntime === 'codex') {
1256
+ configClaudeMdPath = './AGENTS.md';
1257
+ }
1258
+ }
1259
+ catch { /* use default */ }
1260
+ let targetPath;
1261
+ if (options.output) {
1262
+ targetPath = node_path_1.default.isAbsolute(options.output) ? options.output : node_path_1.default.join(cwd, options.output);
1263
+ }
1264
+ else {
1265
+ targetPath = node_path_1.default.isAbsolute(configClaudeMdPath) ? configClaudeMdPath : node_path_1.default.join(cwd, configClaudeMdPath);
1266
+ }
1267
+ // ── --read mode: parse existing block and return field values ───────────────
1268
+ if (options.read) {
1269
+ const fileContent = (0, shell_command_projection_cjs_1.platformReadSync)(targetPath);
1270
+ if (!fileContent) {
1271
+ output({ present: false }, raw, undefined);
1272
+ return;
1273
+ }
1274
+ const blockContent = extractSectionContent(fileContent, 'client-context');
1275
+ if (!blockContent) {
1276
+ output({ present: false }, raw, undefined);
1277
+ return;
1278
+ }
1279
+ const fields = parseClientContextFields(blockContent);
1280
+ if (!fields) {
1281
+ output({ present: false }, raw, undefined);
1282
+ return;
1283
+ }
1284
+ output({ present: true, ...fields }, raw, undefined);
1285
+ return;
1286
+ }
1287
+ // ── --from-json mode: render block and write ────────────────────────────────
1288
+ if (!options.fromJson) {
1289
+ error('--from-json <path> is required (or use --read to read existing block)');
1290
+ }
1291
+ let jsonPath = options.fromJson;
1292
+ if (!node_path_1.default.isAbsolute(jsonPath))
1293
+ jsonPath = node_path_1.default.join(cwd, jsonPath);
1294
+ if (!node_fs_1.default.existsSync(jsonPath))
1295
+ error(`JSON input file not found: ${jsonPath}`);
1296
+ let clientData;
1297
+ const jsonRaw = (0, shell_command_projection_cjs_1.platformReadSync)(jsonPath);
1298
+ try {
1299
+ if (jsonRaw === null)
1300
+ throw new Error(`JSON file not found: ${jsonPath}`);
1301
+ clientData = JSON.parse(jsonRaw);
1302
+ }
1303
+ catch (err) {
1304
+ error(`Failed to parse JSON input: ${err.message}`);
1305
+ }
1306
+ const clientName = sanitizeClientContextField(clientData.client_name || '');
1307
+ const clientSlug = sanitizeClientContextField(clientData.client_slug || '');
1308
+ const codingStandards = sanitizeClientContextField(clientData.coding_standards || '');
1309
+ const archPatterns = sanitizeClientContextField(clientData.architecture_patterns || '');
1310
+ const uiBrand = sanitizeClientContextField(clientData.ui_brand_conventions || '');
1311
+ const dod = sanitizeClientContextField(clientData.definition_of_done || '');
1312
+ const sectionLines = [
1313
+ '<!-- PP:client-context-start -->',
1314
+ '## Client Context & Patterns',
1315
+ '',
1316
+ 'When building or validating this project, apply these client patterns:',
1317
+ '',
1318
+ '### Client',
1319
+ '',
1320
+ `**Name:** ${clientName}`,
1321
+ `**Slug:** ${clientSlug}`,
1322
+ '',
1323
+ '### Coding Standards',
1324
+ '',
1325
+ codingStandards,
1326
+ '',
1327
+ '### Architecture Patterns',
1328
+ '',
1329
+ archPatterns,
1330
+ '',
1331
+ '### UI / Brand Conventions',
1332
+ '',
1333
+ uiBrand,
1334
+ '',
1335
+ '### Definition of Done',
1336
+ '',
1337
+ dod,
1338
+ '<!-- PP:client-context-end -->',
1339
+ ];
1340
+ const sectionContent = sectionLines.join('\n');
1341
+ const existingContent = (0, shell_command_projection_cjs_1.platformReadSync)(targetPath);
1342
+ let action;
1343
+ if (existingContent === null) {
1344
+ // File does not exist: create it with the block
1345
+ (0, shell_command_projection_cjs_1.platformEnsureDir)(node_path_1.default.dirname(targetPath));
1346
+ (0, shell_command_projection_cjs_1.platformWriteSync)(targetPath, sectionContent + '\n');
1347
+ action = 'created';
1348
+ }
1349
+ else if (!/<!-- PP:[a-z][a-z-]*-start/.test(existingContent) && !options.force) {
1350
+ // Hand-crafted guard (T-09-02): existing file has no PP markers → skip
1351
+ // Note: [a-z][a-z-]* matches section names with hyphens (e.g. client-context)
1352
+ output({
1353
+ action: 'skipped',
1354
+ claude_md_path: targetPath,
1355
+ reason: 'existing instruction file has no GSD markers (hand-crafted); not overwriting. Pass --force to overwrite.',
1356
+ }, raw, undefined);
1357
+ return;
1358
+ }
1359
+ else {
1360
+ // Replace-or-append idempotently via updateSection
1361
+ const updated = updateSection(existingContent, 'client-context', sectionContent);
1362
+ (0, shell_command_projection_cjs_1.platformWriteSync)(targetPath, updated.content);
1363
+ action = updated.action;
1364
+ }
1365
+ output({ action, claude_md_path: targetPath }, raw, undefined);
1366
+ }
1156
1367
  module.exports = {
1157
1368
  cmdWriteProfile,
1158
1369
  cmdProfileQuestionnaire,
1159
1370
  cmdGenerateDevPreferences,
1160
1371
  cmdGenerateClaudeProfile,
1161
1372
  cmdGenerateClaudeMd,
1373
+ cmdGenerateClientContext,
1374
+ updateSection,
1375
+ extractSectionContent,
1376
+ extractMarkdownSection,
1162
1377
  PROFILING_QUESTIONS,
1163
1378
  CLAUDE_INSTRUCTIONS,
1164
1379
  };
@@ -209,6 +209,7 @@ const phase = require('./lib/phase.cjs');
209
209
  const roadmap = require('./lib/roadmap.cjs');
210
210
  const verify = require('./lib/verify.cjs');
211
211
  const config = require('./lib/config.cjs');
212
+ const platformBindings = require('./lib/platform-bindings.cjs');
212
213
  const template = require('./lib/template.cjs');
213
214
  const milestone = require('./lib/milestone.cjs');
214
215
  const commands = require('./lib/commands.cjs');
@@ -236,6 +237,7 @@ const { routeTaskCommand } = require('./lib/task-command-router.cjs');
236
237
  const { parseNamedArgs, parseMultiwordArg } = require('./lib/command-arg-projection.cjs');
237
238
  const { cmdGitBaseBranch } = require('./lib/git-base-branch.cjs');
238
239
  const { getEffectiveAuthority, classifyDriftSeverity } = require('./lib/plan-drift-guard.cjs');
240
+ const profileOutput = require('./lib/profile-output.cjs');
239
241
 
240
242
  // ─── Bridge collapsed (Phase 4) ────────────────────────────────────────────────
241
243
  // Non-family commands now run through their CJS handlers directly. Keep the
@@ -521,10 +523,10 @@ async function main() {
521
523
  'config-ensure-section, config-get, config-new-project, config-path, config-set, migrate-config, ' +
522
524
  'current-timestamp, detect-custom-files, docs-init, drift-guard, effort, extract-messages, find-phase, ' +
523
525
  'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' +
524
- 'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' +
525
- 'capability, classify-confidence, git, learnings, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' +
526
+ 'generate-client-context, generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' +
527
+ 'capability, classify-confidence, git, learnings, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, platform-bindings, profile-questionnaire, ' +
526
528
  'profile-sample, progress, prompt-budget, requirements, research-plan, research-store, resolve-granularity, resolve-model, roadmap, scaffold, state, ' +
527
- 'task, template, user-story, validate, verify, verify-path-exists, verify-summary, workstream, worktree\n\n' +
529
+ 'task, template, user-story, validate, validate-bindings, verify, verify-path-exists, verify-summary, workstream, worktree\n\n' +
528
530
  'Global flags:\n' +
529
531
  ' --raw Emit raw output without post-processing\n' +
530
532
  ' --pick <field> Extract a single field from JSON output (dot/bracket notation)\n' +
@@ -697,6 +699,41 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
697
699
  break;
698
700
  }
699
701
 
702
+ case 'platform-bindings': {
703
+ platformBindings.cmdPlatformBindings(cwd, args.slice(1), raw);
704
+ break;
705
+ }
706
+
707
+ case 'validate-bindings': {
708
+ platformBindings.cmdValidateBindings(cwd, args.slice(1), raw);
709
+ break;
710
+ }
711
+
712
+ case 'generate-client-context': {
713
+ // generate-client-context --from-json <path> [--output <path>] [--force]
714
+ // generate-client-context --read [--output <path>]
715
+ const gcArgs = args.slice(1);
716
+ const gcOptions = {};
717
+ for (let i = 0; i < gcArgs.length; i++) {
718
+ const a = gcArgs[i];
719
+ if (a === '--from-json') {
720
+ const v = gcArgs[i + 1];
721
+ if (v !== undefined && !v.startsWith('--')) { gcOptions.fromJson = v; i++; }
722
+ else error('--from-json requires a <path> argument');
723
+ } else if (a === '--output') {
724
+ const v = gcArgs[i + 1];
725
+ if (v !== undefined && !v.startsWith('--')) { gcOptions.output = v; i++; }
726
+ else error('--output requires a <path> argument');
727
+ } else if (a === '--read') {
728
+ gcOptions.read = true;
729
+ } else if (a === '--force') {
730
+ gcOptions.force = true;
731
+ }
732
+ }
733
+ profileOutput.cmdGenerateClientContext(cwd, gcOptions, raw);
734
+ break;
735
+ }
736
+
700
737
  case 'resolve-granularity': {
701
738
  // Parse optional --granularity <val> flag (space form only); positional is phase-type.
702
739
  // The =form (--granularity=<val>) is intentionally not supported: parseNamedArgs and