@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,271 @@
1
+ "use strict";
2
+ /**
3
+ * `pp-tools audience status|set|answer|fields`: the CLI adapter over the per-person
4
+ * audience setting (Phase 39: AUD-01, AUD-04; D-12, D-14; `fields` is Phase 40 CR-01).
5
+ *
6
+ * ADR-457 build-at-publish: this TypeScript file is the SOURCE OF TRUTH. It
7
+ * compiles to the gitignored `pp-core/bin/lib/audience-command.cjs`; do NOT
8
+ * hand-edit the compiled output.
9
+ *
10
+ * The person file is read and written only through `./audience.cjs`. Nothing
11
+ * here touches `.planning/config.json`, `~/.gsd/defaults.json` or any
12
+ * discovery file (D-01, D-13). `status` prints short human lines by default and
13
+ * one JSON object with `--raw`; workflows always call it with `--raw`.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.buildAudienceStatus = buildAudienceStatus;
17
+ exports.cmdAudience = cmdAudience;
18
+ const audience_cjs_1 = require("./audience.cjs");
19
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
20
+ const io = require("./io.cjs");
21
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
22
+ const configLoader = require("./config-loader.cjs");
23
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- init.cjs is an export= CommonJS module
24
+ const initMod = require("./init.cjs");
25
+ const { output, ERROR_REASON } = io;
26
+ /** Explicitly typed so TypeScript narrows after a failing guard (the destructured `io.error` does not). */
27
+ function error(message, reason) {
28
+ return io.error(message, reason);
29
+ }
30
+ const AUDIENCE_LIST = audience_cjs_1.AUDIENCES.join(', ');
31
+ const LANGUAGE_LIST = audience_cjs_1.LANGUAGES.join(', ');
32
+ const SET_USAGE = 'Usage: pp-tools audience set <business|engineer|developer> [--language en | --clear-language], ' +
33
+ 'or pp-tools audience set --clear-language (clears only the language)';
34
+ const CHANGE_LINE = 'change: pp-tools audience set <business|engineer|developer> [--language en | --clear-language]';
35
+ /** Cut to 40 characters and strip control characters before a value enters a message. */
36
+ function clip(v) {
37
+ return String(v).replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, 40);
38
+ }
39
+ /** The project's `response_language`, or null when there is none (or no project here). */
40
+ function projectResponseLanguage(cwd) {
41
+ try {
42
+ const rl = configLoader.loadConfig(cwd).response_language;
43
+ if (typeof rl === 'string' && rl.trim() !== '')
44
+ return rl;
45
+ }
46
+ catch {
47
+ // no project here: the project tier is simply absent
48
+ }
49
+ return null;
50
+ }
51
+ function buildAudienceStatus(cwd, env = process.env) {
52
+ const person = (0, audience_cjs_1.readPerson)(env);
53
+ const aud = (0, audience_cjs_1.resolveAudience)(env, person);
54
+ const projectLanguage = projectResponseLanguage(cwd);
55
+ const lang = (0, audience_cjs_1.resolveLanguage)({ env, person, projectResponseLanguage: projectLanguage });
56
+ const choice = (0, audience_cjs_1.needsChoice)(env, person);
57
+ const status = {
58
+ audience: aud.audience,
59
+ audience_source: aud.source,
60
+ language: lang.code,
61
+ language_name: lang.name,
62
+ language_source: lang.source,
63
+ person_file: (0, audience_cjs_1.personFilePath)(env),
64
+ person_file_exists: person.exists,
65
+ needs_choice: choice,
66
+ warnings: aud.warnings,
67
+ };
68
+ if (choice)
69
+ status.prompt = audience_cjs_1.FIRST_RUN_PROMPT;
70
+ return status;
71
+ }
72
+ const AUDIENCE_LABEL = {
73
+ environment: 'from PP_AUDIENCE',
74
+ person_file: 'from person file',
75
+ default: 'default — nothing set',
76
+ };
77
+ const LANGUAGE_LABEL = {
78
+ person_file: 'from person file',
79
+ audience: 'from audience',
80
+ project: 'from project response_language',
81
+ };
82
+ function languageLine(s) {
83
+ if (s.language_source === 'none')
84
+ return 'language: not set';
85
+ return `language: ${String(s.language_name)} (${s.language ?? '-'}, ${LANGUAGE_LABEL[s.language_source]})`;
86
+ }
87
+ function humanStatus(s) {
88
+ return [
89
+ `audience: ${s.audience} (${AUDIENCE_LABEL[s.audience_source]})`,
90
+ languageLine(s),
91
+ `file: ${s.person_file} (${s.person_file_exists ? 'present' : 'absent'})`,
92
+ CHANGE_LINE,
93
+ ].join('\n');
94
+ }
95
+ function warn(text) {
96
+ process.stderr.write(`pp-tools: warning: ${text}\n`);
97
+ }
98
+ function cmdStatus(cwd, raw) {
99
+ const status = buildAudienceStatus(cwd);
100
+ for (const w of status.warnings)
101
+ warn(w);
102
+ if (raw)
103
+ output(status, false);
104
+ else
105
+ output(status, true, humanStatus(status));
106
+ }
107
+ /**
108
+ * Phase 40 CR-01: the audience fields an `init.*` payload carries, before any init call. A
109
+ * business user or business engineer gets one JSON object (`audience`, `audience_ref`,
110
+ * `response_language`) built by the same code as the payload; a developer gets no output at
111
+ * all, so a developer run loads nothing new (D-07). Workflows that ask the person something
112
+ * before their first init call run this first. Review iteration 2, WR-02: extra arguments are
113
+ * refused with a usage error, so a malformed call exits non-zero instead of looking like a
114
+ * developer run.
115
+ */
116
+ function cmdFields(cwd, args) {
117
+ if (args.length > 0)
118
+ error('Usage: pp-tools audience fields (takes no arguments)', ERROR_REASON.USAGE);
119
+ const fields = initMod.audienceFields(projectResponseLanguage(cwd));
120
+ if (Object.keys(fields).length > 0)
121
+ output(fields, false);
122
+ }
123
+ /**
124
+ * Validate-then-write shared by `set` and `answer`; everything is checked before
125
+ * anything is written. `language` undefined keeps the stored language (CR-01).
126
+ */
127
+ function save(cwd, audience, language, raw) {
128
+ const result = (0, audience_cjs_1.writePerson)(language === undefined ? { audience } : { audience, language });
129
+ if (!result.ok) {
130
+ error(`Could not save ${result.path}: ${result.error}`, ERROR_REASON.AUDIENCE_WRITE_FAILED);
131
+ }
132
+ const envOverride = (process.env['PP_AUDIENCE'] || '').trim().toLowerCase();
133
+ if ((0, audience_cjs_1.isAudience)(envOverride))
134
+ warn(`PP_AUDIENCE=${envOverride} overrides the saved value in this shell`);
135
+ const status = buildAudienceStatus(cwd);
136
+ for (const w of status.warnings)
137
+ warn(w);
138
+ if (raw) {
139
+ output({ ...status, saved: true }, false);
140
+ return;
141
+ }
142
+ const langText = status.language_source === 'person_file' ? `${status.language ?? '-'}` : 'not set';
143
+ output({ ...status, saved: true }, true, `Saved: audience ${audience} (language: ${langText}) in ${result.path}\n${CHANGE_LINE}`);
144
+ }
145
+ /**
146
+ * WR-11: `set --clear-language` without an audience removes only the stored
147
+ * language. The stored audience is never read back and re-saved, so a
148
+ * `PP_AUDIENCE` override (D-12) or a default audience cannot leak into the file.
149
+ */
150
+ function clearLanguageOnly(cwd, raw) {
151
+ const result = (0, audience_cjs_1.writePerson)({ language: null });
152
+ if (!result.ok) {
153
+ error(`Could not save ${result.path}: ${result.error}`, ERROR_REASON.AUDIENCE_WRITE_FAILED);
154
+ }
155
+ const status = buildAudienceStatus(cwd);
156
+ for (const w of status.warnings)
157
+ warn(w);
158
+ const payload = { ...status, saved: result.written };
159
+ if (raw) {
160
+ output(payload, false);
161
+ return;
162
+ }
163
+ const text = result.written
164
+ ? `Cleared the language in ${result.path}; the audience decides it again (audience unchanged).`
165
+ : `No language is saved in ${result.path}; nothing changed.`;
166
+ output(payload, true, `${text}\n${CHANGE_LINE}`);
167
+ }
168
+ function cmdSet(cwd, args, raw) {
169
+ let value = null;
170
+ let language = null;
171
+ let languageGiven = false;
172
+ let clearLanguage = false;
173
+ for (let i = 0; i < args.length; i++) {
174
+ const a = String(args[i]);
175
+ if (a === '--clear-language') {
176
+ clearLanguage = true;
177
+ }
178
+ else if (a === '--language') {
179
+ languageGiven = true;
180
+ const next = args[i + 1];
181
+ if (next === undefined || next.startsWith('--'))
182
+ error(SET_USAGE, ERROR_REASON.USAGE);
183
+ language = String(next);
184
+ i += 1;
185
+ }
186
+ else if (a.startsWith('--language=')) {
187
+ languageGiven = true;
188
+ language = a.slice('--language='.length);
189
+ }
190
+ else if (value === null) {
191
+ value = a;
192
+ }
193
+ else {
194
+ error(SET_USAGE, ERROR_REASON.USAGE);
195
+ }
196
+ }
197
+ if (value === null) {
198
+ if (clearLanguage && !languageGiven) {
199
+ clearLanguageOnly(cwd, raw);
200
+ return;
201
+ }
202
+ error(SET_USAGE, ERROR_REASON.USAGE);
203
+ }
204
+ const audience = value.trim().toLowerCase();
205
+ if (!(0, audience_cjs_1.isAudience)(audience)) {
206
+ error(`Unknown audience "${clip(value)}". Use one of: ${AUDIENCE_LIST}.`, ERROR_REASON.USAGE);
207
+ }
208
+ if (languageGiven && clearLanguage)
209
+ error(SET_USAGE, ERROR_REASON.USAGE);
210
+ // D-10 / CR-01: without --language the stored language is kept (undefined);
211
+ // --clear-language removes it so the audience decides again.
212
+ let lang = clearLanguage ? null : undefined;
213
+ if (languageGiven) {
214
+ const l = (language ?? '').trim().toLowerCase();
215
+ if (l === '')
216
+ error(SET_USAGE, ERROR_REASON.USAGE);
217
+ if (!(0, audience_cjs_1.isLanguage)(l)) {
218
+ error(`Unknown language "${clip(language)}". Use one of: ${LANGUAGE_LIST}.`, ERROR_REASON.USAGE);
219
+ }
220
+ lang = l;
221
+ }
222
+ save(cwd, audience, lang, raw);
223
+ }
224
+ /**
225
+ * The first-run relay seam (D-06). It only ever fills an empty setting: a chat
226
+ * relay cannot send Enter on purpose, so a missing or blank reply is refused
227
+ * rather than read as `developer` (Enter-means-developer lives only in the
228
+ * installer's TTY prompt), and an existing valid audience is never overwritten
229
+ * (WR-01). Changing a saved audience is `audience set`.
230
+ */
231
+ function cmdAnswer(cwd, args, raw) {
232
+ if (args.length === 0)
233
+ error('Usage: pp-tools audience answer <reply>', ERROR_REASON.USAGE);
234
+ const person = (0, audience_cjs_1.readPerson)();
235
+ if (person.audience !== null) {
236
+ error(`An audience is already saved (${person.audience}). Change it with: pp-tools audience set <${audience_cjs_1.AUDIENCES.join('|')}>`, ERROR_REASON.USAGE);
237
+ }
238
+ // WR-10: join every argv word, so an unquoted "Business engineer" is read
239
+ // whole and quoting by the relay is not load-bearing.
240
+ const reply = args.map(String).join(' ');
241
+ if (reply.trim() === '')
242
+ error(`Answer 1, 2 or 3 (${AUDIENCE_LIST}).`, ERROR_REASON.USAGE);
243
+ const audience = (0, audience_cjs_1.parseAudienceInput)(reply);
244
+ if (audience === null) {
245
+ error(`Not recognised: "${clip(reply)}". Answer 1, 2 or 3 (${AUDIENCE_LIST}).`, ERROR_REASON.USAGE);
246
+ }
247
+ save(cwd, audience, undefined, raw);
248
+ }
249
+ function cmdAudience(cwd, args, raw) {
250
+ const sub = args[0];
251
+ const rest = args.slice(1);
252
+ switch (sub) {
253
+ case 'status':
254
+ cmdStatus(cwd, raw);
255
+ return;
256
+ case 'set':
257
+ cmdSet(cwd, rest, raw);
258
+ return;
259
+ case 'answer':
260
+ cmdAnswer(cwd, rest, raw);
261
+ return;
262
+ case 'fields':
263
+ cmdFields(cwd, rest);
264
+ return;
265
+ case undefined:
266
+ error('Usage: pp-tools audience <status|set|answer|fields> ...', ERROR_REASON.USAGE);
267
+ return;
268
+ default:
269
+ error(`Unknown audience subcommand: ${clip(sub)}. Available: status, set, answer, fields`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
270
+ }
271
+ }
@@ -0,0 +1,293 @@
1
+ "use strict";
2
+ /**
3
+ * Per-person audience setting (Phase 39: AUD-01, AUD-04).
4
+ *
5
+ * ADR-457 build-at-publish: this TypeScript file is the SOURCE OF TRUTH. It
6
+ * compiles to the gitignored `pp-core/bin/lib/audience.cjs`; do NOT hand-edit
7
+ * the compiled output.
8
+ *
9
+ * This leaf is the ONLY reader and writer of the person file
10
+ * (`~/.gsd/person.json`, or under `GSD_HOME` when set; D-01). It imports Node
11
+ * built-ins only, so `io.cts` and the hooks can require it later without an
12
+ * import cycle. The locale (`LANG`, `LC_*`) is never read (D-02): a Dutch
13
+ * laptop must not silently change output for current users. The person file
14
+ * never enters `.planning/config.json` or `~/.gsd/defaults.json` (D-01).
15
+ *
16
+ * Audience order: `PP_AUDIENCE` env, then the person file, then `developer`.
17
+ * Language order (D-04): the person's explicit language, then the language the
18
+ * audience implies (business en, engineer en, developer none), then the
19
+ * project's `response_language`. English only since 2026-11-13 (operator: Dutch
20
+ * dropped from v1.23.0); the list shape allows adding a language later.
21
+ *
22
+ * Nothing here throws. Readers return a value plus `warnings`; the CLI adapter
23
+ * prints them.
24
+ */
25
+ var __importDefault = (this && this.__importDefault) || function (mod) {
26
+ return (mod && mod.__esModule) ? mod : { "default": mod };
27
+ };
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.FIRST_RUN_PROMPT = exports.PERSON_FILE_MAX_BYTES = exports.PERSON_FILE_VERSION = exports.LANGUAGE_NAMES = exports.IMPLIED_LANGUAGE = exports.LANGUAGES = exports.AUDIENCES = void 0;
30
+ exports.isAudience = isAudience;
31
+ exports.isLanguage = isLanguage;
32
+ exports.personFilePath = personFilePath;
33
+ exports.readPerson = readPerson;
34
+ exports.writePerson = writePerson;
35
+ exports.resolveAudience = resolveAudience;
36
+ exports.resolveLanguage = resolveLanguage;
37
+ exports.needsChoice = needsChoice;
38
+ exports.parseAudienceInput = parseAudienceInput;
39
+ const node_fs_1 = __importDefault(require("node:fs"));
40
+ const node_os_1 = __importDefault(require("node:os"));
41
+ const node_path_1 = __importDefault(require("node:path"));
42
+ exports.AUDIENCES = ['business', 'engineer', 'developer'];
43
+ exports.LANGUAGES = ['en'];
44
+ /** The language each audience implies; `developer` implies none (D-04). */
45
+ exports.IMPLIED_LANGUAGE = {
46
+ business: 'en',
47
+ engineer: 'en',
48
+ developer: null,
49
+ };
50
+ exports.LANGUAGE_NAMES = { en: 'English' };
51
+ exports.PERSON_FILE_VERSION = 1;
52
+ exports.PERSON_FILE_MAX_BYTES = 4096;
53
+ /**
54
+ * The first-run question (D-06, D-09): plain English (Dutch dropped 2026-11-13).
55
+ * Rendered from this single constant by the installer and (via
56
+ * `audience status --raw`) /pp-init and /pp-discovery. /pp-settings asks it as
57
+ * an AskUserQuestion with these same labels and descriptions, pinned by
58
+ * tests/feat-2527-settings-layers.test.cjs (WR-06).
59
+ */
60
+ exports.FIRST_RUN_PROMPT = [
61
+ 'Who are you?',
62
+ 'This decides how pp-core talks to you.',
63
+ '',
64
+ ' 1) Business user',
65
+ ' Plain words, no technical terms.',
66
+ ' 2) Business engineer',
67
+ ' Plain English; you know the technology.',
68
+ ' 3) Developer',
69
+ ' As today.',
70
+ '',
71
+ 'Choose 1, 2 or 3 [3]:',
72
+ ].join('\n') + '\n';
73
+ const AUDIENCE_LIST = exports.AUDIENCES.join(', ');
74
+ const LANGUAGE_LIST = exports.LANGUAGES.join(', ');
75
+ function isAudience(v) {
76
+ return typeof v === 'string' && exports.AUDIENCES.includes(v);
77
+ }
78
+ function isLanguage(v) {
79
+ return typeof v === 'string' && exports.LANGUAGES.includes(v);
80
+ }
81
+ /** Cut to 40 characters and strip control characters before a value enters a message. */
82
+ function clip(v) {
83
+ return String(v).replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, 40);
84
+ }
85
+ function norm(v) {
86
+ return typeof v === 'string' ? v.trim().toLowerCase() : '';
87
+ }
88
+ /** Same root rule as config-loader's `_globalDefaultsPath`, so GSD_HOME redirects both. */
89
+ function personFilePath(env = process.env) {
90
+ return node_path_1.default.join(env['GSD_HOME'] || node_os_1.default.homedir(), '.gsd', 'person.json');
91
+ }
92
+ const POLLUTING_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
93
+ /** Parse the stored file tolerantly: any failure is reported as a warning, never thrown. */
94
+ function loadStored(file) {
95
+ let size;
96
+ try {
97
+ size = node_fs_1.default.statSync(file).size;
98
+ }
99
+ catch {
100
+ return { obj: null, exists: false, warning: null };
101
+ }
102
+ if (size > exports.PERSON_FILE_MAX_BYTES) {
103
+ return { obj: null, exists: true, warning: `${file}: larger than ${exports.PERSON_FILE_MAX_BYTES} bytes — ignored` };
104
+ }
105
+ let parsed;
106
+ try {
107
+ parsed = JSON.parse(node_fs_1.default.readFileSync(file, 'utf8'));
108
+ }
109
+ catch {
110
+ return { obj: null, exists: true, warning: `${file}: not valid JSON — ignored` };
111
+ }
112
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
113
+ return { obj: null, exists: true, warning: `${file}: not a JSON object — ignored` };
114
+ }
115
+ return { obj: parsed, exists: true, warning: null };
116
+ }
117
+ function readPerson(env = process.env) {
118
+ const person = { audience: null, language: null, exists: false, warnings: [] };
119
+ try {
120
+ const file = personFilePath(env);
121
+ const stored = loadStored(file);
122
+ person.exists = stored.exists;
123
+ if (stored.warning)
124
+ person.warnings.push(stored.warning);
125
+ const obj = stored.obj;
126
+ if (!obj)
127
+ return person;
128
+ const own = (k) => Object.prototype.hasOwnProperty.call(obj, k);
129
+ if (own('audience')) {
130
+ const a = norm(obj['audience']);
131
+ if (isAudience(a))
132
+ person.audience = a;
133
+ else
134
+ person.warnings.push(`${file}: audience "${clip(obj['audience'])}" is not one of ${AUDIENCE_LIST} — ignored`);
135
+ }
136
+ if (own('language')) {
137
+ const l = norm(obj['language']);
138
+ if (isLanguage(l))
139
+ person.language = l;
140
+ else
141
+ person.warnings.push(`${file}: language "${clip(obj['language'])}" is not one of ${LANGUAGE_LIST} — ignored`);
142
+ }
143
+ }
144
+ catch (e) {
145
+ person.warnings.push(`person file could not be read: ${clip(e.message)}`);
146
+ }
147
+ return person;
148
+ }
149
+ /**
150
+ * Save the audience. `language` is three-state (D-10, CR-01): a value sets the
151
+ * override, `null` removes it, and an omitted/`undefined` language keeps
152
+ * whatever is stored, so callers that never ask about language cannot erase it.
153
+ *
154
+ * WR-11: `{ language: null }` without an audience clears only the language and
155
+ * leaves the stored `audience` key exactly as it is, so a hint that clears the
156
+ * language never has to name (and so never overwrites) an audience. When there
157
+ * is no stored `language` key to remove, nothing is written (`written: false`):
158
+ * a language clear never creates a person file or rewrites one it cannot parse.
159
+ */
160
+ function writePerson(update, env = process.env) {
161
+ const file = personFilePath(env);
162
+ let tmp = null;
163
+ try {
164
+ // Key-preserving read-modify-write: a whole-file rewrite that drops keys is
165
+ // the defect that ruled out defaults.json (D-01).
166
+ const next = {};
167
+ const stored = loadStored(file).obj;
168
+ if (update.audience === undefined && !(stored && Object.prototype.hasOwnProperty.call(stored, 'language'))) {
169
+ return { ok: true, path: file, written: false };
170
+ }
171
+ const dir = node_path_1.default.dirname(file);
172
+ node_fs_1.default.mkdirSync(dir, { recursive: true });
173
+ if (stored) {
174
+ for (const k of Object.keys(stored)) {
175
+ if (!POLLUTING_KEYS.has(k))
176
+ next[k] = stored[k];
177
+ }
178
+ }
179
+ next['version'] = exports.PERSON_FILE_VERSION;
180
+ if (update.audience !== undefined)
181
+ next['audience'] = update.audience;
182
+ if (update.language)
183
+ next['language'] = update.language;
184
+ else if (update.language === null)
185
+ delete next['language'];
186
+ tmp = node_path_1.default.join(dir, `person.json.tmp-${process.pid}-${Math.random().toString(16).slice(2, 10)}`);
187
+ node_fs_1.default.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n');
188
+ node_fs_1.default.renameSync(tmp, file);
189
+ return { ok: true, path: file, written: true };
190
+ }
191
+ catch (e) {
192
+ if (tmp) {
193
+ try {
194
+ node_fs_1.default.unlinkSync(tmp);
195
+ }
196
+ catch { /* best effort */ }
197
+ }
198
+ return { ok: false, path: file, error: e.message };
199
+ }
200
+ }
201
+ /** The valid PP_AUDIENCE value, or null; an invalid non-empty value is reported via `warnings`. */
202
+ function readEnvAudience(env) {
203
+ const raw = env['PP_AUDIENCE'];
204
+ const v = norm(raw);
205
+ if (v === '')
206
+ return { value: null, warning: null };
207
+ if (isAudience(v))
208
+ return { value: v, warning: null };
209
+ return { value: null, warning: `PP_AUDIENCE "${clip(raw)}" is not one of ${AUDIENCE_LIST} — ignored` };
210
+ }
211
+ function resolveAudience(env, person) {
212
+ const fromEnv = readEnvAudience(env);
213
+ const warnings = [];
214
+ if (fromEnv.warning)
215
+ warnings.push(fromEnv.warning);
216
+ warnings.push(...person.warnings);
217
+ if (fromEnv.value)
218
+ return { audience: fromEnv.value, source: 'environment', warnings };
219
+ if (person.audience)
220
+ return { audience: person.audience, source: 'person_file', warnings };
221
+ return { audience: 'developer', source: 'default', warnings };
222
+ }
223
+ function resolveLanguage(input) {
224
+ const { env, person, projectResponseLanguage } = input;
225
+ if (person.language) {
226
+ return { code: person.language, name: exports.LANGUAGE_NAMES[person.language], source: 'person_file' };
227
+ }
228
+ const { audience } = resolveAudience(env, person);
229
+ const implied = exports.IMPLIED_LANGUAGE[audience];
230
+ if (implied)
231
+ return { code: implied, name: exports.LANGUAGE_NAMES[implied], source: 'audience' };
232
+ if (typeof projectResponseLanguage === 'string' && projectResponseLanguage.trim() !== '') {
233
+ return { code: null, name: projectResponseLanguage, source: 'project' };
234
+ }
235
+ return { code: null, name: null, source: 'none' };
236
+ }
237
+ /** True only when no valid setting exists anywhere: no usable PP_AUDIENCE and no valid person file. */
238
+ function needsChoice(env, person) {
239
+ return readEnvAudience(env).value === null && person.audience === null;
240
+ }
241
+ const NUMBERED_AUDIENCE = new Map([
242
+ ['1', 'business'],
243
+ ['2', 'engineer'],
244
+ ['3', 'developer'],
245
+ ]);
246
+ /** Option labels (D-09); lower-case, single spaces. */
247
+ const NAMED_AUDIENCE = new Map([
248
+ ['business', 'business'],
249
+ ['business user', 'business'],
250
+ ['engineer', 'engineer'],
251
+ ['business engineer', 'engineer'],
252
+ ['developer', 'developer'],
253
+ ]);
254
+ /**
255
+ * Map a first-run reply to an audience. Enter or whitespace means `developer`;
256
+ * `null`/`undefined` (no reply at all) and anything unrecognised give null.
257
+ *
258
+ * Replies are normalised before matching (WR-05), because a relaying agent
259
+ * most often echoes the chosen option line: a leading option number
260
+ * (`2) Business engineer`), a trailing `(3)`, and each half of a
261
+ * `a / b` label are all read. Every part that names an audience
262
+ * must agree; unknown text or a contradiction (`1) Business engineer`) gives
263
+ * null rather than a guess.
264
+ */
265
+ function parseAudienceInput(answer) {
266
+ if (typeof answer !== 'string' && typeof answer !== 'number')
267
+ return null;
268
+ const s = String(answer).trim().toLowerCase();
269
+ if (s === '')
270
+ return 'developer';
271
+ const found = new Set();
272
+ let rest = s;
273
+ const lead = /^([123])(?![0-9])[).:\s-]*/.exec(rest);
274
+ if (lead) {
275
+ found.add(NUMBERED_AUDIENCE.get(lead[1]));
276
+ rest = rest.slice(lead[0].length);
277
+ }
278
+ const trail = /\s*\(([123])\)$/.exec(rest);
279
+ if (trail) {
280
+ found.add(NUMBERED_AUDIENCE.get(trail[1]));
281
+ rest = rest.slice(0, trail.index);
282
+ }
283
+ for (const half of rest.split('/')) {
284
+ const label = half.trim().replace(/[).:!]+$/, '').replace(/\s+/g, ' ');
285
+ if (label === '')
286
+ continue;
287
+ const named = NAMED_AUDIENCE.get(label);
288
+ if (!named)
289
+ return null;
290
+ found.add(named);
291
+ }
292
+ return found.size === 1 ? [...found][0] : null;
293
+ }
@@ -47,6 +47,8 @@ const { extractFrontmatter } = frontmatter;
47
47
  const modelProfiles = require("./model-profiles.cjs");
