@pareto4/pp-core 1.21.1 → 1.23.1

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.
Files changed (136) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/install.js +205 -28
  3. package/commands/pp-help.md +2 -1
  4. package/gemini-extension.json +1 -1
  5. package/hooks/dist/pp-check-update-worker.js +1 -1
  6. package/hooks/dist/pp-context-monitor.js +3 -3
  7. package/hooks/dist/pp-statusline.js +88 -14
  8. package/hooks/dist/pp-update-banner.js +44 -4
  9. package/hooks/dist/pp-workflow-guard.js +3 -3
  10. package/hooks/pp-check-update-worker.js +1 -1
  11. package/hooks/pp-context-monitor.js +3 -3
  12. package/hooks/pp-statusline.js +88 -14
  13. package/hooks/pp-update-banner.js +44 -4
  14. package/hooks/pp-workflow-guard.js +3 -3
  15. package/package.json +2 -2
  16. package/pp-core/bin/lib/audience-command.cjs +271 -0
  17. package/pp-core/bin/lib/audience.cjs +293 -0
  18. package/pp-core/bin/lib/commands.cjs +26 -17
  19. package/pp-core/bin/lib/discovery-charter.cjs +13 -1
  20. package/pp-core/bin/lib/discovery-flow-page.cjs +191 -0
  21. package/pp-core/bin/lib/discovery-for-phase.cjs +33 -5
  22. package/pp-core/bin/lib/discovery-input-check.cjs +11 -11
  23. package/pp-core/bin/lib/discovery-processes.cjs +97 -4
  24. package/pp-core/bin/lib/discovery-session-check.cjs +7 -7
  25. package/pp-core/bin/lib/discovery-status.cjs +419 -0
  26. package/pp-core/bin/lib/discovery-stories.cjs +36 -5
  27. package/pp-core/bin/lib/discovery-tests.cjs +398 -0
  28. package/pp-core/bin/lib/explain-command.cjs +48 -0
  29. package/pp-core/bin/lib/gate-card.cjs +97 -0
  30. package/pp-core/bin/lib/hook-messages.cjs +132 -0
  31. package/pp-core/bin/lib/init-contract.cjs +116 -76
  32. package/pp-core/bin/lib/init-interactive.cjs +110 -49
  33. package/pp-core/bin/lib/init.cjs +44 -3
  34. package/pp-core/bin/lib/io.cjs +38 -0
  35. package/pp-core/bin/lib/message-labels.cjs +75 -0
  36. package/pp-core/bin/lib/messages/core.cjs +20 -0
  37. package/pp-core/bin/lib/messages/discovery.cjs +267 -0
  38. package/pp-core/bin/lib/messages/gate.cjs +257 -0
  39. package/pp-core/bin/lib/messages/hook.cjs +114 -0
  40. package/pp-core/bin/lib/messages/init.cjs +513 -0
  41. package/pp-core/bin/lib/messages/progress.cjs +110 -0
  42. package/pp-core/bin/lib/messages.cjs +208 -0
  43. package/pp-core/bin/lib/vault-command-router.cjs +16 -3
  44. package/pp-core/bin/lib/violation-render.cjs +78 -0
  45. package/pp-core/bin/pp-tools.cjs +92 -23
  46. package/pp-core/bin/shared/config-schema.manifest.json +1 -0
  47. package/pp-core/references/audience-moments.md +119 -0
  48. package/pp-core/references/audience.md +114 -0
  49. package/pp-core/references/discovery-build-consumption.md +5 -2
  50. package/pp-core/references/discovery-charter.md +2 -2
  51. package/pp-core/references/discovery-id-conventions.md +1 -1
  52. package/pp-core/references/discovery-processes.md +31 -7
  53. package/pp-core/references/discovery-session-output.md +1 -1
  54. package/pp-core/references/discovery-tests.md +186 -0
  55. package/pp-core/templates/project-charter.md +3 -4
  56. package/pp-core/workflows/add-phase.md +2 -0
  57. package/pp-core/workflows/add-tests.md +2 -0
  58. package/pp-core/workflows/add-todo.md +2 -0
  59. package/pp-core/workflows/ai-integration-phase.md +2 -0
  60. package/pp-core/workflows/audit-milestone.md +2 -0
  61. package/pp-core/workflows/autonomous.md +2 -0
  62. package/pp-core/workflows/check-todos.md +2 -0
  63. package/pp-core/workflows/code-review-fix.md +2 -0
  64. package/pp-core/workflows/code-review.md +2 -0
  65. package/pp-core/workflows/complete-milestone.md +10 -1
  66. package/pp-core/workflows/discovery-sprint/charter.md +3 -2
  67. package/pp-core/workflows/discovery-sprint/prefill.md +3 -0
  68. package/pp-core/workflows/discovery-sprint/prototype.md +14 -0
  69. package/pp-core/workflows/discovery-sprint/session-13-gates.md +2 -0
  70. package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +4 -1
  71. package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +135 -76
  72. package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +94 -64
  73. package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +3 -2
  74. package/pp-core/workflows/discovery-sprint/vault-context.md +15 -0
  75. package/pp-core/workflows/discovery-sprint.md +16 -2
  76. package/pp-core/workflows/discovery-to-build/steps/handoff-gates.md +16 -9
  77. package/pp-core/workflows/discovery-to-build/templates/discovery-references.md +1 -1
  78. package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +9 -1
  79. package/pp-core/workflows/discovery-to-build.md +10 -4
  80. package/pp-core/workflows/discuss-phase/discovery.md +2 -2
  81. package/pp-core/workflows/discuss-phase-assumptions.md +2 -0
  82. package/pp-core/workflows/discuss-phase.md +1 -1
  83. package/pp-core/workflows/edit-phase.md +2 -0
  84. package/pp-core/workflows/eval-review.md +2 -0
  85. package/pp-core/workflows/execute-phase/steps/check-query-gate.md +25 -1
  86. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +1 -0
  87. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +2 -0
  88. package/pp-core/workflows/execute-phase.md +1 -1
  89. package/pp-core/workflows/execute-plan.md +2 -0
  90. package/pp-core/workflows/extract-learnings.md +2 -0
  91. package/pp-core/workflows/help/modes/business.md +56 -0
  92. package/pp-core/workflows/help.md +12 -2
  93. package/pp-core/workflows/import.md +10 -1
  94. package/pp-core/workflows/ingest-docs.md +2 -0
  95. package/pp-core/workflows/init.md +17 -1
  96. package/pp-core/workflows/insert-phase.md +2 -0
  97. package/pp-core/workflows/list-workspaces.md +2 -0
  98. package/pp-core/workflows/manager.md +2 -0
  99. package/pp-core/workflows/map-codebase.md +2 -0
  100. package/pp-core/workflows/milestone-summary.md +2 -0
  101. package/pp-core/workflows/new-milestone.md +10 -1
  102. package/pp-core/workflows/new-project.md +12 -2
  103. package/pp-core/workflows/new-workspace.md +2 -0
  104. package/pp-core/workflows/next.md +8 -0
  105. package/pp-core/workflows/plan-milestone-gaps.md +10 -1
  106. package/pp-core/workflows/plan-phase.md +2 -1
  107. package/pp-core/workflows/plan-review-convergence.md +1 -1
  108. package/pp-core/workflows/progress.md +10 -0
  109. package/pp-core/workflows/quick.md +11 -1
  110. package/pp-core/workflows/remove-phase.md +2 -0
  111. package/pp-core/workflows/remove-workspace.md +2 -0
  112. package/pp-core/workflows/resume-project.md +25 -0
  113. package/pp-core/workflows/review.md +2 -0
  114. package/pp-core/workflows/scan.md +2 -0
  115. package/pp-core/workflows/secure-phase.md +2 -0
  116. package/pp-core/workflows/settings.md +41 -0
  117. package/pp-core/workflows/ship.md +2 -0
  118. package/pp-core/workflows/spec-phase.md +1 -1
  119. package/pp-core/workflows/ui-phase.md +2 -0
  120. package/pp-core/workflows/ui-review.md +2 -0
  121. package/pp-core/workflows/ultraplan-phase.md +2 -0
  122. package/pp-core/workflows/validate-phase.md +2 -0
  123. package/pp-core/workflows/vault-review.md +2 -0
  124. package/pp-core/workflows/verify-phase.md +2 -0
  125. package/pp-core/workflows/verify-work.md +2 -0
  126. package/scripts/affected-tests-lib.cjs +26 -12
  127. package/scripts/ci-release-only.cjs +158 -0
  128. package/scripts/live-runs/driver.cjs +326 -0
  129. package/scripts/live-runs/run.cjs +906 -0
  130. package/scripts/live-runs/scenarios.cjs +326 -0
  131. package/scripts/live-runs/scratch-project.cjs +132 -0
  132. package/scripts/live-runs/scrub.cjs +291 -0
  133. package/scripts/live-runs/turn-guard.cjs +107 -0
  134. package/scripts/live-runs/walk-setup.cjs +198 -0
  135. package/scripts/run-tests.cjs +151 -132
  136. package/scripts/test-env.cjs +64 -0
