@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.
- package/.claude-plugin/plugin.json +1 -1
- package/commands/pp-init.md +36 -0
- package/commands/pp-ns-project.md +2 -1
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/pp-core/bin/lib/capability-registry.cjs +135 -2
- package/pp-core/bin/lib/check-command-router.cjs +183 -1
- package/pp-core/bin/lib/clusters.cjs +1 -0
- package/pp-core/bin/lib/command-aliases.cjs +7 -0
- package/pp-core/bin/lib/io.cjs +2 -0
- package/pp-core/bin/lib/platform-bindings.cjs +336 -0
- package/pp-core/bin/lib/profile-output.cjs +216 -1
- package/pp-core/bin/pp-tools.cjs +40 -3
- package/pp-core/workflows/discovery-sprint.md +27 -10
- package/pp-core/workflows/execute-phase/steps/client-context-gate.md +37 -0
- package/pp-core/workflows/execute-phase.md +4 -0
- package/pp-core/workflows/init.md +530 -0
- package/pp-core/workflows/new-project.md +54 -1
- package/pp-core/workflows/ship.md +24 -1
- package/pp-core/workflows/verify-work.md +1 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -0
|
@@ -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]
|
|
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
|
};
|
package/pp-core/bin/pp-tools.cjs
CHANGED
|
@@ -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
|