@pareto4/pp-core 1.12.0 → 1.13.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/agents/pp-code-reviewer.md +1 -1
- package/agents/pp-executor.md +2 -13
- package/agents/pp-planner.md +1 -1
- package/agents/pp-vault-reviewer.md +51 -0
- package/commands/pp-ns-review.md +2 -1
- package/commands/pp-vault-review.md +33 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/pp-core/bin/lib/capability-activation.cjs +19 -9
- package/pp-core/bin/lib/capability-registry.cjs +200 -10
- package/pp-core/bin/lib/check-command-router.cjs +1242 -37
- package/pp-core/bin/lib/clusters.cjs +1 -0
- package/pp-core/bin/lib/command-aliases.cjs +7 -0
- package/pp-core/bin/lib/compose-bindings.cjs +41 -27
- package/pp-core/bin/lib/config-loader.cjs +151 -8
- package/pp-core/bin/lib/decisions.cjs +104 -27
- package/pp-core/bin/lib/dispatch-plan.cjs +12 -1
- package/pp-core/bin/lib/frontmatter.cjs +37 -0
- package/pp-core/bin/lib/git-base-branch.cjs +136 -10
- package/pp-core/bin/lib/init-contract.cjs +180 -66
- package/pp-core/bin/lib/init-interactive.cjs +4 -20
- package/pp-core/bin/lib/loop-gates.cjs +213 -0
- package/pp-core/bin/lib/loop-resolver.cjs +111 -35
- package/pp-core/bin/lib/org-profile.cjs +218 -44
- package/pp-core/bin/lib/phase-command-router.cjs +7 -1
- package/pp-core/bin/lib/phase-manifest.cjs +527 -0
- package/pp-core/bin/lib/phase-tier.cjs +15 -2
- package/pp-core/bin/lib/phase.cjs +51 -1
- package/pp-core/bin/lib/vault-audit.cjs +2070 -0
- package/pp-core/bin/lib/vault-command-router.cjs +460 -1
- package/pp-core/bin/lib/vault-practices.cjs +1142 -104
- package/pp-core/bin/lib/vault-review.cjs +2359 -0
- package/pp-core/bin/lib/vault-trace.cjs +2831 -0
- package/pp-core/bin/lib/vault-update.cjs +266 -0
- package/pp-core/bin/lib/vault-write-back.cjs +19 -0
- package/pp-core/bin/lib/vault.cjs +313 -11
- package/pp-core/bin/pp-tools.cjs +30 -13
- package/pp-core/bin/shared/model-catalog.json +2 -1
- package/pp-core/references/executor-vault-trace.md +47 -0
- package/pp-core/references/loop-hook-dispatch.md +20 -2
- package/pp-core/references/planner-vault-practices.md +46 -1
- package/pp-core/references/planning-config.md +1 -1
- package/pp-core/templates/practices/README.md +140 -11
- package/pp-core/templates/project-charter.md +226 -0
- package/pp-core/workflows/add-tests.md +4 -0
- package/pp-core/workflows/audit-fix.md +2 -0
- package/pp-core/workflows/audit-milestone.md +1 -1
- package/pp-core/workflows/autonomous.md +3 -3
- package/pp-core/workflows/check-todos.md +1 -1
- package/pp-core/workflows/code-review-fix.md +3 -1
- package/pp-core/workflows/code-review.md +1 -1
- package/pp-core/workflows/debug.md +4 -0
- package/pp-core/workflows/discovery-sprint/prefill.md +9 -0
- package/pp-core/workflows/discovery-sprint/prototype.md +2 -1
- package/pp-core/workflows/discovery-sprint/vault-fetch.md +37 -0
- package/pp-core/workflows/discovery-sprint.md +1 -1
- package/pp-core/workflows/discovery-to-build.md +1 -1
- package/pp-core/workflows/discuss-phase.md +2 -2
- package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +1 -0
- package/pp-core/workflows/execute-phase/steps/vault-door-gate.md +19 -0
- package/pp-core/workflows/execute-phase/steps/vault-fetch.md +1 -1
- package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +10 -8
- package/pp-core/workflows/execute-phase.md +29 -12
- package/pp-core/workflows/execute-plan.md +4 -0
- package/pp-core/workflows/fast.md +4 -0
- package/pp-core/workflows/help/modes/full.md +3 -2
- package/pp-core/workflows/init.md +2 -2
- package/pp-core/workflows/new-project.md +1 -1
- package/pp-core/workflows/plan-phase/steps/phase-tier.md +9 -0
- package/pp-core/workflows/plan-phase.md +13 -10
- package/pp-core/workflows/quick.md +5 -1
- package/pp-core/workflows/secure-phase.md +1 -1
- package/pp-core/workflows/ship.md +7 -3
- package/pp-core/workflows/undo.md +1 -1
- package/pp-core/workflows/validate-phase.md +5 -1
- package/pp-core/workflows/vault-review.md +65 -0
- package/pp-core/workflows/verify-work.md +6 -3
- package/scripts/gen-capability-registry.cjs +11 -0
- package/scripts/lint-test-file-count.allowlist.json +10 -1
- package/scripts/prompt-injection-scan.sh +2 -0
|
@@ -712,6 +712,13 @@ exports.NON_FAMILY_COMMAND_ALIASES = [
|
|
|
712
712
|
],
|
|
713
713
|
"mutation": true
|
|
714
714
|
},
|
|
715
|
+
{
|
|
716
|
+
"canonical": "phase.manifest",
|
|
717
|
+
"aliases": [
|
|
718
|
+
"phase manifest"
|
|
719
|
+
],
|
|
720
|
+
"mutation": true
|
|
721
|
+
},
|
|
715
722
|
{
|
|
716
723
|
"canonical": "phase.mvp-mode",
|
|
717
724
|
"aliases": [
|
|
@@ -47,6 +47,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
47
47
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
48
|
exports.COMPOSE_BINDINGS_FLAGS = exports.SEED_VALUE_MAX_LENGTH = void 0;
|
|
49
49
|
exports._resetPlatformTagMemoForTests = _resetPlatformTagMemoForTests;
|
|
50
|
+
exports.listStableTagsAt = listStableTagsAt;
|
|
51
|
+
exports.pickLatestStableTag = pickLatestStableTag;
|
|
50
52
|
exports.resolveCreatedWithFrom = resolveCreatedWithFrom;
|
|
51
53
|
exports.composeBindings = composeBindings;
|
|
52
54
|
exports.redactComposeResultForEmission = redactComposeResultForEmission;
|
|
@@ -398,33 +400,7 @@ function resolveLatestPlatformTag(cwd, repo, provider, opts) {
|
|
|
398
400
|
return cached;
|
|
399
401
|
let result;
|
|
400
402
|
try {
|
|
401
|
-
|
|
402
|
-
? opts._lsRemote(url)
|
|
403
|
-
: node_child_process_1.default.execFileSync('git', ['ls-remote', '--tags', '--refs', '--sort=-v:refname', url, 'v*'], {
|
|
404
|
-
encoding: 'utf-8',
|
|
405
|
-
timeout: 10_000,
|
|
406
|
-
maxBuffer: 512 * 1024,
|
|
407
|
-
windowsHide: true,
|
|
408
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
409
|
-
});
|
|
410
|
-
const tags = lsOutput
|
|
411
|
-
.split('\n')
|
|
412
|
-
.map((line) => {
|
|
413
|
-
const m = line.match(/\brefs\/tags\/([^\s^]+)$/);
|
|
414
|
-
return m ? m[1].trim() : '';
|
|
415
|
-
})
|
|
416
|
-
.filter((t) => t && (0, semver_compare_cjs_1.isStableTripletSemver)(t));
|
|
417
|
-
if (tags.length === 0) {
|
|
418
|
-
result = { tag: null, warnings: [] };
|
|
419
|
-
}
|
|
420
|
-
else {
|
|
421
|
-
let latest = tags[0];
|
|
422
|
-
for (const t of tags) {
|
|
423
|
-
if ((0, semver_compare_cjs_1.compareSemverCore)(t, latest) > 0)
|
|
424
|
-
latest = t;
|
|
425
|
-
}
|
|
426
|
-
result = { tag: latest, warnings: [] };
|
|
427
|
-
}
|
|
403
|
+
result = { tag: pickLatestStableTag(listStableTagsAt(url, opts)), warnings: [] };
|
|
428
404
|
}
|
|
429
405
|
catch {
|
|
430
406
|
result = {
|
|
@@ -437,6 +413,44 @@ function resolveLatestPlatformTag(cwd, repo, provider, opts) {
|
|
|
437
413
|
platformTagMemo.set(url, result);
|
|
438
414
|
return result;
|
|
439
415
|
}
|
|
416
|
+
/**
|
|
417
|
+
* List the stable `vX.Y.Z` tags at `url`, never shell-eval'd (T-34-25):
|
|
418
|
+
* `git ls-remote` is spawned with an argv array, bounded by
|
|
419
|
+
* `timeout`/`maxBuffer`/`windowsHide` (T-34-26), with the child's stderr piped
|
|
420
|
+
* and discarded. `--` ends the options, so a flag-shaped target can never be
|
|
421
|
+
* read as one. Filtered with `isStableTripletSemver`, the single blessed
|
|
422
|
+
* predicate. THROWS on any git fault — callers own the degrade policy. Shared
|
|
423
|
+
* by the platform-tag lookup and `vault update`.
|
|
424
|
+
*/
|
|
425
|
+
function listStableTagsAt(url, opts = {}) {
|
|
426
|
+
const lsOutput = typeof opts._lsRemote === 'function'
|
|
427
|
+
? opts._lsRemote(url)
|
|
428
|
+
: node_child_process_1.default.execFileSync('git', ['ls-remote', '--tags', '--refs', '--sort=-v:refname', '--', url, 'v*'], {
|
|
429
|
+
encoding: 'utf-8',
|
|
430
|
+
timeout: 10_000,
|
|
431
|
+
maxBuffer: 512 * 1024,
|
|
432
|
+
windowsHide: true,
|
|
433
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
434
|
+
});
|
|
435
|
+
return lsOutput
|
|
436
|
+
.split('\n')
|
|
437
|
+
.map((line) => {
|
|
438
|
+
const m = line.match(/\brefs\/tags\/([^\s^]+)$/);
|
|
439
|
+
return m ? m[1].trim() : '';
|
|
440
|
+
})
|
|
441
|
+
.filter((t) => t && (0, semver_compare_cjs_1.isStableTripletSemver)(t));
|
|
442
|
+
}
|
|
443
|
+
/** The highest tag by `compareSemverCore`, or `null` for an empty list. */
|
|
444
|
+
function pickLatestStableTag(tags) {
|
|
445
|
+
if (tags.length === 0)
|
|
446
|
+
return null;
|
|
447
|
+
let latest = tags[0];
|
|
448
|
+
for (const t of tags) {
|
|
449
|
+
if ((0, semver_compare_cjs_1.compareSemverCore)(t, latest) > 0)
|
|
450
|
+
latest = t;
|
|
451
|
+
}
|
|
452
|
+
return latest;
|
|
453
|
+
}
|
|
440
454
|
/**
|
|
441
455
|
* Read the first readable, semver-shaped `VERSION` file among the candidate
|
|
442
456
|
* locations resolved against `baseDir`, returning `<binName>@<version>`.
|
|
@@ -43,6 +43,9 @@ const model_catalog_cjs_1 = require("./model-catalog.cjs");
|
|
|
43
43
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
44
44
|
const federatedConfigModule = require("./federated-config.cjs");
|
|
45
45
|
const { mergeFederatedConfig } = federatedConfigModule;
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
47
|
+
const capabilityActivationModule = require("./capability-activation.cjs");
|
|
48
|
+
const { activationConfigFilesFor } = capabilityActivationModule;
|
|
46
49
|
// The capability-registry.cjs is generated and lives in the same gsd-core/bin/lib/ output dir.
|
|
47
50
|
// Both config-loader.cjs and capability-registry.cjs land in gsd-core/bin/lib/ at build time.
|
|
48
51
|
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
@@ -341,16 +344,154 @@ function _applyFederatedOverlay(baseConfig, userConfig) {
|
|
|
341
344
|
_applyFederatedValues(cloned, _fedOverlay.values, _fedOverlay.validKeys);
|
|
342
345
|
return cloned;
|
|
343
346
|
}
|
|
344
|
-
|
|
347
|
+
/**
|
|
348
|
+
* The workstream `loadConfig` reads for `options`: an explicit `workstream`
|
|
349
|
+
* option, then `workstreamContext.ws`, then `GSD_WORKSTREAM`. Shared with
|
|
350
|
+
* `configFilesFor` so the two can never resolve different workstreams.
|
|
351
|
+
*/
|
|
352
|
+
function _activeWorkstreamFor(options) {
|
|
345
353
|
const activeWorkstream = Object.prototype.hasOwnProperty.call(options, 'workstream')
|
|
346
354
|
? options['workstream']
|
|
347
355
|
: (options['workstreamContext'] && Object.prototype.hasOwnProperty.call(options['workstreamContext'], 'ws'))
|
|
348
356
|
? options['workstreamContext']['ws']
|
|
349
357
|
: (process.env['GSD_WORKSTREAM'] || null);
|
|
358
|
+
return typeof activeWorkstream === 'string' ? activeWorkstream : null;
|
|
359
|
+
}
|
|
360
|
+
function _configPathsFor(cwd, ws) {
|
|
361
|
+
return {
|
|
362
|
+
rootConfigPath: ws ? node_path_1.default.join(planningRoot(cwd), 'config.json') : null,
|
|
363
|
+
configPath: node_path_1.default.join(planningDir(cwd, ws), 'config.json'),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
/** `~/.gsd/defaults.json` (or under `GSD_HOME`): read only in a pre-project context (#1683). */
|
|
367
|
+
function _globalDefaultsPath() {
|
|
368
|
+
return node_path_1.default.join(process.env['GSD_HOME'] || node_os_1.default.homedir(), '.gsd', 'defaults.json');
|
|
369
|
+
}
|
|
370
|
+
/** The #1683 predicate: the global defaults are consulted only when the active planning dir is absent. */
|
|
371
|
+
function _readsGlobalDefaults(cwd, ws) {
|
|
372
|
+
return !node_fs_1.default.existsSync(planningDir(cwd, ws));
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Every config file `loadConfig(cwd, options)` reads, as absolute paths in read
|
|
376
|
+
* order. ONE owner of "which config files does the loader read": `loadConfig`
|
|
377
|
+
* takes its paths from the same `_configPathsFor`, so a reader that must refuse
|
|
378
|
+
* an unreadable config (`unreadableConfigFiles` below) parses exactly these
|
|
379
|
+
* files without restating the workstream ladder.
|
|
380
|
+
*
|
|
381
|
+
* Why a caller needs it: `loadConfig` swallows a parse failure in every one of
|
|
382
|
+
* these files (root → "workstream config stands alone", active → the defaults),
|
|
383
|
+
* so its RESULT cannot say whether it read the project's config or read
|
|
384
|
+
* nothing. Files that do not exist are still listed; absence is not a failure
|
|
385
|
+
* for `loadConfig`, and the caller decides what it means.
|
|
386
|
+
*/
|
|
387
|
+
function configFilesFor(cwd, options = {}) {
|
|
388
|
+
const ws = _activeWorkstreamFor(options);
|
|
389
|
+
const { rootConfigPath, configPath } = _configPathsFor(cwd, ws);
|
|
390
|
+
const files = [];
|
|
391
|
+
if (rootConfigPath)
|
|
392
|
+
files.push(rootConfigPath);
|
|
393
|
+
files.push(configPath);
|
|
394
|
+
if (_readsGlobalDefaults(cwd, ws))
|
|
395
|
+
files.push(_globalDefaultsPath());
|
|
396
|
+
return files;
|
|
397
|
+
}
|
|
398
|
+
/** The error text is bounded; composed text never reaches the cap, but it holds anyway. */
|
|
399
|
+
const UNREADABLE_ERROR_CAP = 200;
|
|
400
|
+
/**
|
|
401
|
+
* Describe why a config file could not be used WITHOUT echoing it. V8's
|
|
402
|
+
* `JSON.parse` messages sometimes quote the input (`Unexpected token '}',
|
|
403
|
+
* "{"a":tru}" is not valid JSON`), so only the position is carried over.
|
|
404
|
+
*/
|
|
405
|
+
function _describeConfigReadFailure(err) {
|
|
406
|
+
const e = err;
|
|
407
|
+
if (e && typeof e.code === 'string' && /^[A-Z][A-Z0-9_]*$/.test(e.code))
|
|
408
|
+
return `cannot be read (${e.code})`;
|
|
409
|
+
const message = e && typeof e.message === 'string' ? e.message : '';
|
|
410
|
+
const lineCol = /\(line (\d+) column (\d+)\)/.exec(message);
|
|
411
|
+
if (lineCol)
|
|
412
|
+
return `not valid JSON (line ${lineCol[1]}, column ${lineCol[2]})`;
|
|
413
|
+
const position = /at position (\d+)/.exec(message);
|
|
414
|
+
if (position)
|
|
415
|
+
return `not valid JSON (at position ${position[1]})`;
|
|
416
|
+
if (/Unexpected end of JSON input/.test(message))
|
|
417
|
+
return 'not valid JSON (the text ends before the JSON does)';
|
|
418
|
+
return 'not valid JSON';
|
|
419
|
+
}
|
|
420
|
+
/** How a refusal names a config file: never an absolute user path. */
|
|
421
|
+
function _configDisplayPath(cwd, abs) {
|
|
422
|
+
const under = (base) => {
|
|
423
|
+
const rel = node_path_1.default.relative(base, abs);
|
|
424
|
+
return rel !== '' && !rel.startsWith('..') && !node_path_1.default.isAbsolute(rel) ? rel.split(node_path_1.default.sep).join('/') : null;
|
|
425
|
+
};
|
|
426
|
+
const inProject = under(cwd);
|
|
427
|
+
if (inProject !== null)
|
|
428
|
+
return inProject;
|
|
429
|
+
const homeOverride = process.env['GSD_HOME'];
|
|
430
|
+
const inHomeOverride = homeOverride ? under(homeOverride) : null;
|
|
431
|
+
if (inHomeOverride !== null)
|
|
432
|
+
return `$GSD_HOME/${inHomeOverride}`;
|
|
433
|
+
const inHome = under(node_os_1.default.homedir());
|
|
434
|
+
if (inHome !== null)
|
|
435
|
+
return `~/${inHome}`;
|
|
436
|
+
return node_path_1.default.basename(abs);
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Every config file the hook resolver would read that EXISTS but cannot be
|
|
440
|
+
* used: unreadable, not JSON, or JSON that is not an object (`loadConfig`
|
|
441
|
+
* reads none of an array's or a null's keys and answers the defaults, the same
|
|
442
|
+
* silent fallback as a parse failure). Empty when every file is usable or
|
|
443
|
+
* absent: a MISSING config is a fresh project and reads as the defaults.
|
|
444
|
+
*
|
|
445
|
+
* The files are the union, in first-seen order, of the loader's list
|
|
446
|
+
* (`configFilesFor`) and the activation resolver's raw levels
|
|
447
|
+
* (`activationConfigFilesFor`, which can name a root config the loader never
|
|
448
|
+
* opens under `GSD_PROJECT`). Reads go through `platformReadSync`, the reader
|
|
449
|
+
* `loadConfig` uses, so a file the loader can read is never called unreadable.
|
|
450
|
+
*
|
|
451
|
+
* This is the ONE owner of "can the project's config be read". Both swallowing
|
|
452
|
+
* readers (`loadConfig`, `resolveConfigKey`) stay tolerant for their many
|
|
453
|
+
* callers; a caller whose answer governs a gate (`loop render-hooks`, the vault
|
|
454
|
+
* load verdict) asks this first and refuses. Never throws.
|
|
455
|
+
*/
|
|
456
|
+
function unreadableConfigFiles(cwd, options = {}) {
|
|
457
|
+
const out = [];
|
|
458
|
+
let files;
|
|
459
|
+
try {
|
|
460
|
+
files = [...new Set([...configFilesFor(cwd, options), ...activationConfigFilesFor(cwd)])];
|
|
461
|
+
}
|
|
462
|
+
catch (err) {
|
|
463
|
+
// An invalid GSD_WORKSTREAM / GSD_PROJECT segment: the config cannot be located, so it cannot be read.
|
|
464
|
+
return [{ file: '.planning/config.json', error: `cannot be located (${err instanceof Error ? err.message : String(err)})`.slice(0, UNREADABLE_ERROR_CAP) }];
|
|
465
|
+
}
|
|
466
|
+
for (const file of files) {
|
|
467
|
+
let error = null;
|
|
468
|
+
try {
|
|
469
|
+
const raw = (0, shell_command_projection_cjs_1.platformReadSync)(file);
|
|
470
|
+
if (raw === null)
|
|
471
|
+
continue;
|
|
472
|
+
const parsed = JSON.parse(raw);
|
|
473
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
474
|
+
error = 'valid JSON but not an object, so none of its settings can be read';
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
catch (err) {
|
|
478
|
+
error = _describeConfigReadFailure(err);
|
|
479
|
+
}
|
|
480
|
+
if (error !== null)
|
|
481
|
+
out.push({ file: _configDisplayPath(cwd, file), error: error.slice(0, UNREADABLE_ERROR_CAP) });
|
|
482
|
+
}
|
|
483
|
+
return out;
|
|
484
|
+
}
|
|
485
|
+
/** One line per unreadable file, `<file>: <error>`, for a refusal message. */
|
|
486
|
+
function describeUnreadableConfig(unreadable) {
|
|
487
|
+
return unreadable.map((u) => `${u.file}: ${u.error}`).join('; ');
|
|
488
|
+
}
|
|
489
|
+
function loadConfig(cwd, options = {}) {
|
|
350
490
|
// When GSD_WORKSTREAM is set, load root config first so workstream config
|
|
351
491
|
// can inherit from it. This prevents users from duplicating model_overrides,
|
|
352
492
|
// workflow.*, etc. across every workstream config (#2714).
|
|
353
|
-
const ws =
|
|
493
|
+
const ws = _activeWorkstreamFor(options);
|
|
494
|
+
const paths = _configPathsFor(cwd, ws);
|
|
354
495
|
// #315 — per-call lazy memo: all three detection sites inside this loadConfig
|
|
355
496
|
// call operate on the same cwd and the subrepo set cannot change mid-call, so
|
|
356
497
|
// a single scan is sufficient. The memo is scoped to THIS call (not module-level)
|
|
@@ -364,8 +505,8 @@ function loadConfig(cwd, options = {}) {
|
|
|
364
505
|
return cachedSubRepos.slice();
|
|
365
506
|
};
|
|
366
507
|
let rootParsed = null;
|
|
367
|
-
if (
|
|
368
|
-
const rootConfigPath =
|
|
508
|
+
if (paths.rootConfigPath) {
|
|
509
|
+
const rootConfigPath = paths.rootConfigPath;
|
|
369
510
|
try {
|
|
370
511
|
const raw = (0, shell_command_projection_cjs_1.platformReadSync)(rootConfigPath);
|
|
371
512
|
if (raw === null)
|
|
@@ -400,7 +541,7 @@ function loadConfig(cwd, options = {}) {
|
|
|
400
541
|
// Root config missing or unparseable — workstream config stands alone
|
|
401
542
|
}
|
|
402
543
|
}
|
|
403
|
-
const configPath =
|
|
544
|
+
const configPath = paths.configPath;
|
|
404
545
|
const defaults = CONFIG_DEFAULTS;
|
|
405
546
|
try {
|
|
406
547
|
const raw = (0, shell_command_projection_cjs_1.platformReadSync)(configPath);
|
|
@@ -625,7 +766,7 @@ function loadConfig(cwd, options = {}) {
|
|
|
625
766
|
}
|
|
626
767
|
catch {
|
|
627
768
|
// Fall back to ~/.gsd/defaults.json only for truly pre-project contexts (#1683)
|
|
628
|
-
if (
|
|
769
|
+
if (!_readsGlobalDefaults(cwd, ws)) {
|
|
629
770
|
if (rootParsed) {
|
|
630
771
|
// Workstream has no config.json: re-parse using root config as the sole source.
|
|
631
772
|
// (FIX 2: overlay is applied recursively in the re-entrant loadConfig call)
|
|
@@ -642,8 +783,7 @@ function loadConfig(cwd, options = {}) {
|
|
|
642
783
|
}
|
|
643
784
|
}
|
|
644
785
|
try {
|
|
645
|
-
const
|
|
646
|
-
const globalDefaultsPath = node_path_1.default.join(home, '.gsd', 'defaults.json');
|
|
786
|
+
const globalDefaultsPath = _globalDefaultsPath();
|
|
647
787
|
const raw = (0, shell_command_projection_cjs_1.platformReadSync)(globalDefaultsPath);
|
|
648
788
|
if (raw === null)
|
|
649
789
|
throw new Error('missing');
|
|
@@ -698,6 +838,9 @@ function loadConfig(cwd, options = {}) {
|
|
|
698
838
|
}
|
|
699
839
|
module.exports = {
|
|
700
840
|
loadConfig,
|
|
841
|
+
configFilesFor,
|
|
842
|
+
unreadableConfigFiles,
|
|
843
|
+
describeUnreadableConfig,
|
|
701
844
|
isGitIgnored,
|
|
702
845
|
CONFIG_DEFAULTS,
|
|
703
846
|
_getConfigDefault,
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* CJS callers that only use {id, text} safely ignore the extra fields.
|
|
11
11
|
*/
|
|
12
12
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.parseDecisionsReport = parseDecisionsReport;
|
|
13
14
|
exports.parseDecisions = parseDecisions;
|
|
14
15
|
const DISCRETION_HEADINGS = new Set([
|
|
15
16
|
"claude's discretion",
|
|
@@ -17,6 +18,67 @@ const DISCRETION_HEADINGS = new Set([
|
|
|
17
18
|
'claude discretion',
|
|
18
19
|
]);
|
|
19
20
|
const NON_TRACKABLE_TAGS = new Set(['informational', 'folded', 'deferred']);
|
|
21
|
+
// ── The decision-bullet grammar ────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
//
|
|
23
|
+
// Why a grammar rather than one regex: this repository writes THREE bullet
|
|
24
|
+
// forms, and originally only the first was readable. The two others were
|
|
25
|
+
// not exotic — they are what `discuss-phase` emits and what hand-authored
|
|
26
|
+
// CONTEXT.md files use — so a parser that reads only form A hands a blocking
|
|
27
|
+
// coverage gate an empty decision set and lets it report a clean pass. That is
|
|
28
|
+
// the defect this grammar retires (todo: decision-coverage-gate-false-pass).
|
|
29
|
+
//
|
|
30
|
+
// Form A `- **D-NN:** text` → arm A
|
|
31
|
+
// Form B `- **D-NN: Title.** text` → arm B (title may wrap: arm D)
|
|
32
|
+
// Form C `- **D-NN** text` → arm C
|
|
33
|
+
//
|
|
34
|
+
// HEAD matches the bullet opener and splits off `rest` (everything after the id
|
|
35
|
+
// and its optional `[tags]` group). The id rule is #3575's, unchanged: the first
|
|
36
|
+
// character after `D-` must be alphanumeric, so `D--x` and `D-_x` still fail
|
|
37
|
+
// HEAD and reach the parse-miss guard.
|
|
38
|
+
const BULLET_HEAD_RE = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?(.*)$/;
|
|
39
|
+
// Arm A — form A, widened. The run before the FIRST `:**` may now contain `:`.
|
|
40
|
+
// #1343 wrote that run as `[^:*]*`, which is what dropped every bold-title
|
|
41
|
+
// bullet AND `- **D-07 ratio 3:1:** w` (that miss used to be pinned as a floor;
|
|
42
|
+
// it now parses as D-07 / "w"). Lazy `[^*]*?` still stops at the first `:**`.
|
|
43
|
+
const ARM_A_RE = /^([^*]*?):\*\*\s*(.*)$/;
|
|
44
|
+
// Arm B — form B, the bold title. The title MAY contain `:` because real
|
|
45
|
+
// titles carry backticked loop points (`plan:post`, `execute:post`, `check:`);
|
|
46
|
+
// a title regex that excluded `:` measured four misses across two phases.
|
|
47
|
+
const ARM_B_RE = /^([^*]*?):\s*([^*]*?)\s*\*\*\s*(.*)$/;
|
|
48
|
+
// Arm C — the bold closes with no colon before it (`- **D-VH-1** body`, and
|
|
49
|
+
// `- **D-13 → see D-14.** *(reserved)*`). The only thing this form lacks is
|
|
50
|
+
// punctuation; the id is unambiguous, so it is admitted.
|
|
51
|
+
const ARM_C_RE = /^([^*]*?)\s*\*\*\s*(.*)$/;
|
|
52
|
+
// Arm D — no closing `**` on this line: a form-B title that wraps onto the
|
|
53
|
+
// next line. The existing continuation logic appends the following indented
|
|
54
|
+
// lines, stray `**` included (they are cosmetic in the resulting text).
|
|
55
|
+
const ARM_D_RE = /^([^*]*)$/;
|
|
56
|
+
/**
|
|
57
|
+
* Read a decision bullet's text out of `rest` (the remainder of the bullet line
|
|
58
|
+
* after the id and optional `[tags]`), trying arms A → B → C → D; first match
|
|
59
|
+
* wins. Returns null when no arm matches, which sends the line to the
|
|
60
|
+
* parse-miss guard.
|
|
61
|
+
*
|
|
62
|
+
* The guard is NOT vacuous after this widening: a malformed id fails HEAD, and
|
|
63
|
+
* a `rest` carrying a `*` in no arm shape (`- **D-01 *italic* no close`)
|
|
64
|
+
* matches no arm.
|
|
65
|
+
*/
|
|
66
|
+
function readBulletText(rest) {
|
|
67
|
+
const a = rest.match(ARM_A_RE);
|
|
68
|
+
if (a)
|
|
69
|
+
return a[2];
|
|
70
|
+
const b = rest.match(ARM_B_RE);
|
|
71
|
+
// Headline first, so a six-word `softPhrase` is drawn from the title.
|
|
72
|
+
if (b)
|
|
73
|
+
return (b[2] + ' ' + b[3]).trim();
|
|
74
|
+
const c = rest.match(ARM_C_RE);
|
|
75
|
+
if (c)
|
|
76
|
+
return (c[1] + ' ' + c[2]).trim();
|
|
77
|
+
const d = rest.match(ARM_D_RE);
|
|
78
|
+
if (d)
|
|
79
|
+
return d[1].replace(/^\s*:?\s*/, '').trim();
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
20
82
|
/**
|
|
21
83
|
* Strip fenced code blocks from `content` so example `<decisions>` snippets
|
|
22
84
|
* inside ```` ``` ```` do not pollute the parser (review F11).
|
|
@@ -40,34 +102,28 @@ function extractDecisionsBlock(content) {
|
|
|
40
102
|
return matches.map((m) => m[1]).join('\n\n');
|
|
41
103
|
}
|
|
42
104
|
/**
|
|
43
|
-
* Parse
|
|
105
|
+
* Parse CONTEXT.md content into BOTH channels: the decisions that were read,
|
|
106
|
+
* and the bullets that could not be.
|
|
44
107
|
*
|
|
45
|
-
*
|
|
46
|
-
* non-trackable ones, with `trackable: false`).
|
|
47
|
-
*
|
|
108
|
+
* `decisions` holds ALL D-NN decisions found inside `<decisions>` (including
|
|
109
|
+
* non-trackable ones, with `trackable: false`). `unparsed` holds every line
|
|
110
|
+
* that opened like a decision bullet and matched no arm of the grammar.
|
|
111
|
+
*
|
|
112
|
+
* The second channel exists because "the block was empty" and "the parser
|
|
113
|
+
* could not read any of it" are opposite situations that a coverage gate used
|
|
114
|
+
* to report identically — as a clean pass on a total of zero.
|
|
48
115
|
*/
|
|
49
|
-
function
|
|
116
|
+
function parseDecisionsReport(content) {
|
|
50
117
|
if (!content || typeof content !== 'string')
|
|
51
|
-
return [];
|
|
118
|
+
return { decisions: [], unparsed: [] };
|
|
52
119
|
const block = extractDecisionsBlock(content);
|
|
53
120
|
if (block === null)
|
|
54
|
-
return [];
|
|
121
|
+
return { decisions: [], unparsed: [] };
|
|
55
122
|
const lines = block.split(/\r?\n/);
|
|
56
123
|
const out = [];
|
|
124
|
+
const unparsed = [];
|
|
57
125
|
let category = '';
|
|
58
126
|
let inDiscretion = false;
|
|
59
|
-
// Bullet line: `- **D-NN[ [tags]]:** text`
|
|
60
|
-
// Phase 6 (#3575): aligned to CJS regex — accepts alphanumeric IDs (D-01, D-INFRA-01, D-FOO_BAR)
|
|
61
|
-
// in addition to numeric-only IDs (D-42). The first character after `D-` must
|
|
62
|
-
// be alphanumeric, so malformed shapes like `D--foo` or `D-_bar` are rejected.
|
|
63
|
-
// CJS callers consume {id, text} and ignore the optional extras.
|
|
64
|
-
// #1343: `[^:*]*` replaces the old `\s*` before `:**` so that a freeform run
|
|
65
|
-
// such as `(parenthetical)`, an em-dash, or other prose between the optional
|
|
66
|
-
// bracket-tag group and the closing `:**` is tolerated rather than silently
|
|
67
|
-
// dropping the whole decision. `[^:*]*` subsumes plain whitespace and stops
|
|
68
|
-
// correctly at `:**`. Capture groups 1 (id), 2 (bracket tags), 3 (text) are
|
|
69
|
-
// unchanged.
|
|
70
|
-
const bulletRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^:*]*:\*\*\s*(.*)$/;
|
|
71
127
|
let current = null;
|
|
72
128
|
const flush = () => {
|
|
73
129
|
if (current) {
|
|
@@ -94,29 +150,36 @@ function parseDecisions(content) {
|
|
|
94
150
|
inDiscretion = DISCRETION_HEADINGS.has(normalized);
|
|
95
151
|
continue;
|
|
96
152
|
}
|
|
97
|
-
const
|
|
98
|
-
|
|
153
|
+
const headMatch = line.match(BULLET_HEAD_RE);
|
|
154
|
+
const bulletText = headMatch ? readBulletText(headMatch[3]) : null;
|
|
155
|
+
if (headMatch && bulletText !== null) {
|
|
99
156
|
flush();
|
|
100
|
-
const id = `D-${
|
|
101
|
-
const tags =
|
|
102
|
-
?
|
|
157
|
+
const id = `D-${headMatch[1]}`;
|
|
158
|
+
const tags = headMatch[2]
|
|
159
|
+
? headMatch[2]
|
|
103
160
|
.split(',')
|
|
104
161
|
.map((t) => t.trim().toLowerCase())
|
|
105
162
|
.filter(Boolean)
|
|
106
163
|
: [];
|
|
107
164
|
const trackable = !inDiscretion && !tags.some((t) => NON_TRACKABLE_TAGS.has(t));
|
|
108
|
-
current = { id, text:
|
|
165
|
+
current = { id, text: bulletText, category, tags, trackable };
|
|
109
166
|
continue;
|
|
110
167
|
}
|
|
111
168
|
// Parse-miss guard (#1343): a line that looks like a `D-NN` decision bullet
|
|
112
|
-
// but failed
|
|
169
|
+
// but failed HEAD (malformed id) or matched no arm of the grammar must NOT
|
|
113
170
|
// be silently dropped — a narrowed trackable set lets a blocking coverage gate
|
|
114
171
|
// report a false pass. Surface it loudly instead.
|
|
172
|
+
//
|
|
173
|
+
// The miss is now recorded as DATA as well, so a gate can fail
|
|
174
|
+
// on it rather than warning past it. The `console.warn` stays: the stderr
|
|
175
|
+
// signal is what a human running the command by hand sees, and it is pinned
|
|
176
|
+
// by the #1343 regression tests.
|
|
115
177
|
if (/^\s*-\s+\*\*D-/.test(line)) {
|
|
116
178
|
// A malformed D-bullet still starts a (failed) new decision, so it ends the
|
|
117
179
|
// previous one — flush before warning so a following continuation line cannot
|
|
118
180
|
// be mis-appended to the prior valid decision.
|
|
119
181
|
flush();
|
|
182
|
+
unparsed.push({ line: trimmed, category });
|
|
120
183
|
console.warn(`parseDecisions: ignored unparseable decision bullet: ${trimmed}`);
|
|
121
184
|
continue;
|
|
122
185
|
}
|
|
@@ -132,5 +195,19 @@ function parseDecisions(content) {
|
|
|
132
195
|
}
|
|
133
196
|
}
|
|
134
197
|
flush();
|
|
135
|
-
return out;
|
|
198
|
+
return { decisions: out, unparsed };
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Parse trackable decisions from CONTEXT.md content.
|
|
202
|
+
*
|
|
203
|
+
* Returns ALL D-NN decisions found inside `<decisions>` (including
|
|
204
|
+
* non-trackable ones, with `trackable: false`). Callers that only want the
|
|
205
|
+
* gate-enforced decisions should filter `.filter(d => d.trackable)`.
|
|
206
|
+
*
|
|
207
|
+
* This signature is unchanged, and so is its behaviour. A caller that must not
|
|
208
|
+
* treat an unreadable bullet as an ABSENT decision — any gate that reports a
|
|
209
|
+
* verdict — should call `parseDecisionsReport` and read `unparsed`.
|
|
210
|
+
*/
|
|
211
|
+
function parseDecisions(content) {
|
|
212
|
+
return parseDecisionsReport(content).decisions;
|
|
136
213
|
}
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
* here): researcher, pattern mapper,
|
|
35
35
|
* planner (always) and plan checker passes; then code reviewer and fixer,
|
|
36
36
|
* the heavy-tier install run (`phase.install-run`, src/install-run.cts),
|
|
37
|
-
* verifier, security auditor
|
|
37
|
+
* verifier, security auditor, nyquist auditor and — heavy tier, vault
|
|
38
|
+
* practices on — the agent practice review. `totals.spawns` counts every agent spawn of every stage, as an upper
|
|
38
39
|
* bound: entries marked `conditional` run only when the step before them asks
|
|
39
40
|
* for it (a revision after a failed check, a fix after review findings).
|
|
40
41
|
*/
|
|
@@ -69,6 +70,7 @@ exports.STAGE_AGENTS = Object.freeze({
|
|
|
69
70
|
codeFixer: 'pp-code-fixer',
|
|
70
71
|
nyquistAuditor: 'pp-nyquist-auditor',
|
|
71
72
|
securityAuditor: 'pp-security-auditor',
|
|
73
|
+
vaultReviewer: 'pp-vault-reviewer',
|
|
72
74
|
});
|
|
73
75
|
function spawn(id, agent, gate, reason, conditional = false) {
|
|
74
76
|
return { id, kind: 'spawn', agent, gate, reason, conditional };
|
|
@@ -79,6 +81,9 @@ function step(id, gate, reason) {
|
|
|
79
81
|
/**
|
|
80
82
|
* The planning and verification stages of a phase, from its resolved tier.
|
|
81
83
|
* `planBounceScript`: bounce runs only with a configured script.
|
|
84
|
+
* `vaultReview`: the project's vault practices apply (`vaultReviewApplies`,
|
|
85
|
+
* src/vault.cts — the caller reads it); the agent practice review is spawned
|
|
86
|
+
* only when that AND the tier's `vault_review` gate are both on.
|
|
82
87
|
*/
|
|
83
88
|
function computeTierStages(tier, opts = {}) {
|
|
84
89
|
const g = tier.gates;
|
|
@@ -117,6 +122,9 @@ function computeTierStages(tier, opts = {}) {
|
|
|
117
122
|
verify.push(spawn('verify-security', exports.STAGE_AGENTS.securityAuditor, 'security_enforcement', 'threat-model verification (SECURITY.md)'));
|
|
118
123
|
if (g.nyquist_validation)
|
|
119
124
|
verify.push(spawn('verify-nyquist', exports.STAGE_AGENTS.nyquistAuditor, 'nyquist_validation', 'validation-gap audit (VALIDATION.md)'));
|
|
125
|
+
if (g.vault_review && opts.vaultReview === true) {
|
|
126
|
+
verify.push(spawn('verify-vault-review', exports.STAGE_AGENTS.vaultReviewer, 'vault_review', 'agent practice review of the client vault (VAULT-REVIEW.md); reports, never gates'));
|
|
127
|
+
}
|
|
120
128
|
return { plan, verify };
|
|
121
129
|
}
|
|
122
130
|
function normalizePath(file) {
|
|
@@ -255,6 +263,7 @@ function waveDefects(spawns, byId) {
|
|
|
255
263
|
* order; `phaseDir` is the phase directory relative to the project root.
|
|
256
264
|
*/
|
|
257
265
|
function computeDispatchPlan(phase, phaseDir, plans, policy, tier = null, tierOpts = {}) {
|
|
266
|
+
const contributions = (tierOpts.executorContributions ?? []).map((c) => ({ capId: c.capId, fragment: c.fragment }));
|
|
258
267
|
const byId = new Map(plans.map((p) => [p.id, p]));
|
|
259
268
|
const skippedComplete = plans.filter((p) => p.has_summary).map((p) => p.id);
|
|
260
269
|
const incomplete = plans.filter((p) => !p.has_summary && (!policy.gapsOnly || p.gap_closure === true));
|
|
@@ -294,6 +303,7 @@ function computeDispatchPlan(phase, phaseDir, plans, policy, tier = null, tierOp
|
|
|
294
303
|
build_reason: buildReason(members),
|
|
295
304
|
checkpoint: members.some((q) => !q.autonomous),
|
|
296
305
|
expected_summaries: entries.map((e) => e.summary),
|
|
306
|
+
contributions: contributions.map((c) => ({ ...c })),
|
|
297
307
|
});
|
|
298
308
|
continue;
|
|
299
309
|
}
|
|
@@ -311,6 +321,7 @@ function computeDispatchPlan(phase, phaseDir, plans, policy, tier = null, tierOp
|
|
|
311
321
|
build_reason: buildReason([p]),
|
|
312
322
|
checkpoint: !p.autonomous,
|
|
313
323
|
expected_summaries: [entry.summary],
|
|
324
|
+
contributions: contributions.map((c) => ({ ...c })),
|
|
314
325
|
});
|
|
315
326
|
}
|
|
316
327
|
const defects = waveDefects(spawns, byId);
|
|
@@ -216,6 +216,24 @@ function reconstructFrontmatter(obj) {
|
|
|
216
216
|
}
|
|
217
217
|
return lines.join('\n');
|
|
218
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* The raw lines of a top-level `key:` block inside a `---` frontmatter match —
|
|
221
|
+
* the `key:` line plus every following line that is blank or indented, up to
|
|
222
|
+
* the next column-0 key — with trailing blank lines dropped, or `null` when the
|
|
223
|
+
* frontmatter has no such top-level block.
|
|
224
|
+
*/
|
|
225
|
+
function rawTopLevelBlock(frontmatterMatch, key) {
|
|
226
|
+
const lines = frontmatterMatch.split(/\r?\n/).slice(1, -1);
|
|
227
|
+
const start = lines.findIndex((l) => l === `${key}:` || new RegExp(`^${key}:\\s*$`).test(l));
|
|
228
|
+
if (start === -1)
|
|
229
|
+
return null;
|
|
230
|
+
let end = start + 1;
|
|
231
|
+
while (end < lines.length && (lines[end].trim() === '' || /^\s/.test(lines[end])))
|
|
232
|
+
end += 1;
|
|
233
|
+
while (end > start + 1 && lines[end - 1].trim() === '')
|
|
234
|
+
end -= 1;
|
|
235
|
+
return lines.slice(start, end).join('\n');
|
|
236
|
+
}
|
|
219
237
|
function spliceFrontmatter(content, newObj) {
|
|
220
238
|
const match = content.match(/^---\r?\n[\s\S]+?\r?\n---/);
|
|
221
239
|
if (match) {
|
|
@@ -238,6 +256,25 @@ function spliceFrontmatter(content, newObj) {
|
|
|
238
256
|
catch {
|
|
239
257
|
/* fall through to regeneration on any comparison hiccup */
|
|
240
258
|
}
|
|
259
|
+
// The practice-trace ledger (`vault:`, docs/vault-trace-format.md) is a list
|
|
260
|
+
// of maps the lossy serializer cannot re-emit. When the caller left it
|
|
261
|
+
// untouched — `frontmatter set <plan> wave 2`, the plan-graph write-back —
|
|
262
|
+
// its raw lines are carried over verbatim instead of being regenerated.
|
|
263
|
+
const ledger = rawTopLevelBlock(match[0], 'vault');
|
|
264
|
+
if (ledger !== null) {
|
|
265
|
+
try {
|
|
266
|
+
const original = extractFrontmatter(content);
|
|
267
|
+
if (frontmatterDeepEqual(original.vault, newObj.vault)) {
|
|
268
|
+
const rest = { ...newObj };
|
|
269
|
+
delete rest.vault;
|
|
270
|
+
const restStr = reconstructFrontmatter(rest);
|
|
271
|
+
return `---\n${restStr}${restStr === '' ? '' : '\n'}${ledger}\n---` + content.slice(match[0].length);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
/* fall through to regeneration */
|
|
276
|
+
}
|
|
277
|
+
}
|
|
241
278
|
const yamlStr = reconstructFrontmatter(newObj);
|
|
242
279
|
return `---\n${yamlStr}\n---` + content.slice(match[0].length);
|
|
243
280
|
}
|