48
48
  const { MODEL_PROFILES, VALID_PHASE_TYPES } = modelProfiles;
49
49
  const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
50
+ const messages_cjs_1 = require("./messages.cjs");
51
+ const message_labels_cjs_1 = require("./message-labels.cjs");
50
52
  // 16-03: the one `.planning/` path test. The seal leaf's top level imports only
51
53
  // Node built-ins and it never requires this module, so the edge adds no cycle.
52
54
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -909,17 +911,20 @@ function cmdProgressRender(cwd, format, raw) {
909
911
  catch { /* intentionally empty */ }
910
912
  const percent = totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0;
911
913
  if (format === 'table') {
912
- // Render markdown table
914
+ // Render markdown table. Display text only: the status cell is the canonical
915
+ // word relabelled here, at the last step, and nothing reads it back (D-13).
916
+ const audience = (0, messages_cjs_1.audienceOf)(process.env);
913
917
  const barWidth = 10;
914
918
  const filled = Math.round((percent / 100) * barWidth);
915
919
  const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
916
- let out = `# ${milestone.version} ${milestone.name}\n\n`;
917
- out += `**Progress:** [${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)\n\n`;
918
- out += `| Phase | Name | Plans | Status |\n`;
919
- out += `|-------|------|-------|--------|\n`;
920
+ let out = (0, messages_cjs_1.text)('progress.table', { version: milestone.version, name: milestone.name }, { audience }) + '\n\n';
921
+ out += (0, messages_cjs_1.text)('progress.table.progress', { bar, done: totalSummaries, total: totalPlans, percent }, { audience }) + '\n\n';
922
+ out += (0, messages_cjs_1.text)('progress.table.header', {}, { audience }) + '\n';
920
923
  for (const p of phases) {
921
- out += `| ${p.number} | ${p.name} | ${p.summaries}/${p.plans} | ${p.status} |\n`;
924
+ out += `| ${p.number} | ${p.name} | ${p.summaries}/${p.plans} | ${(0, message_labels_cjs_1.label)('phase.status', p.status, { audience })} |\n`;
922
925
  }
926
+ if (audience !== 'developer')
927
+ out += (0, messages_cjs_1.refLine)('progress.table') + '\n';
923
928
  output({ rendered: out }, raw, out);
924
929
  }
925
930
  else if (format === 'bar') {
@@ -1279,32 +1284,36 @@ function cmdStats(cwd, format, raw) {
1279
1284
  last_activity: lastActivity,
1280
1285
  };
1281
1286
  if (format === 'table') {
1287
+ // Display text only: the status cell is the canonical word relabelled here,
1288
+ // at the last step; completedPhases above is counted on the canonical word (D-13).
1289
+ const audience = (0, messages_cjs_1.audienceOf)(process.env);
1282
1290
  const barWidth = 10;
1283
1291
  const filled = Math.round((percent / 100) * barWidth);
1284
1292
  const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
1285
- let out = `# ${milestone.version} ${milestone.name} — Statistics\n\n`;
1286
- out += `**Progress:** [${bar}] ${completedPhases}/${phases.length} phases (${percent}%)\n`;
1293
+ let out = (0, messages_cjs_1.text)('stats.table', { version: milestone.version, name: milestone.name }, { audience }) + '\n\n';
1294
+ out += (0, messages_cjs_1.text)('stats.table.progress', { bar, done: completedPhases, total: phases.length, percent }, { audience }) + '\n';
1287
1295
  if (totalPlans > 0) {
1288
- out += `**Plans:** ${totalSummaries}/${totalPlans} complete (${planPercent}%)\n`;
1296
+ out += (0, messages_cjs_1.text)('stats.table.plans', { done: totalSummaries, total: totalPlans, percent: planPercent }, { audience }) + '\n';
1289
1297
  }
1290
- out += `**Phases:** ${completedPhases}/${phases.length} complete\n`;
1298
+ out += (0, messages_cjs_1.text)('stats.table.phases', { done: completedPhases, total: phases.length }, { audience }) + '\n';
1291
1299
  if (requirementsTotal > 0) {
1292
- out += `**Requirements:** ${requirementsComplete}/${requirementsTotal} complete\n`;
1300
+ out += (0, messages_cjs_1.text)('stats.table.requirements', { done: requirementsComplete, total: requirementsTotal }, { audience }) + '\n';
1293
1301
  }
1294
1302
  out += '\n';
1295
- out += `| Phase | Name | Plans | Completed | Status | Wall-clock |\n`;
1296
- out += `|-------|------|-------|-----------|--------|------------|\n`;
1303
+ out += (0, messages_cjs_1.text)('stats.table.header', {}, { audience }) + '\n';
1297
1304
  for (const p of phases) {
1298
- out += `| ${p.number} | ${p.name} | ${p.plans} | ${p.summaries} | ${p.status} | ${p.wall_clock ?? '—'} |\n`;
1305
+ out += `| ${p.number} | ${p.name} | ${p.plans} | ${p.summaries} | ${(0, message_labels_cjs_1.label)('phase.status', p.status, { audience })} | ${p.wall_clock ?? '—'} |\n`;
1299
1306
  }
1300
1307
  if (gitCommits > 0) {
1301
- out += `\n**Git:** ${gitCommits} commits`;
1308
+ out += '\n' + (0, messages_cjs_1.text)('stats.table.git', { count: gitCommits }, { audience });
1302
1309
  if (gitFirstCommitDate)
1303
- out += ` (since ${gitFirstCommitDate})`;
1310
+ out += (0, messages_cjs_1.text)('stats.table.git_since', { date: gitFirstCommitDate }, { audience });
1304
1311
  out += '\n';
1305
1312
  }
1306
1313
  if (lastActivity)
1307
- out += `**Last activity:** ${lastActivity}\n`;
1314
+ out += (0, messages_cjs_1.text)('stats.table.last_activity', { date: lastActivity }, { audience }) + '\n';
1315
+ if (audience !== 'developer')
1316
+ out += (0, messages_cjs_1.refLine)('stats.table') + '\n';
1308
1317
  output({ rendered: out }, raw, out);
1309
1318
  }
1310
1319
  else {