@pareto4/pp-core 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/agents/pp-code-fixer.md +11 -0
- package/agents/pp-code-reviewer.md +77 -1
- package/agents/pp-executor.md +34 -1
- package/agents/pp-planner.md +24 -103
- package/commands/pp-ns-manage.md +2 -1
- package/commands/pp-vault-promote.md +31 -0
- package/gemini-extension.json +1 -1
- package/package.json +5 -3
- package/pareto-skills/pp-brand/SKILL.md +1 -1
- package/pp-core/bin/lib/capability-registry.cjs +160 -3
- package/pp-core/bin/lib/capability-state.cjs +41 -3
- package/pp-core/bin/lib/check-command-router.cjs +473 -3
- package/pp-core/bin/lib/clusters.cjs +3 -0
- package/pp-core/bin/lib/compose-bindings.cjs +736 -0
- package/pp-core/bin/lib/git-provider.cjs +279 -0
- package/pp-core/bin/lib/init-contract.cjs +989 -0
- package/pp-core/bin/lib/init-interactive.cjs +183 -0
- package/pp-core/bin/lib/io.cjs +9 -0
- package/pp-core/bin/lib/org-profile.cjs +696 -0
- package/pp-core/bin/lib/platform-bindings.cjs +268 -64
- package/pp-core/bin/lib/practices-review-check.cjs +938 -0
- package/pp-core/bin/lib/vault-command-router.cjs +239 -2
- package/pp-core/bin/lib/vault-coordinates.cjs +485 -0
- package/pp-core/bin/lib/vault-practices.cjs +982 -0
- package/pp-core/bin/lib/vault-write-back.cjs +842 -0
- package/pp-core/bin/lib/vault.cjs +170 -51
- package/pp-core/bin/pp-tools.cjs +225 -2
- package/pp-core/references/planner-checkpoints.md +72 -0
- package/pp-core/references/planner-tdd.md +55 -0
- package/pp-core/references/planner-vault-practices.md +67 -0
- package/pp-core/templates/practices/README.md +122 -0
- package/pp-core/templates/practices/error-handling.md +18 -0
- package/pp-core/templates/practices/minimal-comments.md +14 -0
- package/pp-core/templates/practices/naming-convention.md +18 -0
- package/pp-core/templates/practices/practice-template.md +23 -0
- package/pp-core/workflows/code-review.md +74 -18
- package/pp-core/workflows/discovery-sprint.md +1 -1
- package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +1 -1
- package/pp-core/workflows/init.md +137 -76
- package/pp-core/workflows/ship.md +1 -0
- package/pp-core/workflows/vault-write-back.md +213 -0
- package/scripts/gen-capability-registry.cjs +68 -8
- package/scripts/gen-platform-bindings-schema.cjs +148 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +8 -1
- package/scripts/lint-descriptions.cjs +3 -1
- package/scripts/lint-legacy-dir-name.cjs +5 -0
- package/scripts/lint-test-file-count.allowlist.json +10 -1
|
@@ -0,0 +1,989 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* src/init-contract.cts — the `/pp-init` question set and end-state
|
|
4
|
+
* invariant check, as code (milestone v1.11.0, Phase 34-07, gap closure).
|
|
5
|
+
*
|
|
6
|
+
* ADR-457 build-at-publish: this TypeScript file is the SOURCE OF TRUTH. It
|
|
7
|
+
* compiles to the git-ignored `pp-core/bin/lib/init-contract.cjs`; do NOT
|
|
8
|
+
* hand-edit the compiled output. Behaviour is locked by
|
|
9
|
+
* `tests/init-contract.test.cjs`.
|
|
10
|
+
*
|
|
11
|
+
* Born from a documented failure, not a speculative hardening. Two human
|
|
12
|
+
* UAT walks of `/pp-init` (`34-VALIDATION.md` § Manual-Only Verifications,
|
|
13
|
+
* `34-05-SUMMARY.md`) found that prose-driven load-bearing behaviour — a
|
|
14
|
+
* question set that must not shrink, a step that must not be skippable, a
|
|
15
|
+
* config key that must be set — is not reliably enforced by an executing
|
|
16
|
+
* agent re-interpreting Markdown, even after two escalating rounds of
|
|
17
|
+
* explicit in-file hardening. `buildInitQuestions` moves the canonical
|
|
18
|
+
* question set (and the provider option list, derived from `PROVIDERS`
|
|
19
|
+
* rather than retyped) out of prose into tested data; `verifyInitInvariants`
|
|
20
|
+
* inspects the RESULTING project state after init runs, rather than
|
|
21
|
+
* trusting that any one step announced itself — enforcement placed inside
|
|
22
|
+
* the step it protects does not constrain an agent that skips the step
|
|
23
|
+
* entirely (see `34-VALIDATION.md`'s diagnosis).
|
|
24
|
+
*
|
|
25
|
+
* Both functions are read-only and never throw. `verifyInitInvariants`
|
|
26
|
+
* calls `composeBindings` in offline mode to answer "was a composable
|
|
27
|
+
* binding ever produced" — this module therefore sits ABOVE
|
|
28
|
+
* `src/compose-bindings.cts` in the CommonJS graph. Nothing in
|
|
29
|
+
* `compose-bindings.cts`, `git-provider.cts` or `org-profile.cts` imports
|
|
30
|
+
* this module, at load time or lazily, so this introduces no require cycle.
|
|
31
|
+
*
|
|
32
|
+
* This module's own prose deliberately never names a concrete org profile
|
|
33
|
+
* package or client, matching the convention `src/org-profile.cts` and
|
|
34
|
+
* `src/compose-bindings.cts` both state in their own headers.
|
|
35
|
+
*
|
|
36
|
+
* Residual risk, stated plainly (see the plan's threat register): nothing
|
|
37
|
+
* here prevents an agent from declining to call this arm at all.
|
|
38
|
+
* `buildInitQuestions` makes the rendered content correct when called;
|
|
39
|
+
* `verifyInitInvariants` makes the omission provable afterwards, by
|
|
40
|
+
* inspecting state `init.md` cannot avoid leaving behind. It does not make
|
|
41
|
+
* step-skipping impossible.
|
|
42
|
+
*/
|
|
43
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
44
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
45
|
+
};
|
|
46
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
47
|
+
exports.INIT_SESSION_WRITE_FAILED = exports.INIT_SESSION_MALFORMED = exports.INIT_CONTRACT_FLAGS = exports.INIT_COMPOSE_DECLINED_KEY = void 0;
|
|
48
|
+
exports.buildInitQuestions = buildInitQuestions;
|
|
49
|
+
exports.verifyInitInvariants = verifyInitInvariants;
|
|
50
|
+
exports.initSessionNext = initSessionNext;
|
|
51
|
+
exports.initSessionAnswer = initSessionAnswer;
|
|
52
|
+
exports.initSessionReset = initSessionReset;
|
|
53
|
+
exports.cmdInitContract = cmdInitContract;
|
|
54
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
55
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
57
|
+
const io = require("./io.cjs");
|
|
58
|
+
const { output } = io;
|
|
59
|
+
const git_provider_cjs_1 = require("./git-provider.cjs");
|
|
60
|
+
const org_profile_cjs_1 = require("./org-profile.cjs");
|
|
61
|
+
const compose_bindings_cjs_1 = require("./compose-bindings.cjs");
|
|
62
|
+
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
63
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
64
|
+
/**
|
|
65
|
+
* The dotted `.planning/config.json` key `/pp-init` Step 2.5 sets when the
|
|
66
|
+
* operator explicitly declines the compose-and-write confirmation. This is
|
|
67
|
+
* the ONLY signal that distinguishes "the operator was asked and said no"
|
|
68
|
+
* from "the step never ran at all" — both leave `.platform-bindings.json`
|
|
69
|
+
* absent, and `verifyInitInvariants` cannot tell them apart any other way
|
|
70
|
+
* (see `INVARIANT_CODE.COMPOSITION_NOT_RUN` below). Registered as a
|
|
71
|
+
* `vault` capability config key (`capabilities/vault/capability.json`) so
|
|
72
|
+
* `pp-tools config-set` accepts it without the "unknown config key" error
|
|
73
|
+
* a bare unregistered top-level namespace would trip.
|
|
74
|
+
*/
|
|
75
|
+
exports.INIT_COMPOSE_DECLINED_KEY = 'init.compose_declined';
|
|
76
|
+
/** The identity-only choices appended after the provider seam's own
|
|
77
|
+
* `PROVIDERS` list — never retyped ahead of `PROVIDERS` itself (T-34-50). */
|
|
78
|
+
const IDENTITY_ONLY_PROVIDER_OPTIONS = Object.freeze(['github', 'other', 'none']);
|
|
79
|
+
/** Bounds the `node_modules` scan `verifyInitInvariants` runs to answer
|
|
80
|
+
* "could an org profile package have been pointed to" — a DoS guard, the
|
|
81
|
+
* same convention `src/org-profile.cts`'s `PROFILE_FLATTEN_LEAF_CAP` and
|
|
82
|
+
* `PROFILE_FLATTEN_DEPTH_CAP` use for the analogous profile-document walk. */
|
|
83
|
+
const NODE_MODULES_SCAN_CAP = 2000;
|
|
84
|
+
const INVARIANT_CODE = Object.freeze({
|
|
85
|
+
ORG_PROFILE_UNASKED: 'INIT_QUESTIONS_ORG_PROFILE_UNASKED',
|
|
86
|
+
COMPOSITION_NOT_RUN: 'INIT_COMPOSITION_NOT_RUN',
|
|
87
|
+
VAULT_PROVIDER_UNSET: 'INIT_VAULT_PROVIDER_UNSET',
|
|
88
|
+
IDENTITY_MISSING: 'INIT_IDENTITY_MISSING',
|
|
89
|
+
});
|
|
90
|
+
/** The recognized flag set for the `init-contract` CLI arm (the
|
|
91
|
+
* `pp-tools.cjs` fail-fast unrecognized-flag guard reads this, mirroring
|
|
92
|
+
* `COMPOSE_BINDINGS_FLAGS`). Extended by Task 2 (34-08 gap closure) with the
|
|
93
|
+
* session relay flags — `--session-answer` additionally consumes the next
|
|
94
|
+
* argv token as its `<stepId>=<value>` payload (parsed in `pp-tools.cjs`,
|
|
95
|
+
* mirroring the `--cwd`/`--pick` convention), and `--offline` forwards to
|
|
96
|
+
* every `composeBindings` call the session makes so a CLI-driven loop can
|
|
97
|
+
* run with no network access — parity with `compose-bindings --offline`. */
|
|
98
|
+
exports.INIT_CONTRACT_FLAGS = Object.freeze([
|
|
99
|
+
'--questions',
|
|
100
|
+
'--verify',
|
|
101
|
+
'--session-next',
|
|
102
|
+
'--session-answer',
|
|
103
|
+
'--session-reset',
|
|
104
|
+
'--offline',
|
|
105
|
+
]);
|
|
106
|
+
/** `.planning/.init-session.json` — the CLI-owned session state file
|
|
107
|
+
* (Artifacts table, 34-08). Never hand-edited; malformed content is
|
|
108
|
+
* refused, not silently repaired (T-34-63). */
|
|
109
|
+
const INIT_SESSION_FILE_NAME = '.init-session.json';
|
|
110
|
+
/** The `error` code `initSessionNext`/`initSessionAnswer` report when the
|
|
111
|
+
* session file exists but does not parse as valid session state — the ONLY
|
|
112
|
+
* recovery is `--session-reset` (T-34-63); this module never resumes a
|
|
113
|
+
* malformed file into a guessed-at state. */
|
|
114
|
+
exports.INIT_SESSION_MALFORMED = 'INIT_SESSION_MALFORMED';
|
|
115
|
+
/** CR-01: the `error`/`reason` code `initSessionNext`/`initSessionAnswer`
|
|
116
|
+
* report when a filesystem write this module performs (`persistSession`,
|
|
117
|
+
* `writeConfigMerge`, the write-mode `composeBindings` call) fails — e.g. an
|
|
118
|
+
* unwritable `.planning` directory (permissions, a read-only mount, a full
|
|
119
|
+
* disk). Every sibling module in this seam (`composeBindings`,
|
|
120
|
+
* `resolveOrgProfileDocument`, `resolveOrgProfile`, `verifyInitInvariants`)
|
|
121
|
+
* never throws and degrades to a bounded data verdict instead (D-09); this
|
|
122
|
+
* module previously did not follow that convention on its write paths and
|
|
123
|
+
* let the exception escape uncaught. Recovery is to fix the underlying
|
|
124
|
+
* filesystem issue and retry the same step — nothing is left half-recorded,
|
|
125
|
+
* since the throw is caught before `session` is persisted. */
|
|
126
|
+
exports.INIT_SESSION_WRITE_FAILED = 'INIT_SESSION_WRITE_FAILED';
|
|
127
|
+
/** compose_confirm's answer vocabulary — deliberately permissive so a human
|
|
128
|
+
* relaying through `AskUserQuestion`/TEXT_MODE can answer naturally; every
|
|
129
|
+
* other string is rejected (never silently treated as a decline). */
|
|
130
|
+
const YES_VALUES = Object.freeze(['yes', 'y', 'approve', 'approved', 'true']);
|
|
131
|
+
const NO_VALUES = Object.freeze(['no', 'n', 'decline', 'declined', 'false']);
|
|
132
|
+
/** Maps each of the 7 identity/provider/visibility question ids (every
|
|
133
|
+
* question but `org_profile`, which is written immediately on answer — see
|
|
134
|
+
* `initSessionAnswer` — so the very next question, `provider`, can
|
|
135
|
+
* re-resolve its `preselected` against it) to the short key it is written
|
|
136
|
+
* under inside `.planning/config.json`'s `project` block. */
|
|
137
|
+
const PROJECT_BLOCK_QUESTION_MAP = Object.freeze({
|
|
138
|
+
project_name: 'name',
|
|
139
|
+
project_slug: 'slug',
|
|
140
|
+
client_name: 'client_name',
|
|
141
|
+
client_slug: 'client_slug',
|
|
142
|
+
description: 'description',
|
|
143
|
+
provider: 'provider',
|
|
144
|
+
visibility: 'visibility',
|
|
145
|
+
});
|
|
146
|
+
// ─── Small local helpers (deliberate per-module duplication — this repo's
|
|
147
|
+
// established convention for small, cycle-risk-free helpers; see
|
|
148
|
+
// `src/git-provider.cts`'s `cfgGet` for the canonical example) ────────────────
|
|
149
|
+
function isObject(v) {
|
|
150
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
151
|
+
}
|
|
152
|
+
function isUsableString(v) {
|
|
153
|
+
return typeof v === 'string' && v.trim() !== '';
|
|
154
|
+
}
|
|
155
|
+
function getDotted(obj, dottedPath) {
|
|
156
|
+
return dottedPath.split('.').reduce((cur, key) => (isObject(cur) ? cur[key] : undefined), obj);
|
|
157
|
+
}
|
|
158
|
+
// ─── buildInitQuestions ────────────────────────────────────────────────────
|
|
159
|
+
/**
|
|
160
|
+
* The canonical `/pp-init` question set, as data. `init.md` renders exactly
|
|
161
|
+
* what this returns, in the order returned, none merged, reordered or
|
|
162
|
+
* dropped — see the module header for why this moved out of prose.
|
|
163
|
+
*
|
|
164
|
+
* Question 6 (`org_profile`) is the org profile pointer, marked
|
|
165
|
+
* `optionalAnswer: true` — that flag describes the ANSWER (pressing Enter
|
|
166
|
+
* is a complete, valid response), never whether the question is asked.
|
|
167
|
+
*
|
|
168
|
+
* Question 7 (`provider`) carries the fixed, complete option list: every
|
|
169
|
+
* value in `PROVIDERS` (imported, never retyped — this is what keeps
|
|
170
|
+
* `azure` from being silently dropped again, T-34-50) plus the three
|
|
171
|
+
* identity-only choices. `preselected` is resolved via `resolveProvider`,
|
|
172
|
+
* the same authority the `compose-bindings --offline --pick provider`
|
|
173
|
+
* probe reads (pure config + org-profile consultation, no network) — so
|
|
174
|
+
* calling it directly here is equivalent, not a second implementation.
|
|
175
|
+
*/
|
|
176
|
+
function buildInitQuestions(cwd) {
|
|
177
|
+
let preselected = git_provider_cjs_1.DEFAULT_PROVIDER;
|
|
178
|
+
try {
|
|
179
|
+
preselected = (0, git_provider_cjs_1.resolveProvider)(cwd).provider;
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
preselected = git_provider_cjs_1.DEFAULT_PROVIDER;
|
|
183
|
+
}
|
|
184
|
+
const providerOptions = Object.freeze([...git_provider_cjs_1.PROVIDERS, ...IDENTITY_ONLY_PROVIDER_OPTIONS]);
|
|
185
|
+
return [
|
|
186
|
+
{ id: 'project_name', prompt: 'Project name (human-readable, e.g. "Acme Dashboard")', optionalAnswer: false },
|
|
187
|
+
{ id: 'project_slug', prompt: 'Project slug (kebab-case, e.g. "acme-dashboard")', optionalAnswer: false },
|
|
188
|
+
{
|
|
189
|
+
id: 'client_name',
|
|
190
|
+
prompt: 'Client name (company / team, e.g. "Acme Corp") — type "internal" if none',
|
|
191
|
+
optionalAnswer: false,
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
id: 'client_slug',
|
|
195
|
+
prompt: 'Client slug (kebab-case, e.g. "acme") — type "internal" if none',
|
|
196
|
+
optionalAnswer: false,
|
|
197
|
+
},
|
|
198
|
+
{ id: 'description', prompt: 'One-line description (what this project does)', optionalAnswer: false },
|
|
199
|
+
{
|
|
200
|
+
id: 'org_profile',
|
|
201
|
+
// The example is written as an angle-bracket TEMPLATE, not a literal package
|
|
202
|
+
// name. `tests/org-profile.test.cjs`'s client-confidentiality guard scans this
|
|
203
|
+
// file for any concrete scope-slash-org-prefix package identifier, so even a
|
|
204
|
+
// generic literal placeholder trips it. The template keeps the naming
|
|
205
|
+
// convention visible while staying clean, and the guard stays maximally
|
|
206
|
+
// strict — no allowlist entry was added for it.
|
|
207
|
+
prompt: 'Org profile package (optional — press Enter to skip, e.g. "@<your-scope>/org-<name>")',
|
|
208
|
+
optionalAnswer: true,
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
id: 'provider',
|
|
212
|
+
prompt: 'Provider (gitlab / azure / github / other / none)',
|
|
213
|
+
optionalAnswer: false,
|
|
214
|
+
options: providerOptions,
|
|
215
|
+
preselected,
|
|
216
|
+
preselectedLabel: `${preselected} (resolved default)`,
|
|
217
|
+
},
|
|
218
|
+
{ id: 'visibility', prompt: 'Visibility (private / public / internal)', optionalAnswer: false },
|
|
219
|
+
];
|
|
220
|
+
}
|
|
221
|
+
// ─── verifyInitInvariants ───────────────────────────────────────────────────
|
|
222
|
+
/**
|
|
223
|
+
* Byte-for-byte the same heuristic `src/org-profile.cts`'s own (private,
|
|
224
|
+
* unexported) `resolveGlobalPackageRoot` uses. Duplicated here because it
|
|
225
|
+
* is not part of that module's public surface — the same per-module
|
|
226
|
+
* duplication convention every sibling seam module in this repo follows
|
|
227
|
+
* for helpers it cannot import without either widening an unrelated
|
|
228
|
+
* module's exports or risking a require cycle.
|
|
229
|
+
*/
|
|
230
|
+
function resolveGlobalNodeModulesRoot() {
|
|
231
|
+
try {
|
|
232
|
+
const prefixEnv = process.env.NPM_CONFIG_PREFIX ?? process.env.npm_config_prefix;
|
|
233
|
+
let candidate;
|
|
234
|
+
if (typeof prefixEnv === 'string' && prefixEnv.trim() !== '') {
|
|
235
|
+
const prefix = prefixEnv.trim();
|
|
236
|
+
candidate =
|
|
237
|
+
process.platform === 'win32' ? node_path_1.default.join(prefix, 'node_modules') : node_path_1.default.join(prefix, 'lib', 'node_modules');
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
const execDir = node_path_1.default.dirname(process.execPath);
|
|
241
|
+
candidate =
|
|
242
|
+
process.platform === 'win32'
|
|
243
|
+
? node_path_1.default.join(execDir, 'node_modules')
|
|
244
|
+
: node_path_1.default.resolve(execDir, '..', 'lib', 'node_modules');
|
|
245
|
+
}
|
|
246
|
+
return node_fs_1.default.existsSync(candidate) ? candidate : null;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* True when `nodeModulesDir` contains at least one package directory (one
|
|
254
|
+
* level, or one level inside an `@scope/` directory) carrying a file named
|
|
255
|
+
* `ORG_PROFILE_DATA_FILE` at its root — the same fixed filename
|
|
256
|
+
* `resolveOrgProfileDocument` reads, imported rather than retyped. Bounded
|
|
257
|
+
* at `NODE_MODULES_SCAN_CAP` total directory entries visited (T-34-54: this
|
|
258
|
+
* check must never hang on a pathological `node_modules` tree).
|
|
259
|
+
*
|
|
260
|
+
* This is a heuristic, not a resolution: it answers "a package shaped like
|
|
261
|
+
* an org profile is installed here", not "this exact profile would
|
|
262
|
+
* resolve" — `verifyInitInvariants` never claims more than that.
|
|
263
|
+
*/
|
|
264
|
+
function containsOrgProfileShapedPackage(nodeModulesDir) {
|
|
265
|
+
let visited = 0;
|
|
266
|
+
let entries;
|
|
267
|
+
try {
|
|
268
|
+
entries = node_fs_1.default.readdirSync(nodeModulesDir, { withFileTypes: true });
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
for (const entry of entries) {
|
|
274
|
+
if (visited >= NODE_MODULES_SCAN_CAP)
|
|
275
|
+
return false;
|
|
276
|
+
visited += 1;
|
|
277
|
+
if (!entry.isDirectory())
|
|
278
|
+
continue;
|
|
279
|
+
if (entry.name.startsWith('@')) {
|
|
280
|
+
const scopeDir = node_path_1.default.join(nodeModulesDir, entry.name);
|
|
281
|
+
let scoped;
|
|
282
|
+
try {
|
|
283
|
+
scoped = node_fs_1.default.readdirSync(scopeDir, { withFileTypes: true });
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
for (const scopedEntry of scoped) {
|
|
289
|
+
if (visited >= NODE_MODULES_SCAN_CAP)
|
|
290
|
+
return false;
|
|
291
|
+
visited += 1;
|
|
292
|
+
if (!scopedEntry.isDirectory())
|
|
293
|
+
continue;
|
|
294
|
+
try {
|
|
295
|
+
if (node_fs_1.default.existsSync(node_path_1.default.join(scopeDir, scopedEntry.name, org_profile_cjs_1.ORG_PROFILE_DATA_FILE)))
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
try {
|
|
305
|
+
if (node_fs_1.default.existsSync(node_path_1.default.join(nodeModulesDir, entry.name, org_profile_cjs_1.ORG_PROFILE_DATA_FILE)))
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
function orgProfilePackageResolvable(cwd, opts) {
|
|
316
|
+
try {
|
|
317
|
+
const projectNodeModules = node_path_1.default.join(cwd, 'node_modules');
|
|
318
|
+
if (node_fs_1.default.existsSync(projectNodeModules) && containsOrgProfileShapedPackage(projectNodeModules)) {
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
const globalRoot = opts._globalRoot !== undefined ? opts._globalRoot : resolveGlobalNodeModulesRoot();
|
|
322
|
+
if (globalRoot && containsOrgProfileShapedPackage(globalRoot)) {
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/** Read `.planning/config.json` at `cwd`, distinguishing "file absent" from
|
|
332
|
+
* "file present but empty/malformed" — `INIT_IDENTITY_MISSING` only fires
|
|
333
|
+
* on the latter (see `verifyInitInvariants`'s own doc comment for why an
|
|
334
|
+
* absent file must never trip it). Never throws. */
|
|
335
|
+
function readConfigFile(cwd) {
|
|
336
|
+
const configPath = node_path_1.default.join(cwd, '.planning', 'config.json');
|
|
337
|
+
let exists = false;
|
|
338
|
+
try {
|
|
339
|
+
exists = node_fs_1.default.existsSync(configPath);
|
|
340
|
+
if (!exists) {
|
|
341
|
+
return { exists: false, parsed: {} };
|
|
342
|
+
}
|
|
343
|
+
const raw = node_fs_1.default.readFileSync(configPath, 'utf-8');
|
|
344
|
+
const parsedUnknown = JSON.parse(raw);
|
|
345
|
+
return { exists: true, parsed: isObject(parsedUnknown) ? parsedUnknown : {} };
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
// A present-but-unreadable/malformed file still counts as "a config
|
|
349
|
+
// file exists" for INIT_IDENTITY_MISSING's purposes.
|
|
350
|
+
return { exists, parsed: {} };
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Inspect the project's RESULTING state at `cwd` after an `/pp-init` run
|
|
355
|
+
* and report every named invariant it violates. Read-only: no filesystem
|
|
356
|
+
* write anywhere in this function or in the `composeBindings` call it
|
|
357
|
+
* makes (always `offline: true`, `write` omitted so it defaults to
|
|
358
|
+
* `false`). Never throws — each check below is independently guarded so
|
|
359
|
+
* one failing check cannot suppress the others; there is no outer
|
|
360
|
+
* catch-all because none is needed (see the design note below).
|
|
361
|
+
*
|
|
362
|
+
* An empty directory — nothing in `.planning/`, no `.platform-bindings.json`
|
|
363
|
+
* — returns `{ ok: true, violations: [] }`. Every check requires some
|
|
364
|
+
* positive signal that a partial init was attempted (a config file present
|
|
365
|
+
* for `INIT_IDENTITY_MISSING`; a "gitlab"/"azure" `project.provider` for
|
|
366
|
+
* `INIT_VAULT_PROVIDER_UNSET`; an installed org-profile-shaped package for
|
|
367
|
+
* `INIT_QUESTIONS_ORG_PROFILE_UNASKED`; a `composable: true` dry run for
|
|
368
|
+
* `INIT_COMPOSITION_NOT_RUN`) — an empty directory carries none of them, so
|
|
369
|
+
* nothing is reported as skipped. This is deliberate (see the module
|
|
370
|
+
* header): nothing was attempted, so nothing can be reported as attempted-
|
|
371
|
+
* and-abandoned.
|
|
372
|
+
*/
|
|
373
|
+
function verifyInitInvariants(cwd, opts = {}) {
|
|
374
|
+
const violations = [];
|
|
375
|
+
const { exists: configExists, parsed } = readConfigFile(cwd);
|
|
376
|
+
// INIT_IDENTITY_MISSING — only meaningful once a config file exists at
|
|
377
|
+
// all; an absent file is "nothing was attempted", not a violation.
|
|
378
|
+
try {
|
|
379
|
+
if (configExists) {
|
|
380
|
+
const projectBlock = getDotted(parsed, 'project');
|
|
381
|
+
const hasProjectBlock = isObject(projectBlock) && Object.keys(projectBlock).length > 0;
|
|
382
|
+
if (!hasProjectBlock) {
|
|
383
|
+
violations.push({
|
|
384
|
+
code: INVARIANT_CODE.IDENTITY_MISSING,
|
|
385
|
+
message: 'No "project" block found in .planning/config.json — Step 2 identity capture did not complete.',
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
// fail open — one check's fault must not suppress the others
|
|
392
|
+
}
|
|
393
|
+
// INIT_VAULT_PROVIDER_UNSET — project.provider names a provisioning
|
|
394
|
+
// provider (an identity record, D-13) but vault.provider —
|
|
395
|
+
// resolveProvider's ONLY gating key — was never set, so the D-13
|
|
396
|
+
// provisioning gate this phase built never fires.
|
|
397
|
+
try {
|
|
398
|
+
const projectProvider = getDotted(parsed, 'project.provider');
|
|
399
|
+
const vaultProvider = getDotted(parsed, 'vault.provider');
|
|
400
|
+
if ((projectProvider === 'gitlab' || projectProvider === 'azure') && !isUsableString(vaultProvider)) {
|
|
401
|
+
violations.push({
|
|
402
|
+
code: INVARIANT_CODE.VAULT_PROVIDER_UNSET,
|
|
403
|
+
message: `project.provider is "${String(projectProvider)}" but vault.provider is unset — the D-13 provisioning gate is disabled.`,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
// fail open
|
|
409
|
+
}
|
|
410
|
+
// INIT_QUESTIONS_ORG_PROFILE_UNASKED — an org-profile-shaped package is
|
|
411
|
+
// installed (project node_modules or the machine's global root) but
|
|
412
|
+
// org.profile was never set, i.e. Question 6 appears to have been
|
|
413
|
+
// skipped rather than answered blank.
|
|
414
|
+
try {
|
|
415
|
+
const orgProfilePointer = getDotted(parsed, org_profile_cjs_1.ORG_PROFILE_CONFIG_KEY);
|
|
416
|
+
if (!isUsableString(orgProfilePointer) && orgProfilePackageResolvable(cwd, opts)) {
|
|
417
|
+
violations.push({
|
|
418
|
+
code: INVARIANT_CODE.ORG_PROFILE_UNASKED,
|
|
419
|
+
message: 'An org-profile-shaped package is installed but org.profile is unset — the pointer question (Question 6) appears to have been skipped rather than answered.',
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
// fail open
|
|
425
|
+
}
|
|
426
|
+
// INIT_COMPOSITION_NOT_RUN — a binding was composable from the org
|
|
427
|
+
// profile and project answers, but .platform-bindings.json was never
|
|
428
|
+
// written and no explicit decline was recorded — Step 2.5 appears to
|
|
429
|
+
// have been skipped rather than run-and-declined.
|
|
430
|
+
try {
|
|
431
|
+
const bindingsPath = node_path_1.default.join(cwd, '.platform-bindings.json');
|
|
432
|
+
const bindingsExist = node_fs_1.default.existsSync(bindingsPath);
|
|
433
|
+
const declined = getDotted(parsed, exports.INIT_COMPOSE_DECLINED_KEY) === true;
|
|
434
|
+
if (!bindingsExist && !declined) {
|
|
435
|
+
const dryRun = (0, compose_bindings_cjs_1.composeBindings)(cwd, { offline: true, _globalRoot: opts._globalRoot });
|
|
436
|
+
if (dryRun.composable === true) {
|
|
437
|
+
violations.push({
|
|
438
|
+
code: INVARIANT_CODE.COMPOSITION_NOT_RUN,
|
|
439
|
+
message: 'A binding is composable from the org profile and project answers, but .platform-bindings.json was never written and no decline was recorded — Step 2.5 appears to have been skipped.',
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
// fail open
|
|
446
|
+
}
|
|
447
|
+
return { ok: violations.length === 0, violations };
|
|
448
|
+
}
|
|
449
|
+
// ─── Session state machine (34-08 gap closure) ─────────────────────────────
|
|
450
|
+
//
|
|
451
|
+
// Why this exists at all: `buildInitQuestions`/`verifyInitInvariants` make
|
|
452
|
+
// the CONTENT correct when called and the OMISSION provable afterwards —
|
|
453
|
+
// they do not make skipping impossible, because prose can still hand an
|
|
454
|
+
// agent the full 8-question list and the agent can still compress it (this
|
|
455
|
+
// module's own header; `34-VERIFICATION.md`'s third walk). This session
|
|
456
|
+
// machine removes the list itself: `initSessionNext` returns exactly ONE
|
|
457
|
+
// step, `initSessionAnswer` records exactly ONE answer and rejects
|
|
458
|
+
// anything out of order, and the CLI performs every side effect (identity
|
|
459
|
+
// write, `vault.provider`, composition) at the state that needs it — never
|
|
460
|
+
// leaving it to a workflow's own prose to remember. Neither
|
|
461
|
+
// `buildInitQuestions` nor `verifyInitInvariants` nor `composeBindings` is
|
|
462
|
+
// reimplemented here: this machine only sequences calls into them.
|
|
463
|
+
function sessionFilePath(cwd) {
|
|
464
|
+
return node_path_1.default.join(cwd, '.planning', INIT_SESSION_FILE_NAME);
|
|
465
|
+
}
|
|
466
|
+
function newSession() {
|
|
467
|
+
return { version: 1, answers: {}, identityWritten: false, composeDecision: null, doneAt: null };
|
|
468
|
+
}
|
|
469
|
+
/** Validate the ENTIRE persisted shape before trusting it — a session file
|
|
470
|
+
* that merely parses as JSON but carries the wrong shape (hand-edited,
|
|
471
|
+
* truncated mid-write, a leftover from an incompatible future version) must
|
|
472
|
+
* fail closed exactly like a parse error (T-34-63). */
|
|
473
|
+
function isValidSessionShape(v) {
|
|
474
|
+
if (!isObject(v))
|
|
475
|
+
return false;
|
|
476
|
+
const version = v.version;
|
|
477
|
+
const answers = v.answers;
|
|
478
|
+
const identityWritten = v.identityWritten;
|
|
479
|
+
const composeDecision = v.composeDecision;
|
|
480
|
+
const doneAt = v.doneAt;
|
|
481
|
+
if (version !== 1)
|
|
482
|
+
return false;
|
|
483
|
+
if (!isObject(answers))
|
|
484
|
+
return false;
|
|
485
|
+
for (const val of Object.values(answers)) {
|
|
486
|
+
if (typeof val !== 'string')
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
if (typeof identityWritten !== 'boolean')
|
|
490
|
+
return false;
|
|
491
|
+
if (composeDecision !== null &&
|
|
492
|
+
composeDecision !== 'approved' &&
|
|
493
|
+
composeDecision !== 'declined' &&
|
|
494
|
+
composeDecision !== 'skipped' &&
|
|
495
|
+
composeDecision !== 'unchanged') {
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
if (doneAt !== null && typeof doneAt !== 'string')
|
|
499
|
+
return false;
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
/** Absent file → a brand-new session (the normal first call). Present and
|
|
503
|
+
* valid → that session. Present but invalid in ANY way → `{ malformed:
|
|
504
|
+
* true }`, and every caller below refuses to proceed rather than silently
|
|
505
|
+
* re-initializing (T-34-63) — `initSessionReset` is the only documented
|
|
506
|
+
* recovery. Never throws. */
|
|
507
|
+
function loadSession(cwd) {
|
|
508
|
+
const p = sessionFilePath(cwd);
|
|
509
|
+
try {
|
|
510
|
+
if (!node_fs_1.default.existsSync(p)) {
|
|
511
|
+
return { malformed: false, session: newSession() };
|
|
512
|
+
}
|
|
513
|
+
const raw = node_fs_1.default.readFileSync(p, 'utf-8');
|
|
514
|
+
const parsed = JSON.parse(raw);
|
|
515
|
+
if (!isValidSessionShape(parsed)) {
|
|
516
|
+
return { malformed: true };
|
|
517
|
+
}
|
|
518
|
+
return { malformed: false, session: parsed };
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
return { malformed: true };
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/** CR-01: format an unexpected write failure (e.g. EACCES) as a bounded,
|
|
525
|
+
* human-readable message — never a raw stack trace reaching the operator. */
|
|
526
|
+
function writeFailedMessage(err) {
|
|
527
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
528
|
+
return (`A filesystem write inside the init session failed (${detail}). ` +
|
|
529
|
+
'This project\'s .planning directory (or .platform-bindings.json) may not be writable ' +
|
|
530
|
+
'(permissions, a read-only mount, a full disk). Nothing further was recorded for this step — ' +
|
|
531
|
+
'fix the underlying filesystem issue and try the same step again.');
|
|
532
|
+
}
|
|
533
|
+
function persistSession(cwd, session) {
|
|
534
|
+
const dir = node_path_1.default.join(cwd, '.planning');
|
|
535
|
+
(0, shell_command_projection_cjs_1.platformEnsureDir)(dir);
|
|
536
|
+
(0, shell_command_projection_cjs_1.platformWriteSync)(sessionFilePath(cwd), JSON.stringify(session, null, 2) + '\n');
|
|
537
|
+
}
|
|
538
|
+
/** Read `.planning/config.json` for a MERGE write — distinct from
|
|
539
|
+
* `readConfigFile` above (which reports `exists`/malformed for
|
|
540
|
+
* `verifyInitInvariants`'s read-only purposes): this helper only ever
|
|
541
|
+
* needs "the object to merge into", degrading to `{}` on any absence or
|
|
542
|
+
* fault so a merge write never crashes on a pre-existing malformed file —
|
|
543
|
+
* it simply starts the merge from empty and the write below then produces
|
|
544
|
+
* a well-formed file going forward. */
|
|
545
|
+
function readConfigForMerge(cwd) {
|
|
546
|
+
try {
|
|
547
|
+
const raw = node_fs_1.default.readFileSync(node_path_1.default.join(cwd, '.planning', 'config.json'), 'utf-8');
|
|
548
|
+
const parsed = JSON.parse(raw);
|
|
549
|
+
return isObject(parsed) ? parsed : {};
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
return {};
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
/** Shallow-recursive merge: object values merge key-by-key; any other
|
|
556
|
+
* value (string, number, boolean, array) replaces the target outright.
|
|
557
|
+
* Bounded by the patches this module ever constructs itself (`project.*`,
|
|
558
|
+
* `vault.provider`, `org.profile`, `init.compose_declined` — never a
|
|
559
|
+
* caller-supplied arbitrary depth), so no depth cap is needed here the way
|
|
560
|
+
* `walkLeaves` in `compose-bindings.cts` needs one for untrusted file
|
|
561
|
+
* content. */
|
|
562
|
+
function deepMergeConfigPatch(target, patch) {
|
|
563
|
+
for (const k of Object.keys(patch)) {
|
|
564
|
+
const pv = patch[k];
|
|
565
|
+
const existing = target[k];
|
|
566
|
+
if (isObject(pv) && isObject(existing)) {
|
|
567
|
+
target[k] = deepMergeConfigPatch({ ...existing }, pv);
|
|
568
|
+
}
|
|
569
|
+
else {
|
|
570
|
+
target[k] = pv;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return target;
|
|
574
|
+
}
|
|
575
|
+
/** The ONLY place this module writes `.planning/config.json` — every side
|
|
576
|
+
* effect the session performs (identity write, `vault.provider`,
|
|
577
|
+
* `org.profile`, `init.compose_declined`) routes through here, merged
|
|
578
|
+
* against whatever is already on disk so a session step never clobbers an
|
|
579
|
+
* unrelated key a human or another tool wrote. Direct `fs`/`JSON` I/O,
|
|
580
|
+
* bypassing `config-schema.cts`'s registered-key validation — the same
|
|
581
|
+
* convention `compose-bindings.cts`'s `cfgGet` and this module's own
|
|
582
|
+
* `readConfigFile` already use for `project.*`/`org.profile` reads; see
|
|
583
|
+
* `.planning/todos/pending/project-config-key-unknown.md` for why `project`
|
|
584
|
+
* is not (yet) a registered top-level key, out of scope for this plan. */
|
|
585
|
+
function writeConfigMerge(cwd, patch) {
|
|
586
|
+
const dir = node_path_1.default.join(cwd, '.planning');
|
|
587
|
+
(0, shell_command_projection_cjs_1.platformEnsureDir)(dir);
|
|
588
|
+
const merged = deepMergeConfigPatch(readConfigForMerge(cwd), patch);
|
|
589
|
+
(0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(dir, 'config.json'), JSON.stringify(merged, null, 2) + '\n');
|
|
590
|
+
}
|
|
591
|
+
/** The batch write Task 1 §2 describes: once all 8 answers are recorded,
|
|
592
|
+
* write the `project` identity block and — only when the `provider` answer
|
|
593
|
+
* is exactly `gitlab` or `azure` — `vault.provider`. `org_profile` is
|
|
594
|
+
* excluded from this map: it is written immediately when THAT answer is
|
|
595
|
+
* recorded (see `initSessionAnswer`), not deferred here, precisely so the
|
|
596
|
+
* `provider` question re-resolves its `preselected` against it. */
|
|
597
|
+
function writeIdentityAndProvider(cwd, answers) {
|
|
598
|
+
const projectPatch = {};
|
|
599
|
+
for (const [questionId, configKey] of Object.entries(PROJECT_BLOCK_QUESTION_MAP)) {
|
|
600
|
+
const raw = answers[questionId];
|
|
601
|
+
if (isUsableString(raw)) {
|
|
602
|
+
projectPatch[configKey] = raw.trim();
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
const patch = { project: projectPatch };
|
|
606
|
+
const providerAnswer = answers.provider;
|
|
607
|
+
if (providerAnswer === 'gitlab' || providerAnswer === 'azure') {
|
|
608
|
+
patch.vault = { provider: providerAnswer };
|
|
609
|
+
}
|
|
610
|
+
writeConfigMerge(cwd, patch);
|
|
611
|
+
}
|
|
612
|
+
/** Case/whitespace-tolerant yes/no parse for the `compose_confirm` answer.
|
|
613
|
+
* `null` for anything else — never defaulted to a decline, so a typo'd
|
|
614
|
+
* answer is rejected rather than silently treated as "no". */
|
|
615
|
+
function normalizeYesNo(value) {
|
|
616
|
+
if (typeof value !== 'string')
|
|
617
|
+
return null;
|
|
618
|
+
const v = value.trim().toLowerCase();
|
|
619
|
+
if (YES_VALUES.includes(v))
|
|
620
|
+
return true;
|
|
621
|
+
if (NO_VALUES.includes(v))
|
|
622
|
+
return false;
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* The single decision function every public session entry point drives.
|
|
627
|
+
* Recomputes `buildInitQuestions(cwd)` FRESH on every call — never cached
|
|
628
|
+
* across calls — which is what makes the `provider` question's
|
|
629
|
+
* `preselected` reflect an `org_profile` answer given moments earlier in
|
|
630
|
+
* the SAME session (the walk-3 regression this plan's design hazards
|
|
631
|
+
* name): by the time this function is asked for the `provider` question,
|
|
632
|
+
* `org.profile` has already been written to disk by `initSessionAnswer`,
|
|
633
|
+
* so `buildInitQuestions`'s own `resolveProvider` call sees it.
|
|
634
|
+
*
|
|
635
|
+
* Performs three idempotent side effects, each guarded by its own session
|
|
636
|
+
* flag so re-calling this (as every public entry point does, to validate
|
|
637
|
+
* the current step) never repeats a write: the identity+provider batch
|
|
638
|
+
* write once all 8 questions are answered; marking `composeDecision:
|
|
639
|
+
* 'skipped'` when nothing is composable at all (the real inert path,
|
|
640
|
+
* INIT-02) or `composeDecision: 'unchanged'` when a binding IS composable
|
|
641
|
+
* but the existing file already matches it (a healthy, up-to-date state —
|
|
642
|
+
* NOT the inert path, and reported with its own distinct message; no
|
|
643
|
+
* `compose_confirm` prompt either way, nothing written); and recording
|
|
644
|
+
* `doneAt` once `verifyInitInvariants` first reports `ok: true`.
|
|
645
|
+
*/
|
|
646
|
+
function computeCurrentStep(cwd, session, opts) {
|
|
647
|
+
// CR-01: every sibling module in this seam (composeBindings,
|
|
648
|
+
// resolveOrgProfileDocument, resolveOrgProfile, verifyInitInvariants)
|
|
649
|
+
// wraps its entire body in a top-level try/catch so a filesystem fault
|
|
650
|
+
// degrades to a bounded result rather than crashing (D-09). This
|
|
651
|
+
// function's writes (writeIdentityAndProvider/persistSession, below) did
|
|
652
|
+
// not — an unwritable .planning directory threw an uncaught EACCES
|
|
653
|
+
// straight out of initSessionNext/initSessionAnswer. Fixed by matching
|
|
654
|
+
// the convention: any failure anywhere in this body degrades to a
|
|
655
|
+
// `write_failed` verdict instead of propagating.
|
|
656
|
+
try {
|
|
657
|
+
const questions = buildInitQuestions(cwd);
|
|
658
|
+
for (const q of questions) {
|
|
659
|
+
if (!Object.prototype.hasOwnProperty.call(session.answers, q.id)) {
|
|
660
|
+
return { kind: 'question', question: q };
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (!session.identityWritten) {
|
|
664
|
+
writeIdentityAndProvider(cwd, session.answers);
|
|
665
|
+
session.identityWritten = true;
|
|
666
|
+
persistSession(cwd, session);
|
|
667
|
+
}
|
|
668
|
+
if (session.composeDecision === null) {
|
|
669
|
+
const dryRun = (0, compose_bindings_cjs_1.composeBindings)(cwd, { offline: opts.offline === true, _globalRoot: opts._globalRoot });
|
|
670
|
+
// WR-01: only offer compose_confirm when approving it would actually
|
|
671
|
+
// change something. `action: 'unchanged'` means the existing file
|
|
672
|
+
// already carries every key the composer would produce (D-08's
|
|
673
|
+
// absent-only merge) — asking the operator to approve a write that
|
|
674
|
+
// writes nothing is exactly the shape of prompt that produced the
|
|
675
|
+
// false-success report this finding names. Fall through to the
|
|
676
|
+
// messaging below instead of asking — but see the coordinator
|
|
677
|
+
// follow-up: 'unchanged' and 'not composable at all' are DIFFERENT
|
|
678
|
+
// end states and must record (and later report) distinct decisions,
|
|
679
|
+
// never sharing the inert-path (INIT-02) message below.
|
|
680
|
+
if (dryRun.composable === true && dryRun.action !== 'unchanged') {
|
|
681
|
+
return {
|
|
682
|
+
kind: 'compose_confirm',
|
|
683
|
+
info: {
|
|
684
|
+
provenance: dryRun.provenance,
|
|
685
|
+
drift: dryRun.drift,
|
|
686
|
+
action: dryRun.action,
|
|
687
|
+
composed: dryRun.composed,
|
|
688
|
+
warnings: dryRun.warnings,
|
|
689
|
+
},
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
session.composeDecision = dryRun.composable === true && dryRun.action === 'unchanged' ? 'unchanged' : 'skipped';
|
|
693
|
+
persistSession(cwd, session);
|
|
694
|
+
}
|
|
695
|
+
// Three genuinely different end states can reach `done`/`verify` with
|
|
696
|
+
// nothing written, and each needs its own message — conflating them
|
|
697
|
+
// (as an earlier pass of this fix did) tells an operator with a
|
|
698
|
+
// healthy, up-to-date binding that their org profile produced nothing
|
|
699
|
+
// composable, which is exactly the "tool reports a state that isn't
|
|
700
|
+
// the real one" failure shape this phase exists to eliminate.
|
|
701
|
+
const skippedComposeInfo = session.composeDecision === 'skipped'
|
|
702
|
+
? 'Nothing was composable from the org profile and project answers — no compose_confirm step was shown and nothing was written (the inert path, INIT-02).'
|
|
703
|
+
: session.composeDecision === 'unchanged'
|
|
704
|
+
? 'The existing .platform-bindings.json is already up to date with what the org profile and project answers would produce — no compose_confirm step was needed and nothing was written.'
|
|
705
|
+
: undefined;
|
|
706
|
+
const verifyResult = verifyInitInvariants(cwd, { _globalRoot: opts._globalRoot });
|
|
707
|
+
if (verifyResult.ok) {
|
|
708
|
+
if (!session.doneAt) {
|
|
709
|
+
session.doneAt = new Date().toISOString();
|
|
710
|
+
persistSession(cwd, session);
|
|
711
|
+
}
|
|
712
|
+
return { kind: 'done', skippedComposeInfo };
|
|
713
|
+
}
|
|
714
|
+
return { kind: 'verify', violations: verifyResult.violations, skippedComposeInfo };
|
|
715
|
+
}
|
|
716
|
+
catch (err) {
|
|
717
|
+
return { kind: 'write_failed', message: writeFailedMessage(err) };
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
function currentStepToState(step) {
|
|
721
|
+
switch (step.kind) {
|
|
722
|
+
case 'question': {
|
|
723
|
+
const q = step.question;
|
|
724
|
+
const state = { done: false, step: q.id, prompt: q.prompt, optionalAnswer: q.optionalAnswer };
|
|
725
|
+
if (q.options !== undefined)
|
|
726
|
+
state.options = q.options;
|
|
727
|
+
if (q.preselected !== undefined)
|
|
728
|
+
state.preselected = q.preselected;
|
|
729
|
+
if (q.preselectedLabel !== undefined)
|
|
730
|
+
state.preselectedLabel = q.preselectedLabel;
|
|
731
|
+
return state;
|
|
732
|
+
}
|
|
733
|
+
case 'compose_confirm':
|
|
734
|
+
return {
|
|
735
|
+
done: false,
|
|
736
|
+
step: 'compose_confirm',
|
|
737
|
+
prompt: 'A binding can be composed from the org profile and project answers. Compose and write .platform-bindings.json now? (yes/no)',
|
|
738
|
+
optionalAnswer: false,
|
|
739
|
+
info: step.info,
|
|
740
|
+
};
|
|
741
|
+
case 'verify':
|
|
742
|
+
return {
|
|
743
|
+
done: false,
|
|
744
|
+
step: 'verify',
|
|
745
|
+
prompt: null,
|
|
746
|
+
violations: step.violations,
|
|
747
|
+
...(step.skippedComposeInfo !== undefined ? { info: step.skippedComposeInfo } : {}),
|
|
748
|
+
};
|
|
749
|
+
case 'done':
|
|
750
|
+
return {
|
|
751
|
+
done: true,
|
|
752
|
+
step: 'done',
|
|
753
|
+
prompt: null,
|
|
754
|
+
...(step.skippedComposeInfo !== undefined ? { info: step.skippedComposeInfo } : {}),
|
|
755
|
+
};
|
|
756
|
+
case 'write_failed':
|
|
757
|
+
return {
|
|
758
|
+
done: false,
|
|
759
|
+
step: null,
|
|
760
|
+
prompt: null,
|
|
761
|
+
error: exports.INIT_SESSION_WRITE_FAILED,
|
|
762
|
+
message: step.message,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Return the ONE next step of the `/pp-init` session at `cwd` — a question,
|
|
768
|
+
* `compose_confirm`, `verify`, or `done`. Never a list. Fails closed (T-34-
|
|
769
|
+
* 63) with `error: INIT_SESSION_MALFORMED` when the session file exists but
|
|
770
|
+
* does not parse as valid session state; `--session-reset` is the
|
|
771
|
+
* documented recovery, never a silent re-initialize.
|
|
772
|
+
*/
|
|
773
|
+
function initSessionNext(cwd, opts = {}) {
|
|
774
|
+
const loaded = loadSession(cwd);
|
|
775
|
+
if (loaded.malformed) {
|
|
776
|
+
return {
|
|
777
|
+
done: false,
|
|
778
|
+
step: null,
|
|
779
|
+
prompt: null,
|
|
780
|
+
error: exports.INIT_SESSION_MALFORMED,
|
|
781
|
+
message: 'The init session file at .planning/.init-session.json exists but is malformed or unreadable. ' +
|
|
782
|
+
'Run "pp_run query init-contract --session-reset" to start over — it is never resumed silently into an unknown state.',
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
return currentStepToState(computeCurrentStep(cwd, loaded.session, opts));
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Record ONE answer for the CURRENT step only. Rejects — with `ok: false`
|
|
789
|
+
* and a named `reason`, never a throw — an answer for any step other than
|
|
790
|
+
* the one `initSessionNext` would currently serve (T-34-60: an agent trying
|
|
791
|
+
* to batch or skip ahead fails loudly), a blank answer to a
|
|
792
|
+
* non-`optionalAnswer` question, an unparseable `compose_confirm` value, or
|
|
793
|
+
* any answer once the session is already `done`.
|
|
794
|
+
*
|
|
795
|
+
* The `org_profile` answer is written to `.planning/config.json` (`org.profile`)
|
|
796
|
+
* IMMEDIATELY when non-blank — the one exception to the "batch write after
|
|
797
|
+
* the last answer" rule in `computeCurrentStep`'s own doc comment — because
|
|
798
|
+
* the very next question (`provider`) must see it to re-resolve its
|
|
799
|
+
* `preselected` (the walk-3 regression Task 1's acceptance names).
|
|
800
|
+
*/
|
|
801
|
+
function initSessionAnswer(cwd, stepId, value, opts = {}) {
|
|
802
|
+
const loaded = loadSession(cwd);
|
|
803
|
+
if (loaded.malformed) {
|
|
804
|
+
return {
|
|
805
|
+
ok: false,
|
|
806
|
+
reason: 'session_malformed',
|
|
807
|
+
message: 'The init session file at .planning/.init-session.json exists but is malformed or unreadable. ' +
|
|
808
|
+
'Run "pp_run query init-contract --session-reset" to start over.',
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
const session = loaded.session;
|
|
812
|
+
// CR-01: wrap the entire remaining body — every write this function
|
|
813
|
+
// performs directly (the `org_profile` immediate write, the batch
|
|
814
|
+
// `persistSession` calls, the `compose_confirm` decline/approve writes)
|
|
815
|
+
// — in a top-level try/catch, matching the D-09 convention every sibling
|
|
816
|
+
// module in this seam already follows. `computeCurrentStep` degrades to
|
|
817
|
+
// a `write_failed` step on its own internal failures (handled explicitly
|
|
818
|
+
// below); this catch additionally covers the write sites that live in
|
|
819
|
+
// THIS function, after `computeCurrentStep` returns.
|
|
820
|
+
try {
|
|
821
|
+
const step = computeCurrentStep(cwd, session, opts);
|
|
822
|
+
if (step.kind === 'write_failed') {
|
|
823
|
+
return { ok: false, reason: 'write_failed', message: step.message };
|
|
824
|
+
}
|
|
825
|
+
if (step.kind === 'done') {
|
|
826
|
+
return {
|
|
827
|
+
ok: false,
|
|
828
|
+
reason: 'session_already_done',
|
|
829
|
+
message: 'The init session is already complete; there is nothing left to answer. Run --session-reset to start over.',
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
if (step.kind === 'question') {
|
|
833
|
+
if (step.question.id !== stepId) {
|
|
834
|
+
return {
|
|
835
|
+
ok: false,
|
|
836
|
+
reason: 'step_mismatch',
|
|
837
|
+
message: `Expected an answer for step "${step.question.id}" but received one for "${stepId}". Answers must be submitted in the order the session serves them — call --session-next to see the actual current step.`,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
if (!step.question.optionalAnswer && (typeof value !== 'string' || value.trim() === '')) {
|
|
841
|
+
return { ok: false, reason: 'answer_required', message: `Question "${stepId}" requires a non-blank answer.` };
|
|
842
|
+
}
|
|
843
|
+
session.answers[stepId] = typeof value === 'string' ? value : '';
|
|
844
|
+
if (stepId === 'org_profile') {
|
|
845
|
+
const trimmed = session.answers[stepId].trim();
|
|
846
|
+
if (trimmed !== '') {
|
|
847
|
+
writeConfigMerge(cwd, { org: { profile: trimmed } });
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
persistSession(cwd, session);
|
|
851
|
+
return { ok: true };
|
|
852
|
+
}
|
|
853
|
+
if (step.kind === 'compose_confirm') {
|
|
854
|
+
if (stepId !== 'compose_confirm') {
|
|
855
|
+
return {
|
|
856
|
+
ok: false,
|
|
857
|
+
reason: 'step_mismatch',
|
|
858
|
+
message: `Expected an answer for step "compose_confirm" but received one for "${stepId}". Call --session-next to see the actual current step.`,
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
const normalized = normalizeYesNo(value);
|
|
862
|
+
if (normalized === null) {
|
|
863
|
+
return {
|
|
864
|
+
ok: false,
|
|
865
|
+
reason: 'invalid_answer',
|
|
866
|
+
message: 'compose_confirm requires "yes" or "no" (also accepts approve/decline, y/n, true/false).',
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
if (normalized === true) {
|
|
870
|
+
// WR-02: inspect the write-mode result before recording the
|
|
871
|
+
// decision. The dry run inside computeCurrentStep already reported
|
|
872
|
+
// composable:true/action!=='unchanged' moments earlier, but the
|
|
873
|
+
// world can change between that dry run and this write (the file
|
|
874
|
+
// became malformed on disk in between, a transient fault now caught
|
|
875
|
+
// internally by composeBindings's own outer catch, etc.) — in that
|
|
876
|
+
// case `wrote` comes back false even though the operator approved.
|
|
877
|
+
// Recording 'approved' unconditionally here would make the session
|
|
878
|
+
// proceed toward done/verify as though the write had succeeded,
|
|
879
|
+
// and the eventual INIT_COMPOSITION_NOT_RUN violation would then
|
|
880
|
+
// misdescribe what happened ("Step 2.5 appears to have been
|
|
881
|
+
// skipped") when in fact the user approved and the write failed.
|
|
882
|
+
const writeResult = (0, compose_bindings_cjs_1.composeBindings)(cwd, { write: true, offline: opts.offline === true, _globalRoot: opts._globalRoot });
|
|
883
|
+
if (writeResult.composable !== true || writeResult.wrote !== true) {
|
|
884
|
+
// Do NOT record composeDecision here — leave it null so the next
|
|
885
|
+
// --session-next call re-evaluates from a fresh dry run (and, if
|
|
886
|
+
// still composable, re-offers compose_confirm) rather than
|
|
887
|
+
// silently treating this as skipped or declined.
|
|
888
|
+
return {
|
|
889
|
+
ok: false,
|
|
890
|
+
reason: 'compose_write_failed',
|
|
891
|
+
message: 'compose_confirm was approved, but the write to .platform-bindings.json did not succeed — ' +
|
|
892
|
+
'this was NOT recorded as skipped or declined.' +
|
|
893
|
+
(writeResult.warnings.length > 0 ? ` Warnings: ${writeResult.warnings.join('; ')}.` : '') +
|
|
894
|
+
' Call --session-next to see the current state and try again.',
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
session.composeDecision = 'approved';
|
|
898
|
+
}
|
|
899
|
+
else {
|
|
900
|
+
writeConfigMerge(cwd, { init: { compose_declined: true } });
|
|
901
|
+
session.composeDecision = 'declined';
|
|
902
|
+
}
|
|
903
|
+
persistSession(cwd, session);
|
|
904
|
+
return { ok: true };
|
|
905
|
+
}
|
|
906
|
+
// step.kind === 'verify': a system-computed check, never answerable.
|
|
907
|
+
return {
|
|
908
|
+
ok: false,
|
|
909
|
+
reason: 'step_not_answerable',
|
|
910
|
+
message: 'Step "verify" does not accept an answer — it is a system-computed check. Call --session-next to see its result.',
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
catch (err) {
|
|
914
|
+
return { ok: false, reason: 'write_failed', message: writeFailedMessage(err) };
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
/** Delete the session file, the documented recovery from a malformed
|
|
918
|
+
* session (T-34-63). Never throws; a missing file is not an error. */
|
|
919
|
+
function initSessionReset(cwd) {
|
|
920
|
+
try {
|
|
921
|
+
const p = sessionFilePath(cwd);
|
|
922
|
+
if (node_fs_1.default.existsSync(p))
|
|
923
|
+
node_fs_1.default.unlinkSync(p);
|
|
924
|
+
}
|
|
925
|
+
catch {
|
|
926
|
+
// best-effort — nothing further this function can do
|
|
927
|
+
}
|
|
928
|
+
return { ok: true };
|
|
929
|
+
}
|
|
930
|
+
// ─── cmdInitContract ────────────────────────────────────────────────────────
|
|
931
|
+
/**
|
|
932
|
+
* CLI adapter for `pp-tools query init-contract`.
|
|
933
|
+
*
|
|
934
|
+
* `--verify` runs `verifyInitInvariants` and always prints the full result
|
|
935
|
+
* as data (so `init.md` can read and show every violation verbatim), then
|
|
936
|
+
* sets `process.exitCode = 1` when the result is not `ok` — a data
|
|
937
|
+
* verdict, never an `error()` usage failure. This is the load-bearing
|
|
938
|
+
* property Task 2's acceptance requires: a caller cannot mistake a failed
|
|
939
|
+
* check for a pass, but the JSON body still reaches stdout either way.
|
|
940
|
+
*
|
|
941
|
+
* `--questions` (also the no-flag default, matching this seam's
|
|
942
|
+
* safe-default convention — mirrors `compose-bindings`'s dry-run default)
|
|
943
|
+
* is read-only and always exits 0.
|
|
944
|
+
*
|
|
945
|
+
* `--session-reset` / `--session-answer` / `--session-next` (34-08 gap
|
|
946
|
+
* closure) drive the session state machine above. Precedence — reset,
|
|
947
|
+
* then answer, then next, then verify, then the questions default — mirrors
|
|
948
|
+
* the order a relay loop actually calls them in (an agent never sends more
|
|
949
|
+
* than one of these per invocation; the order only matters for a caller
|
|
950
|
+
* that somehow set two, which should never happen from `pp-tools.cjs`'s own
|
|
951
|
+
* parsing). `--session-answer` and `--session-next` both set
|
|
952
|
+
* `process.exitCode = 1` on a data verdict that means "not ready to
|
|
953
|
+
* proceed" (`ok: false` / `error` present) — same never-a-usage-failure
|
|
954
|
+
* convention as `--verify`.
|
|
955
|
+
*/
|
|
956
|
+
function cmdInitContract(cwd, options, raw) {
|
|
957
|
+
if (options.sessionReset === true) {
|
|
958
|
+
const result = initSessionReset(cwd);
|
|
959
|
+
output(result, raw);
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
if (options.sessionAnswer !== undefined) {
|
|
963
|
+
const result = initSessionAnswer(cwd, options.sessionAnswer.stepId, options.sessionAnswer.value, {
|
|
964
|
+
offline: options.offline === true,
|
|
965
|
+
});
|
|
966
|
+
output(result, raw);
|
|
967
|
+
if (!result.ok) {
|
|
968
|
+
process.exitCode = 1;
|
|
969
|
+
}
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
if (options.sessionNext === true) {
|
|
973
|
+
const state = initSessionNext(cwd, { offline: options.offline === true });
|
|
974
|
+
output(state, raw);
|
|
975
|
+
if (state.error !== undefined) {
|
|
976
|
+
process.exitCode = 1;
|
|
977
|
+
}
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
if (options.verify === true) {
|
|
981
|
+
const result = verifyInitInvariants(cwd);
|
|
982
|
+
output(result, raw);
|
|
983
|
+
if (!result.ok) {
|
|
984
|
+
process.exitCode = 1;
|
|
985
|
+
}
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
output({ questions: buildInitQuestions(cwd) }, raw);
|
|
989
|
+
}
|