@@ -0,0 +1,291 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * scrub.cjs - Phase 44 REACH-02 evidence scrub (D-11, Pitfall 6).
5
+ *
6
+ * A raw transcript carries the operator's home path, the scratch project's temp
7
+ * path, the user and host names, the operator's git identity, secret env values,
8
+ * account utilisation (`rate_limit_event`), account-linked ids (session, uuid,
9
+ * request) and credential, socket and memory-path fields. Everything goes through
10
+ * here before it is written to a path git will commit.
11
+ *
12
+ * Secrets have one shape: { paths: [{ value, token }], words: [{ value, token }] }.
13
+ * A path is replaced wherever it appears (its JSON-escaped form too), a word only
14
+ * where it is not part of a longer word. The replacement is one pass, so a token is
15
+ * never scrubbed again. `hostSecrets` builds the shape from the real machine; tests
16
+ * pass planted values instead.
17
+ */
18
+
19
+ const { execFileSync } = require('node:child_process');
20
+ const fs = require('node:fs');
21
+ const os = require('node:os');
22
+ const path = require('node:path');
23
+
24
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
25
+
26
+ const DROP_EVENT_TYPES = Object.freeze(['rate_limit_event']);
27
+ const DROP_KEYS = Object.freeze(['credential_expires_at', 'messaging_socket_path', 'memory_paths']);
28
+ const REDACT_KEYS = Object.freeze(['signature', 'request_id']);
29
+
30
+ /**
31
+ * Account-linked ids that tie the evidence to the operator's account. Each distinct value
32
+ * becomes a stable placeholder (`<session-1>`, `<uuid-3>`), so links inside one transcript
33
+ * survive while the real ids do not.
34
+ */
35
+ const PSEUDONYM_KEYS = Object.freeze({ session_id: 'session', uuid: 'uuid' });
36
+
37
+ /** The throwaway identity the scratch repos commit under (driver.cjs GIT_IDENTITY): never a secret. */
38
+ const THROWAWAY_IDENTITY = Object.freeze(['Live Run', 'live-run@example.invalid']);
39
+
40
+ const MIN_SECRET_LENGTH = 3;
41
+ const REMOVED = '<removed>';
42
+
43
+ /** Env keys whose values are treated as secrets wherever they appear (a command's output included). */
44
+ const SECRET_ENV_KEY_RE = /TOKEN|SECRET|KEY|PASSWORD|PASSWD|AUTH|CREDENTIAL/i;
45
+ const MIN_ENV_SECRET_LENGTH = 8;
46
+
47
+ /** The values of secret-looking env keys, as path-style secrets (replaced wherever they appear). */
48
+ function envSecrets(env) {
49
+ const out = [];
50
+ for (const [key, value] of Object.entries(env || {})) {
51
+ if (!SECRET_ENV_KEY_RE.test(key) || typeof value !== 'string') continue;
52
+ const v = value.trim();
53
+ if (v.length >= MIN_ENV_SECRET_LENGTH) out.push({ value: v, token: '<secret>' });
54
+ }
55
+ return out;
56
+ }
57
+
58
+ function safeRealpath(p) {
59
+ try { return fs.realpathSync(p); } catch { return null; }
60
+ }
61
+
62
+ function quiet(command, args, { env, cwd } = {}) {
63
+ try {
64
+ return execFileSync(command, args, { env, cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10000 }).trim();
65
+ } catch {
66
+ return '';
67
+ }
68
+ }
69
+
70
+ /** `Name <email> 1700000000 +0100` -> { name, email }. */
71
+ function parseIdent(ident) {
72
+ const m = /^(.*?)\s*<([^>]*)>/.exec(String(ident || ''));
73
+ return m ? { name: m[1].trim(), email: m[2].trim() } : { name: '', email: '' };
74
+ }
75
+
76
+ /**
77
+ * Every git name and email the operator's commits could carry: the global config with
78
+ * its include/includeIf files, the effective config in the repository, and git's own
79
+ * idea of the author and committer (which falls back to the implicit user@host identity
80
+ * when nothing is configured).
81
+ */
82
+ function gitIdentities(env, repoRoot) {
83
+ const names = [];
84
+ const emails = [];
85
+ for (const cwd of [undefined, repoRoot]) {
86
+ const scope = cwd ? [] : ['--global'];
87
+ emails.push(quiet('git', ['config', ...scope, '--includes', '--get', 'user.email'], { env, cwd }));
88
+ names.push(quiet('git', ['config', ...scope, '--includes', '--get', 'user.name'], { env, cwd }));
89
+ }
90
+ for (const v of ['GIT_AUTHOR_IDENT', 'GIT_COMMITTER_IDENT']) {
91
+ const id = parseIdent(quiet('git', ['var', v], { env, cwd: repoRoot }));
92
+ names.push(id.name);
93
+ emails.push(id.email);
94
+ }
95
+ const keep = (list) => list.filter((v) => v && !THROWAWAY_IDENTITY.includes(v));
96
+ return { names: keep(names), emails: keep(emails) };
97
+ }
98
+
99
+ /** The host's names: os.hostname(), the fully qualified name, and the short form of each. */
100
+ function hostNames(env) {
101
+ const full = [os.hostname(), quiet('hostname', ['-f'], { env })].filter(Boolean);
102
+ return [...full, ...full.map((h) => h.split('.')[0])];
103
+ }
104
+
105
+ /** Longest first, no duplicates, nothing shorter than 3 characters. */
106
+ function tidy(entries) {
107
+ const seen = new Set();
108
+ const out = [];
109
+ for (const e of entries) {
110
+ if (typeof e.value !== 'string' || e.value.length < MIN_SECRET_LENGTH || seen.has(e.value)) continue;
111
+ seen.add(e.value);
112
+ out.push(e);
113
+ }
114
+ return out.sort((a, b) => b.value.length - a.value.length);
115
+ }
116
+
117
+ /**
118
+ * The secrets of this machine and this run: the operator's home, the scratch project,
119
+ * the person home, the temp root (each with its resolved real path), the values of
120
+ * secret-looking env keys (tokens, keys, passwords), the user and host names, and the
121
+ * global git email and name.
122
+ */
123
+ function hostSecrets({ projectDir, personHome, extraPaths = [], env = process.env, repoRoot = REPO_ROOT } = {}) {
124
+ const pathEntries = [];
125
+ const addPath = (value, token) => {
126
+ if (!value) return;
127
+ pathEntries.push({ value, token });
128
+ const real = safeRealpath(value);
129
+ if (real && real !== value) pathEntries.push({ value: real, token });
130
+ };
131
+ addPath(projectDir, '<project>');
132
+ addPath(personHome, '<person-home>');
133
+ addPath(os.tmpdir(), '<tmp>');
134
+ addPath(env.HOME || os.homedir(), '~');
135
+ for (const extra of extraPaths) addPath(extra, '<removed>');
136
+ pathEntries.push(...envSecrets(env));
137
+
138
+ const wordEntries = [];
139
+ let user = '';
140
+ try { user = os.userInfo().username; } catch { /* no passwd entry: the user name stays unknown */ }
141
+ wordEntries.push({ value: user, token: '<user>' });
142
+ const hosts = hostNames(env);
143
+ for (const host of hosts) wordEntries.push({ value: host, token: '<host>' });
144
+ const { names, emails } = gitIdentities(env, repoRoot);
145
+ const shortHosts = new Set(hosts.map((h) => h.split('.')[0].toLowerCase()).filter((h) => h.length >= MIN_SECRET_LENGTH));
146
+ for (const email of emails) {
147
+ wordEntries.push({ value: email, token: '<email>' });
148
+ // An implicit identity is user@host.domain: its domain names the machine too.
149
+ const domain = email.includes('@') ? email.slice(email.lastIndexOf('@') + 1) : '';
150
+ if (domain && shortHosts.has(domain.split('.')[0].toLowerCase())) wordEntries.push({ value: domain, token: '<host>' });
151
+ }
152
+ for (const name of names) wordEntries.push({ value: name, token: '<git-name>' });
153
+
154
+ return { paths: tidy(pathEntries), words: tidy(wordEntries) };
155
+ }
156
+
157
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
158
+ const jsonEscaped = (s) => JSON.stringify(s).slice(1, -1);
159
+
160
+ function compile(secrets) {
161
+ const paths = (secrets && secrets.paths) || [];
162
+ const words = (secrets && secrets.words) || [];
163
+ const tokenOf = new Map();
164
+ const alternatives = [];
165
+ for (const w of words) {
166
+ tokenOf.set(w.value.toLowerCase(), w.token);
167
+ alternatives.push({ length: w.value.length, source: `(?<![A-Za-z0-9_])${escapeRe(w.value)}(?![A-Za-z0-9_])` });
168
+ }
169
+ for (const p of paths) {
170
+ for (const form of new Set([p.value, jsonEscaped(p.value)])) {
171
+ tokenOf.set(form.toLowerCase(), p.token);
172
+ alternatives.push({ length: form.length, source: escapeRe(form) });
173
+ }
174
+ }
175
+ if (alternatives.length === 0) return null;
176
+ alternatives.sort((a, b) => b.length - a.length);
177
+ return { re: new RegExp(alternatives.map((a) => a.source).join('|'), 'gi'), tokenOf };
178
+ }
179
+
180
+ /** Replace every secret in a string with its token, in one pass. */
181
+ function scrubText(text, secrets) {
182
+ const compiled = compile(secrets);
183
+ if (!compiled) return String(text);
184
+ return String(text).replace(compiled.re, (m) => compiled.tokenOf.get(m.toLowerCase()) || REMOVED);
185
+ }
186
+
187
+ /** The stable placeholder for one account-linked id; `pseudo` maps kind -> (value -> placeholder). */
188
+ function pseudonym(pseudo, kind, value) {
189
+ if (!pseudo.has(kind)) pseudo.set(kind, new Map());
190
+ const seen = pseudo.get(kind);
191
+ if (!seen.has(value)) seen.set(value, `<${kind}-${seen.size + 1}>`);
192
+ return seen.get(value);
193
+ }
194
+
195
+ function scrubValue(value, compiled, pseudo) {
196
+ if (typeof value === 'string') return compiled ? value.replace(compiled.re, (m) => compiled.tokenOf.get(m.toLowerCase()) || REMOVED) : value;
197
+ if (Array.isArray(value)) return value.map((v) => scrubValue(v, compiled, pseudo));
198
+ if (value && typeof value === 'object') {
199
+ const out = {};
200
+ for (const [key, v] of Object.entries(value)) {
201
+ if (DROP_KEYS.includes(key)) continue;
202
+ const safeKey = scrubValue(key, compiled, pseudo);
203
+ if (REDACT_KEYS.includes(key) && v !== undefined && v !== null) out[safeKey] = REMOVED;
204
+ else if (Object.hasOwn(PSEUDONYM_KEYS, key) && typeof v === 'string' && v !== '') out[safeKey] = pseudonym(pseudo, PSEUDONYM_KEYS[key], v);
205
+ else out[safeKey] = scrubValue(v, compiled, pseudo);
206
+ }
207
+ return out;
208
+ }
209
+ return value;
210
+ }
211
+
212
+ /**
213
+ * A scrubbed deep copy of one event, or null for an event type that is dropped. The input
214
+ * is not changed. Pass the same `pseudo` map for every event of one transcript so an id
215
+ * gets the same placeholder throughout.
216
+ */
217
+ function scrubEvent(event, secrets, pseudo = new Map()) {
218
+ if (event && typeof event === 'object' && DROP_EVENT_TYPES.includes(event.type)) return null;
219
+ return scrubValue(event, compile(secrets), pseudo);
220
+ }
221
+
222
+ /** A session id shorter than this is not scrubbed where it is embedded in other text. */
223
+ const MIN_EMBEDDED_ID_LENGTH = 8;
224
+
225
+ /** Every non-empty string `session_id` in one event, in the order scrubValue meets them. */
226
+ function collectSessionIds(value, found) {
227
+ if (Array.isArray(value)) {
228
+ for (const v of value) collectSessionIds(v, found);
229
+ return;
230
+ }
231
+ if (!value || typeof value !== 'object') return;
232
+ for (const [key, v] of Object.entries(value)) {
233
+ if (DROP_KEYS.includes(key)) continue;
234
+ if (key === 'session_id' && typeof v === 'string' && v !== '') found.push(v);
235
+ else if (!REDACT_KEYS.includes(key) || v === undefined || v === null) collectSessionIds(v, found);
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Scrub a whole stream-json transcript line by line. A line that does not parse still goes
241
+ * through scrubText. The CLI also writes the session id into paths (tool results under
242
+ * `projects/<dir>/<session-id>/`, task output under `<tmp>/<dir>/<session-id>/tasks/`), so
243
+ * every session id is first collected and then replaced wherever it appears, with the same
244
+ * placeholder its `session_id` key gets.
245
+ */
246
+ function scrubJsonl(text, secrets) {
247
+ const out = [];
248
+ const pseudo = new Map();
249
+ const lines = String(text).split('\n').filter((raw) => raw.trim() !== '').map((raw) => {
250
+ try {
251
+ return { raw, ok: true, event: JSON.parse(raw) };
252
+ } catch {
253
+ return { raw, ok: false, event: undefined };
254
+ }
255
+ });
256
+ const sessionIds = [];
257
+ for (const { ok, event } of lines) {
258
+ const dropped = event && typeof event === 'object' && DROP_EVENT_TYPES.includes(event.type);
259
+ if (ok && !dropped) collectSessionIds(event, sessionIds);
260
+ }
261
+ const embedded = [];
262
+ for (const sid of sessionIds) {
263
+ const token = pseudonym(pseudo, PSEUDONYM_KEYS.session_id, sid);
264
+ if (sid.length >= MIN_EMBEDDED_ID_LENGTH) embedded.push({ value: sid, token });
265
+ }
266
+ const all = embedded.length === 0
267
+ ? secrets
268
+ : { paths: [...((secrets && secrets.paths) || []), ...embedded], words: (secrets && secrets.words) || [] };
269
+ for (const { raw, ok, event } of lines) {
270
+ if (!ok) {
271
+ out.push(scrubText(raw, all));
272
+ continue;
273
+ }
274
+ const clean = scrubEvent(event, all, pseudo);
275
+ if (clean !== null) out.push(JSON.stringify(clean));
276
+ }
277
+ return out.length === 0 ? '' : `${out.join('\n')}\n`;
278
+ }
279
+
280
+ module.exports = {
281
+ DROP_EVENT_TYPES,
282
+ DROP_KEYS,
283
+ REDACT_KEYS,
284
+ PSEUDONYM_KEYS,
285
+ SECRET_ENV_KEY_RE,
286
+ envSecrets,
287
+ hostSecrets,
288
+ scrubText,
289
+ scrubEvent,
290
+ scrubJsonl,
291
+ };
@@ -0,0 +1,107 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * turn-guard.cjs - Phase 44 process-group guard for one CLI turn (driver.cjs runTurn).
5
+ *
6
+ * `node turn-guard.cjs <wallMs> <graceMs> <command> [args...]` starts the command in a
7
+ * process group of its own and passes its stdout and stderr through. At wallMs it sends
8
+ * SIGTERM to the whole group, at wallMs + graceMs SIGKILL. When the command exits, what
9
+ * is left of its group (background shells, hook processes) is killed too, so nothing
10
+ * from a turn keeps running while the next attempt starts or holds the output pipe open.
11
+ *
12
+ * The command's group is not the terminal's foreground group, so Ctrl-C (SIGINT) and a
13
+ * SIGTERM or SIGHUP aimed at the harness would not reach it. The guard therefore
14
+ * forwards those signals: it sends SIGTERM to the group, and SIGKILL after a short
15
+ * grace (or at once on a second signal). It also checks every second that its parent
16
+ * is still alive. When the parent is gone (for example a SIGKILL of run.cjs), it kills
17
+ * the group, because nobody is left to read the turn's output or stop it.
18
+ *
19
+ * On fd 3 it writes one JSON line for the driver: { status, signal, timedOut, spawnError }.
20
+ * spawnError is 'interrupted' after a forwarded signal and 'parent_gone' when the parent
21
+ * died. The spawn is an argv array with no shell. On Windows there are no process
22
+ * groups, so only the command itself is killed.
23
+ */
24
+
25
+ const { spawn } = require('node:child_process');
26
+ const fs = require('node:fs');
27
+ const os = require('node:os');
28
+
29
+ const STATUS_FD = 3;
30
+ const IS_WINDOWS = process.platform === 'win32';
31
+ const FORWARDED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
32
+ const INTERRUPT_GRACE_MS = 2000;
33
+ const PARENT_POLL_MS = 1000;
34
+
35
+ const [wallRaw, graceRaw, command, ...args] = process.argv.slice(2);
36
+ const wallMs = Number(wallRaw);
37
+ const graceMs = Number(graceRaw);
38
+
39
+ let child = null;
40
+ let timedOut = false;
41
+ let interrupted = null;
42
+ let done = false;
43
+ let parentWatch = null;
44
+ const timers = [];
45
+
46
+ function killGroup(signal) {
47
+ if (!child || child.pid === undefined) return;
48
+ try {
49
+ if (IS_WINDOWS) child.kill(signal);
50
+ else process.kill(-child.pid, signal);
51
+ } catch {
52
+ // the group is already gone
53
+ }
54
+ }
55
+
56
+ function finish(report) {
57
+ if (done) return;
58
+ done = true;
59
+ for (const t of timers) clearTimeout(t);
60
+ if (parentWatch) clearInterval(parentWatch);
61
+ killGroup('SIGKILL');
62
+ try {
63
+ fs.writeSync(STATUS_FD, `${JSON.stringify(report)}\n`);
64
+ } catch {
65
+ // no status channel: the driver falls back to this process's own exit
66
+ }
67
+ // The timers are cleared and the child is gone, so the guard ends as soon as this returns.
68
+ // After a forwarded signal it exits the way the shell reports a signal death (128 + n).
69
+ const signum = interrupted && os.constants.signals[interrupted];
70
+ if (signum) process.exitCode = 128 + signum;
71
+ else process.exitCode = Number.isInteger(report.status) ? report.status : 1;
72
+ }
73
+
74
+ /** Ctrl-C, SIGTERM or SIGHUP to the guard: stop the group, which the terminal cannot reach. */
75
+ function onSignal(sig) {
76
+ if (done) return;
77
+ if (interrupted) {
78
+ // a second signal: do not wait out the grace
79
+ finish({ status: null, signal: sig, timedOut, spawnError: 'interrupted' });
80
+ return;
81
+ }
82
+ interrupted = sig;
83
+ killGroup('SIGTERM');
84
+ const graceLeft = Math.min(graceMs, INTERRUPT_GRACE_MS);
85
+ timers.push(setTimeout(() => finish({ status: null, signal: sig, timedOut, spawnError: 'interrupted' }), graceLeft));
86
+ }
87
+
88
+ if (!command || !Number.isFinite(wallMs) || !Number.isFinite(graceMs)) {
89
+ process.stderr.write('turn-guard: usage: turn-guard.cjs <wallMs> <graceMs> <command> [args...]\n');
90
+ finish({ status: 2, signal: null, timedOut: false, spawnError: 'usage' });
91
+ } else {
92
+ const parentPid = process.ppid;
93
+ for (const sig of FORWARDED_SIGNALS) process.on(sig, onSignal);
94
+ child = spawn(command, args, { stdio: ['ignore', 'inherit', 'inherit'], detached: !IS_WINDOWS });
95
+ child.on('error', (err) => finish({ status: null, signal: null, timedOut, spawnError: err.code || err.message }));
96
+ child.on('exit', (status, signal) => finish({ status, signal, timedOut, spawnError: interrupted ? 'interrupted' : null }));
97
+ timers.push(setTimeout(() => {
98
+ timedOut = true;
99
+ killGroup('SIGTERM');
100
+ }, wallMs));
101
+ timers.push(setTimeout(() => killGroup('SIGKILL'), wallMs + graceMs));
102
+ // The child's handle keeps the guard alive; this poll must not.
103
+ parentWatch = setInterval(() => {
104
+ if (process.ppid !== parentPid) finish({ status: null, signal: null, timedOut, spawnError: 'parent_gone' });
105
+ }, PARENT_POLL_MS);
106
+ parentWatch.unref();
107
+ }
@@ -0,0 +1,198 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * walk-setup.cjs - Phase 44 PROOF-02 walk project setup (D-13, D-15).
5
+ *
6
+ * `node scripts/live-runs/walk-setup.cjs --walk business|engineer [--first-run]`
7
+ * builds a walk's throwaway project from the same build and the same fixtures as
8
+ * the live runs (scripts/live-runs/scratch-project.cjs and scenarios.cjs), saves
9
+ * the walk's audience in a temp person home, and prints the exact lines that start
10
+ * the walk, including PP_AUDIENCE and GSD_HOME.
11
+ *
12
+ * It never calls a model. It never adds a remote: the engineer walk reaches
13
+ * /pp-ship, and the only repository that walk may push to is a throwaway sandbox
14
+ * the facilitator makes by hand (D-15). The two folders it makes are kept for the
15
+ * walk and deleted by the facilitator afterwards.
16
+ *
17
+ * Flags: --walk business|engineer (required), --first-run (no audience saved, no
18
+ * PP_AUDIENCE line: the Phase 39 first-run moment), --dir <path> (missing or
19
+ * empty), --json, --help. Exit codes: 0 ok, 1 setup failed, 2 usage.
20
+ */
21
+
22
+ const { execFileSync } = require('node:child_process');
23
+ const fs = require('node:fs');
24
+ const path = require('node:path');
25
+
26
+ const { REPO_ROOT, createScratchProject } = require('./scratch-project.cjs');
27
+ const { SCENARIOS } = require('./scenarios.cjs');
28
+ const { ExitError, runMain } = require('../lib/cli-exit.cjs');
29
+
30
+ const EXIT = Object.freeze({ OK: 0, FAILED: 1, USAGE: 2 });
31
+
32
+ const ENGINEER_README = 'Sandbox project for a pp-core walk. It is thrown away afterwards and is never pushed anywhere except a throwaway sandbox repository.\n';
33
+
34
+ function engineerFixture(projectDir) {
35
+ fs.writeFileSync(path.join(projectDir, 'README.md'), ENGINEER_README);
36
+ }
37
+
38
+ const WALKS = Object.freeze({
39
+ business: Object.freeze({
40
+ id: 'business',
41
+ audience: 'business',
42
+ fixture: SCENARIOS.s1.fixture,
43
+ initialCommit: false,
44
+ commands: Object.freeze(['pp-help', 'pp-progress', 'pp-resume-work', 'pp-discovery']),
45
+ }),
46
+ engineer: Object.freeze({
47
+ id: 'engineer',
48
+ audience: 'engineer',
49
+ fixture: engineerFixture,
50
+ initialCommit: true,
51
+ commands: Object.freeze(['pp-init', 'pp-new-project', 'pp-plan-phase', 'pp-execute-phase', 'pp-verify-work', 'pp-ship']),
52
+ }),
53
+ });
54
+
55
+ const USAGE_TEXT = [
56
+ 'usage: node scripts/live-runs/walk-setup.cjs --walk business|engineer [--first-run] [--dir PATH] [--json]',
57
+ '',
58
+ 'options:',
59
+ ' --walk business|engineer which walk to set up (required)',
60
+ ' --first-run save no audience and print no PP_AUDIENCE line (the first-run moment)',
61
+ ' --dir PATH build the project here (the folder must be missing or empty)',
62
+ ' --json print the result as JSON',
63
+ ' --help show this text',
64
+ '',
65
+ 'exit codes: 0 ok, 1 setup failed, 2 usage',
66
+ ].join('\n');
67
+
68
+ function usageError(message) {
69
+ return new ExitError(EXIT.USAGE, message);
70
+ }
71
+
72
+ /** Fail-closed argument parsing: an unknown flag, a missing value or an unknown walk is a usage error. */
73
+ function parseArgs(argv) {
74
+ const opts = { walk: null, firstRun: false, dir: null, json: false, help: false };
75
+ for (let i = 0; i < argv.length; i++) {
76
+ const flag = argv[i];
77
+ if (flag === '--walk' || flag === '--dir') {
78
+ const value = argv[i + 1];
79
+ if (value === undefined || value.startsWith('--') || value === '') throw usageError(`${flag} needs a value`);
80
+ if (flag === '--walk') opts.walk = value;
81
+ // Absolute, so the printed `cd` works from the new terminal window the walk starts in.
82
+ else opts.dir = path.resolve(value);
83
+ i++;
84
+ } else if (flag === '--first-run') {
85
+ opts.firstRun = true;
86
+ } else if (flag === '--json') {
87
+ opts.json = true;
88
+ } else if (flag === '--help') {
89
+ opts.help = true;
90
+ } else {
91
+ throw usageError(`unknown option: ${flag}`);
92
+ }
93
+ }
94
+ if (opts.help) return opts;
95
+ if (opts.walk === null) throw usageError('--walk is required (business or engineer)');
96
+ if (!Object.prototype.hasOwnProperty.call(WALKS, opts.walk)) {
97
+ throw usageError(`unknown walk: ${opts.walk} (use business or engineer)`);
98
+ }
99
+ return opts;
100
+ }
101
+
102
+ /** Single-quote a value for a shell line; a single quote inside it is written as '\'' . */
103
+ const shellQuote = (value) => `'${String(value).replace(/'/g, "'\\''")}'`;
104
+
105
+ function buildCommit() {
106
+ return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: REPO_ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
107
+ }
108
+
109
+ /**
110
+ * Build one walk's throwaway project and return what the facilitator needs.
111
+ * The folders are kept: nothing here cleans them up.
112
+ */
113
+ function setupWalk({ walk, firstRun = false, dir, repoRoot = REPO_ROOT } = {}) {
114
+ const def = Object.prototype.hasOwnProperty.call(WALKS, walk) ? WALKS[walk] : null;
115
+ if (!def) throw new Error(`setupWalk: unknown walk ${JSON.stringify(walk)}`);
116
+ const audience = firstRun ? null : def.audience;
117
+ const project = createScratchProject({
118
+ label: `walk-${def.id}`,
119
+ audience,
120
+ fixture: def.fixture,
121
+ initialCommit: def.initialCommit,
122
+ commands: [...def.commands],
123
+ repoRoot,
124
+ dir,
125
+ });
126
+ const start = [`cd ${shellQuote(project.projectDir)}`, `export GSD_HOME=${shellQuote(project.personHome)}`];
127
+ if (audience) start.push(`export PP_AUDIENCE=${audience}`);
128
+ start.push('claude --setting-sources project,local --strict-mcp-config');
129
+ return {
130
+ walk: def.id,
131
+ projectDir: project.projectDir,
132
+ personHome: project.personHome,
133
+ audience,
134
+ firstRun,
135
+ buildCommit: buildCommit(),
136
+ start,
137
+ };
138
+ }
139
+
140
+ function defaultIo() {
141
+ return {
142
+ out: (text) => process.stdout.write(`${text}\n`),
143
+ err: (text) => process.stderr.write(`${text}\n`),
144
+ };
145
+ }
146
+
147
+ function report(result) {
148
+ const lines = [
149
+ `Walk: ${result.walk}${result.firstRun ? ' (first run: no audience saved)' : ''}`,
150
+ `Project folder: ${result.projectDir}`,
151
+ `Person-home folder: ${result.personHome}`,
152
+ '',
153
+ 'Start the walk in a normal terminal window:',
154
+ ...result.start.map((line) => ` ${line}`),
155
+ '',
156
+ `Build commit: ${result.buildCommit}`,
157
+ 'After the walk, delete both folders.',
158
+ ];
159
+ return lines.join('\n');
160
+ }
161
+
162
+ function main(argv = process.argv.slice(2), env = process.env, io = {}) {
163
+ void env;
164
+ const writers = { ...defaultIo(), ...io };
165
+ let opts;
166
+ try {
167
+ opts = parseArgs(argv);
168
+ } catch (err) {
169
+ if (!(err instanceof ExitError)) throw err;
170
+ writers.err(err.message);
171
+ writers.err(USAGE_TEXT);
172
+ return err.code;
173
+ }
174
+ if (opts.help) {
175
+ writers.out(USAGE_TEXT);
176
+ return EXIT.OK;
177
+ }
178
+ if (opts.dir && fs.existsSync(opts.dir)) {
179
+ const stat = fs.statSync(opts.dir);
180
+ if (!stat.isDirectory() || fs.readdirSync(opts.dir).length > 0) {
181
+ writers.err(`--dir ${opts.dir} must be a missing or empty folder; nothing was changed`);
182
+ return EXIT.USAGE;
183
+ }
184
+ }
185
+ let result;
186
+ try {
187
+ result = setupWalk({ walk: opts.walk, firstRun: opts.firstRun, dir: opts.dir || undefined });
188
+ } catch (err) {
189
+ writers.err(`walk setup failed: ${String(err && err.message ? err.message : err).split('\n')[0]}`);
190
+ return EXIT.FAILED;
191
+ }
192
+ writers.out(opts.json ? JSON.stringify(result, null, 2) : report(result));
193
+ return EXIT.OK;
194
+ }
195
+
196
+ module.exports = { WALKS, parseArgs, main, setupWalk };
197
+
198
+ if (require.main === module) runMain(() => main(process.argv.slice(2), process.env));