@pareto4/pp-core 1.5.3 → 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 +220 -5
- package/pp-core/bin/pp-tools.cjs +40 -3
- package/pp-core/templates/claude-md.md +4 -4
- 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
|
+
}
|
|
@@ -200,14 +200,14 @@ function buildClaudeMdFallbacks(runtime) {
|
|
|
200
200
|
const SKILL_SEARCH_DIRS = ['.claude/skills', '.agents/skills', '.cursor/skills', '.github/skills', '.codex/skills'];
|
|
201
201
|
function buildClaudeMdWorkflowEnforcement(runtime) {
|
|
202
202
|
return [
|
|
203
|
-
'Before using Edit, Write, or other file-changing tools, start work through a
|
|
203
|
+
'Before using Edit, Write, or other file-changing tools, start work through a pp-core command so planning artifacts and execution context stay in sync.',
|
|
204
204
|
'',
|
|
205
205
|
'Use these entry points:',
|
|
206
206
|
`- \`${String((0, runtime_slash_cjs_1.formatGsdSlash)('quick', runtime))}\` for small fixes, doc updates, and ad-hoc tasks`,
|
|
207
207
|
`- \`${String((0, runtime_slash_cjs_1.formatGsdSlash)('debug', runtime))}\` for investigation and bug fixing`,
|
|
208
208
|
`- \`${String((0, runtime_slash_cjs_1.formatGsdSlash)('execute-phase', runtime))}\` for planned phase work`,
|
|
209
209
|
'',
|
|
210
|
-
'Do not make direct repo edits outside a
|
|
210
|
+
'Do not make direct repo edits outside a pp-core workflow unless the user explicitly asks to bypass it.',
|
|
211
211
|
].join('\n');
|
|
212
212
|
}
|
|
213
213
|
function buildClaudeMdProfilePlaceholder(runtime) {
|
|
@@ -414,7 +414,7 @@ function generateArchitectureSection(cwd) {
|
|
|
414
414
|
function generateWorkflowSection(cwd) {
|
|
415
415
|
return {
|
|
416
416
|
content: buildClaudeMdWorkflowEnforcement((0, runtime_slash_cjs_1.resolveRuntime)(cwd)),
|
|
417
|
-
source: '
|
|
417
|
+
source: 'pp-core defaults',
|
|
418
418
|
linkPath: null,
|
|
419
419
|
hasFallback: false,
|
|
420
420
|
};
|
|
@@ -988,7 +988,7 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
|
|
|
988
988
|
conventions: '## Conventions',
|
|
989
989
|
architecture: '## Architecture',
|
|
990
990
|
skills: '## Project Skills',
|
|
991
|
-
workflow: '##
|
|
991
|
+
workflow: '## pp-core Workflow Enforcement',
|
|
992
992
|
};
|
|
993
993
|
const generated = {};
|
|
994
994
|
const sectionsGenerated = [];
|
|
@@ -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
|
};
|