pumuki 6.3.129 → 6.3.130

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 (77) hide show
  1. package/AGENTS.md +16 -1
  2. package/CHANGELOG.md +9 -0
  3. package/README.md +14 -10
  4. package/VERSION +1 -1
  5. package/core/facts/detectors/text/android.test.ts +2583 -24
  6. package/core/facts/detectors/text/android.ts +4633 -34
  7. package/core/facts/detectors/typescript/index.test.ts +3639 -73
  8. package/core/facts/detectors/typescript/index.ts +4819 -270
  9. package/core/facts/extractHeuristicFacts.ts +271 -6
  10. package/core/rules/presets/heuristics/android.test.ts +314 -1
  11. package/core/rules/presets/heuristics/android.ts +1220 -50
  12. package/core/rules/presets/heuristics/typescript.test.ts +158 -2
  13. package/core/rules/presets/heuristics/typescript.ts +508 -0
  14. package/docs/README.md +3 -3
  15. package/docs/operations/RELEASE_NOTES.md +7 -1
  16. package/docs/operations/framework-menu-consumer-walkthrough.md +18 -15
  17. package/docs/product/API_REFERENCE.md +1 -1
  18. package/docs/product/USAGE.md +1 -1
  19. package/docs/validation/README.md +3 -1
  20. package/integrations/config/skillsCompilerTemplates.test.ts +131 -0
  21. package/integrations/config/skillsCompilerTemplates.ts +953 -7
  22. package/integrations/config/skillsDetectorRegistry.ts +451 -8
  23. package/integrations/config/skillsMarkdownRules.ts +884 -2
  24. package/integrations/evidence/buildEvidence.ts +29 -1
  25. package/integrations/evidence/platformSummary.test.ts +73 -9
  26. package/integrations/evidence/platformSummary.ts +165 -7
  27. package/integrations/evidence/repoState.ts +3 -0
  28. package/integrations/evidence/schema.ts +18 -0
  29. package/integrations/evidence/trackingContract.ts +17 -0
  30. package/integrations/evidence/writeEvidence.test.ts +3 -0
  31. package/integrations/evidence/writeEvidence.ts +29 -1
  32. package/integrations/gate/evaluateAiGate.ts +251 -8
  33. package/integrations/gate/governanceActionCatalog.ts +275 -0
  34. package/integrations/gate/remediationCatalog.ts +8 -0
  35. package/integrations/git/runPlatformGate.ts +9 -1
  36. package/integrations/lifecycle/adapter.ts +24 -0
  37. package/integrations/lifecycle/bootstrapManifest.ts +248 -0
  38. package/integrations/lifecycle/cliGovernanceConsole.ts +69 -0
  39. package/integrations/lifecycle/governanceNextAction.ts +171 -0
  40. package/integrations/lifecycle/governanceObservationSnapshot.ts +369 -0
  41. package/integrations/lifecycle/packageInfo.ts +118 -1
  42. package/integrations/lifecycle/state.ts +8 -1
  43. package/integrations/lifecycle/trackingState.ts +403 -0
  44. package/integrations/lifecycle/watch.ts +1 -1
  45. package/integrations/mcp/aiGateCheck.ts +194 -10
  46. package/integrations/mcp/alignedPlatformGate.ts +232 -0
  47. package/integrations/mcp/enterpriseServer.ts +19 -3
  48. package/integrations/mcp/preFlightCheck.ts +66 -3
  49. package/integrations/mcp/readMcpPrePushStdin.ts +7 -0
  50. package/package.json +1 -1
  51. package/scripts/build-ruralgo-s1-evidence-pack.ts +85 -0
  52. package/scripts/check-tracking-single-active.sh +1 -1
  53. package/scripts/framework-menu-advanced-view-lib.ts +49 -0
  54. package/scripts/framework-menu-consumer-actions-lib.ts +32 -32
  55. package/scripts/framework-menu-consumer-preflight-render.ts +10 -0
  56. package/scripts/framework-menu-consumer-preflight-run.ts +23 -0
  57. package/scripts/framework-menu-consumer-preflight-types.ts +12 -0
  58. package/scripts/framework-menu-consumer-runtime-actions.ts +11 -5
  59. package/scripts/framework-menu-consumer-runtime-audit.ts +0 -28
  60. package/scripts/framework-menu-consumer-runtime-evidence-classic.ts +118 -42
  61. package/scripts/framework-menu-consumer-runtime-lib.ts +38 -0
  62. package/scripts/framework-menu-consumer-runtime-menu.ts +55 -15
  63. package/scripts/framework-menu-consumer-runtime-types.ts +4 -0
  64. package/scripts/framework-menu-evidence-summary-read.ts +17 -1
  65. package/scripts/framework-menu-evidence-summary-types.ts +3 -0
  66. package/scripts/framework-menu-layout-data.ts +3 -23
  67. package/scripts/framework-menu-system-notifications-macos-applescript-dialog.ts +1 -1
  68. package/scripts/framework-menu-system-notifications-macos-dialog-payload.ts +14 -2
  69. package/scripts/framework-menu-system-notifications-macos-swift-source.ts +1 -1
  70. package/scripts/framework-menu-system-notifications-payloads-blocked.ts +128 -4
  71. package/scripts/framework-menu-system-notifications-payloads.ts +8 -1
  72. package/scripts/framework-menu-system-notifications-text.ts +7 -1
  73. package/scripts/framework-menu.ts +37 -2
  74. package/scripts/package-install-smoke-consumer-git-repo-lib.ts +10 -1
  75. package/scripts/package-install-smoke-consumer-npm-lib.ts +46 -9
  76. package/scripts/ruralgo-s1-evidence-pack-lib.ts +200 -0
  77. package/skills.lock.json +613 -698
@@ -0,0 +1,369 @@
1
+ import { existsSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { readEvidenceResult } from '../evidence/readEvidence';
4
+ import { readRepoTrackingState } from '../evidence/trackingContract';
5
+ import type { RepoTrackingState } from '../evidence/schema';
6
+ import { readSddStatus } from '../sdd';
7
+ import type { SddStatusPayload } from '../sdd/types';
8
+ import type { LifecycleExperimentalFeaturesSnapshot } from './experimentalFeaturesSnapshot';
9
+ import type { ILifecycleGitService } from './gitService';
10
+ import { LifecycleGitService } from './gitService';
11
+ import type { LifecyclePolicyValidationSnapshot } from './policyValidationSnapshot';
12
+ import { writeInfo } from './cliOutputs';
13
+ import { formatTrackingActionableContext } from './trackingState';
14
+
15
+ const DEFAULT_PROTECTED_BRANCHES = new Set(['main', 'master', 'develop', 'dev']);
16
+
17
+ export type GovernanceEvidenceSummary = {
18
+ path: string;
19
+ readable: 'missing' | 'invalid' | 'valid';
20
+ snapshot_stage?: string;
21
+ snapshot_outcome?: 'PASS' | 'WARN' | 'BLOCK';
22
+ matched_warn_count?: number;
23
+ matched_blocking_count?: number;
24
+ findings_count?: number;
25
+ ai_gate_status?: 'ALLOWED' | 'BLOCKED';
26
+ human_summary_preview: string[];
27
+ };
28
+
29
+ export type GovernanceContractSurface = {
30
+ agents_md: boolean;
31
+ skills_lock_json: boolean;
32
+ skills_sources_json: boolean;
33
+ vendor_skills_dir: boolean;
34
+ pumuki_adapter_json: boolean;
35
+ };
36
+
37
+ export type GovernanceObservationSnapshot = {
38
+ schema_version: '1';
39
+ platform_bundles_effective?: ReadonlyArray<string>;
40
+ pre_write_effective?: {
41
+ mode: 'off' | 'advisory' | 'strict';
42
+ source: string;
43
+ blocking: boolean;
44
+ strict_policy: boolean;
45
+ };
46
+ sdd: {
47
+ experimental_raw: string | null;
48
+ effective_mode: 'off' | 'advisory' | 'strict';
49
+ experimental_source: string;
50
+ };
51
+ sdd_session: {
52
+ active: boolean;
53
+ valid: boolean;
54
+ change_id: string | null;
55
+ remaining_seconds: number | null;
56
+ };
57
+ policy_strict: {
58
+ pre_write: boolean;
59
+ pre_commit: boolean;
60
+ pre_push: boolean;
61
+ ci: boolean;
62
+ };
63
+ enterprise_warn_as_block_env: boolean;
64
+ evidence: GovernanceEvidenceSummary;
65
+ git: {
66
+ current_branch: string | null;
67
+ on_protected_branch_hint: boolean;
68
+ };
69
+ contract_surface: GovernanceContractSurface;
70
+ tracking: RepoTrackingState;
71
+ attention_codes: ReadonlyArray<string>;
72
+ governance_effective: 'green' | 'attention' | 'blocked';
73
+ agent_bootstrap_hints: ReadonlyArray<string>;
74
+ };
75
+
76
+ const truthyEnv = (value: string | undefined): boolean => {
77
+ if (typeof value !== 'string') {
78
+ return false;
79
+ }
80
+ const normalized = value.trim().toLowerCase();
81
+ return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'strict';
82
+ };
83
+
84
+ const readCurrentBranch = (git: ILifecycleGitService, repoRoot: string): string | null => {
85
+ try {
86
+ const branch = git.runGit(['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot).trim();
87
+ return branch.length > 0 ? branch : null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ };
92
+
93
+ const readSddStatusSafe = (repoRoot: string, git: ILifecycleGitService): SddStatusPayload => {
94
+ try {
95
+ return readSddStatus(repoRoot, git);
96
+ } catch {
97
+ return {
98
+ repoRoot,
99
+ openspec: {
100
+ installed: false,
101
+ projectInitialized: false,
102
+ minimumVersion: '0.0.0',
103
+ recommendedVersion: '0.0.0',
104
+ compatible: false,
105
+ },
106
+ session: {
107
+ repoRoot,
108
+ active: false,
109
+ valid: false,
110
+ },
111
+ };
112
+ }
113
+ };
114
+
115
+ const buildContractSurface = (repoRoot: string): GovernanceContractSurface => ({
116
+ agents_md: existsSync(join(repoRoot, 'AGENTS.md')),
117
+ skills_lock_json: existsSync(join(repoRoot, 'skills.lock.json')),
118
+ skills_sources_json: existsSync(join(repoRoot, 'skills.sources.json')),
119
+ vendor_skills_dir: existsSync(join(repoRoot, 'vendor', 'skills')),
120
+ pumuki_adapter_json: existsSync(join(repoRoot, '.pumuki', 'adapter.json')),
121
+ });
122
+
123
+ const PLATFORM_BUNDLE_ORDER = [
124
+ 'android-enterprise-rules',
125
+ 'backend-enterprise-rules',
126
+ 'frontend-enterprise-rules',
127
+ 'ios-enterprise-rules',
128
+ ] as const;
129
+
130
+ const PLATFORM_BUNDLE_LABELS: Record<(typeof PLATFORM_BUNDLE_ORDER)[number], string> = {
131
+ 'android-enterprise-rules': 'android',
132
+ 'backend-enterprise-rules': 'backend',
133
+ 'frontend-enterprise-rules': 'frontend',
134
+ 'ios-enterprise-rules': 'ios',
135
+ };
136
+
137
+ const resolvePlatformBundlesEffective = (repoRoot: string): ReadonlyArray<string> => {
138
+ const vendorSkillsPath = join(repoRoot, 'vendor', 'skills');
139
+ if (!existsSync(vendorSkillsPath)) {
140
+ return [];
141
+ }
142
+ const present = new Set(
143
+ readdirSync(vendorSkillsPath, { withFileTypes: true })
144
+ .filter((entry) => entry.isDirectory())
145
+ .map((entry) => entry.name)
146
+ );
147
+ return PLATFORM_BUNDLE_ORDER.filter((bundle) => present.has(bundle)).map(
148
+ (bundle) => PLATFORM_BUNDLE_LABELS[bundle]
149
+ );
150
+ };
151
+
152
+ const summarizeEvidence = (repoRoot: string): GovernanceEvidenceSummary => {
153
+ const evidenceResult = readEvidenceResult(repoRoot);
154
+ const path = evidenceResult.source_descriptor.path;
155
+ if (evidenceResult.kind === 'missing') {
156
+ return { path, readable: 'missing', human_summary_preview: [] };
157
+ }
158
+ if (evidenceResult.kind === 'invalid') {
159
+ return {
160
+ path,
161
+ readable: 'invalid',
162
+ human_summary_preview: [evidenceResult.detail ?? evidenceResult.reason],
163
+ };
164
+ }
165
+
166
+ const snapshot = evidenceResult.evidence.snapshot;
167
+ const hints = evidenceResult.evidence.operational_hints?.human_summary_lines ?? [];
168
+ const breakdown = evidenceResult.evidence.operational_hints?.rule_execution_breakdown;
169
+ return {
170
+ path,
171
+ readable: 'valid',
172
+ snapshot_stage: snapshot.stage,
173
+ snapshot_outcome: snapshot.outcome,
174
+ matched_warn_count: breakdown?.matched_warn_count,
175
+ matched_blocking_count: breakdown?.matched_blocking_count,
176
+ findings_count: Array.isArray(snapshot.findings) ? snapshot.findings.length : 0,
177
+ ai_gate_status: evidenceResult.evidence.ai_gate.status,
178
+ human_summary_preview: hints.slice(0, 5),
179
+ };
180
+ };
181
+
182
+ const buildHints = (
183
+ surface: GovernanceContractSurface,
184
+ branch: string | null,
185
+ protectedBranchHint: boolean,
186
+ tracking: RepoTrackingState
187
+ ): string[] => {
188
+ const hints: string[] = [];
189
+ if (surface.agents_md) {
190
+ hints.push('AGENTS.md presente: aplica el contrato del repo antes de dar governance en verde.');
191
+ }
192
+ if (!surface.skills_lock_json) {
193
+ hints.push('Falta skills.lock.json: genera lock canónico de skills antes de cerrar la gobernanza.');
194
+ }
195
+ if (!surface.pumuki_adapter_json) {
196
+ hints.push('Falta .pumuki/adapter.json: instala el adaptador si quieres wiring IDE/MCP explícito.');
197
+ }
198
+ if (protectedBranchHint && branch) {
199
+ hints.push(`La rama "${branch}" cae en el set protegido por defecto: usa feature/* o refactor/*.`);
200
+ }
201
+ if (tracking.conflict) {
202
+ hints.push('Tracking canónico en conflicto: AGENTS.md y los README del repo no apuntan al mismo MD.');
203
+ }
204
+ if (tracking.enforced && !tracking.canonical_present) {
205
+ hints.push(`Falta el tracking canónico declarado (${tracking.canonical_path ?? 'sin resolver'}).`);
206
+ }
207
+ if (tracking.enforced && tracking.single_in_progress_valid === false) {
208
+ const actionableContext = formatTrackingActionableContext(tracking);
209
+ hints.push(
210
+ `El tracking canónico debe dejar exactamente una 🚧 (actual=${tracking.in_progress_count ?? 'n/a'}${actionableContext ? `, ${actionableContext}` : ''}).`
211
+ );
212
+ }
213
+ hints.push('SDD/OpenSpec: usa PUMUKI_EXPERIMENTAL_SDD=advisory|strict cuando el loop SDD esté activo.');
214
+ hints.push('WARN-as-BLOCK: activa PUMUKI_ENTERPRISE_STRICT_WARN_AS_BLOCK=1 si el repo exige promoción dura.');
215
+ return hints;
216
+ };
217
+
218
+ export const readGovernanceObservationSnapshot = (params: {
219
+ repoRoot: string;
220
+ experimentalFeatures: LifecycleExperimentalFeaturesSnapshot;
221
+ policyValidation: LifecyclePolicyValidationSnapshot;
222
+ git?: ILifecycleGitService;
223
+ }): GovernanceObservationSnapshot => {
224
+ const git = params.git ?? new LifecycleGitService();
225
+ const { repoRoot, experimentalFeatures, policyValidation } = params;
226
+ const rawSdd = process.env.PUMUKI_EXPERIMENTAL_SDD?.trim();
227
+ const sddStatus = readSddStatusSafe(repoRoot, git);
228
+ const evidence = summarizeEvidence(repoRoot);
229
+ const branch = readCurrentBranch(git, repoRoot);
230
+ const onProtected = typeof branch === 'string' && DEFAULT_PROTECTED_BRANCHES.has(branch.trim().toLowerCase());
231
+ const surface = buildContractSurface(repoRoot);
232
+ const tracking = readRepoTrackingState(repoRoot);
233
+ const warnAsBlock = truthyEnv(process.env.PUMUKI_ENTERPRISE_STRICT_WARN_AS_BLOCK);
234
+
235
+ const attention: string[] = [];
236
+ if (evidence.readable === 'invalid') {
237
+ attention.push('EVIDENCE_INVALID_OR_CHAIN');
238
+ }
239
+ if (evidence.readable === 'valid' && evidence.ai_gate_status === 'BLOCKED') {
240
+ attention.push('AI_GATE_BLOCKED');
241
+ }
242
+ if (evidence.readable === 'valid' && evidence.snapshot_outcome === 'WARN') {
243
+ attention.push('EVIDENCE_SNAPSHOT_WARN');
244
+ }
245
+ if (evidence.readable === 'valid' && evidence.snapshot_outcome === 'BLOCK') {
246
+ attention.push('EVIDENCE_SNAPSHOT_BLOCK');
247
+ }
248
+ if (
249
+ sddStatus.session.valid !== true &&
250
+ (sddStatus.session.active === true || !!sddStatus.session.changeId || sddStatus.session.remainingSeconds === 0)
251
+ ) {
252
+ attention.push('SDD_SESSION_INVALID_OR_EXPIRED');
253
+ }
254
+ if (!policyValidation.stages.PRE_WRITE.strict) {
255
+ attention.push('POLICY_PRE_WRITE_NOT_STRICT');
256
+ }
257
+ if (!policyValidation.stages.PRE_COMMIT.strict) {
258
+ attention.push('POLICY_PRE_COMMIT_NOT_STRICT');
259
+ }
260
+ if (!policyValidation.stages.PRE_PUSH.strict) {
261
+ attention.push('POLICY_PRE_PUSH_NOT_STRICT');
262
+ }
263
+ if (!policyValidation.stages.CI.strict) {
264
+ attention.push('POLICY_CI_NOT_STRICT');
265
+ }
266
+ if (onProtected) {
267
+ attention.push('GITFLOW_PROTECTED_BRANCH_CONTEXT');
268
+ }
269
+ if (tracking.conflict) {
270
+ attention.push('TRACKING_CANONICAL_SOURCE_CONFLICT');
271
+ }
272
+ if (tracking.enforced && !tracking.canonical_present) {
273
+ attention.push('TRACKING_CANONICAL_FILE_MISSING');
274
+ }
275
+ if (tracking.enforced && tracking.single_in_progress_valid === false) {
276
+ attention.push('TRACKING_CANONICAL_IN_PROGRESS_INVALID');
277
+ }
278
+
279
+ let governanceEffective: GovernanceObservationSnapshot['governance_effective'] = 'green';
280
+ if (
281
+ evidence.readable === 'invalid'
282
+ || (evidence.readable === 'valid' && evidence.ai_gate_status === 'BLOCKED')
283
+ || (evidence.readable === 'valid' && evidence.snapshot_outcome === 'BLOCK')
284
+ ) {
285
+ governanceEffective = 'blocked';
286
+ } else if (attention.length > 0) {
287
+ governanceEffective = 'attention';
288
+ }
289
+
290
+ return {
291
+ schema_version: '1',
292
+ platform_bundles_effective: resolvePlatformBundlesEffective(repoRoot),
293
+ pre_write_effective: {
294
+ mode: experimentalFeatures.features.pre_write.mode,
295
+ source: experimentalFeatures.features.pre_write.source,
296
+ blocking: experimentalFeatures.features.pre_write.blocking,
297
+ strict_policy: policyValidation.stages.PRE_WRITE.strict,
298
+ },
299
+ sdd: {
300
+ experimental_raw: rawSdd && rawSdd.length > 0 ? rawSdd : null,
301
+ effective_mode: experimentalFeatures.features.sdd.mode,
302
+ experimental_source: experimentalFeatures.features.sdd.source,
303
+ },
304
+ sdd_session: {
305
+ active: sddStatus.session.active,
306
+ valid: sddStatus.session.valid,
307
+ change_id: sddStatus.session.changeId ?? null,
308
+ remaining_seconds:
309
+ typeof sddStatus.session.remainingSeconds === 'number' ? sddStatus.session.remainingSeconds : null,
310
+ },
311
+ policy_strict: {
312
+ pre_write: policyValidation.stages.PRE_WRITE.strict,
313
+ pre_commit: policyValidation.stages.PRE_COMMIT.strict,
314
+ pre_push: policyValidation.stages.PRE_PUSH.strict,
315
+ ci: policyValidation.stages.CI.strict,
316
+ },
317
+ enterprise_warn_as_block_env: warnAsBlock,
318
+ evidence,
319
+ git: {
320
+ current_branch: branch,
321
+ on_protected_branch_hint: onProtected,
322
+ },
323
+ contract_surface: surface,
324
+ tracking,
325
+ attention_codes: attention,
326
+ governance_effective: governanceEffective,
327
+ agent_bootstrap_hints: buildHints(surface, branch, onProtected, tracking),
328
+ };
329
+ };
330
+
331
+ export const buildGovernanceObservationSummaryLines = (
332
+ snapshot: GovernanceObservationSnapshot
333
+ ): string[] => {
334
+ const lines = [
335
+ `Contract: AGENTS=${snapshot.contract_surface.agents_md ? 'yes' : 'no'} skills.lock=${snapshot.contract_surface.skills_lock_json ? 'yes' : 'no'} skills.sources=${snapshot.contract_surface.skills_sources_json ? 'yes' : 'no'} vendor/skills=${snapshot.contract_surface.vendor_skills_dir ? 'yes' : 'no'} adapter=${snapshot.contract_surface.pumuki_adapter_json ? 'yes' : 'no'}`,
336
+ `Governance: ${snapshot.governance_effective.toUpperCase()}`,
337
+ `Platforms: ${snapshot.platform_bundles_effective?.join(', ') || 'none-detected'}`,
338
+ `GitFlow: branch=${snapshot.git.current_branch ?? 'unknown'} protected_hint=${snapshot.git.on_protected_branch_hint ? 'yes' : 'no'}`,
339
+ `SDD: env=${snapshot.sdd.experimental_raw ?? '(unset)'} effective=${snapshot.sdd.effective_mode} session_active=${snapshot.sdd_session.active} session_valid=${snapshot.sdd_session.valid} change=${snapshot.sdd_session.change_id ?? 'none'}`,
340
+ `Evidence: readable=${snapshot.evidence.readable} stage=${snapshot.evidence.snapshot_stage ?? 'n/a'} outcome=${snapshot.evidence.snapshot_outcome ?? 'n/a'} ai_gate=${snapshot.evidence.ai_gate_status ?? 'n/a'} findings=${snapshot.evidence.findings_count ?? 'n/a'}`,
341
+ `Pre-write: mode=${snapshot.pre_write_effective?.mode ?? 'unknown'} blocking=${snapshot.pre_write_effective?.blocking ? 'yes' : 'no'} strict_policy=${snapshot.pre_write_effective?.strict_policy ? 'yes' : 'no'} source=${snapshot.pre_write_effective?.source ?? 'unknown'}`,
342
+ ];
343
+ if (snapshot.attention_codes.length > 0) {
344
+ lines.push(`Attention: ${snapshot.attention_codes.join(', ')}`);
345
+ }
346
+ if (snapshot.tracking.enforced && snapshot.tracking.single_in_progress_valid === false) {
347
+ const actionableContext = formatTrackingActionableContext(snapshot.tracking);
348
+ lines.push(
349
+ `Tracking: canonical=${snapshot.tracking.canonical_path ?? 'unknown'} in_progress_count=${snapshot.tracking.in_progress_count ?? 'n/a'}${actionableContext ? ` ${actionableContext}` : ''}`
350
+ );
351
+ }
352
+ return lines;
353
+ };
354
+
355
+ export const printGovernanceObservationHuman = (snapshot: GovernanceObservationSnapshot): void => {
356
+ writeInfo('[pumuki] governance truth (S1 / governance console baseline):');
357
+ for (const line of buildGovernanceObservationSummaryLines(snapshot)) {
358
+ writeInfo(`[pumuki] ${line}`);
359
+ }
360
+ for (const hint of snapshot.evidence.human_summary_preview) {
361
+ writeInfo(`[pumuki] evidence hint: ${hint}`);
362
+ }
363
+ };
364
+
365
+ export const doctorGovernanceIsBlocking = (snapshot: GovernanceObservationSnapshot): boolean =>
366
+ snapshot.governance_effective === 'blocked';
367
+
368
+ export const doctorGovernanceNeedsAttention = (snapshot: GovernanceObservationSnapshot): boolean =>
369
+ snapshot.governance_effective !== 'green';
@@ -49,15 +49,126 @@ const hasPathExecutionHazard = (repoRoot?: string): boolean =>
49
49
  repoRoot.trim().length > 0 &&
50
50
  repoRoot.includes(delimiter);
51
51
 
52
+ type ConsumerNodeRuntimeSpec = {
53
+ version: string;
54
+ source: 'volta' | '.nvmrc' | 'package.engines';
55
+ commandPrefix: 'volta' | 'nvm';
56
+ };
57
+
58
+ const normalizeNodeVersionToken = (value: string): string =>
59
+ value.trim().replace(/^node@/i, '').replace(/^v/i, '');
60
+
61
+ const extractNodeVersionToken = (value: string): string | null => {
62
+ const normalized = normalizeNodeVersionToken(value);
63
+ const exactVersion = normalized.match(/\d+\.\d+\.\d+/)?.[0];
64
+ if (exactVersion) {
65
+ return exactVersion;
66
+ }
67
+ const majorOnly = normalized.match(/^\d+$/)?.[0];
68
+ return majorOnly ?? null;
69
+ };
70
+
71
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
72
+ typeof value === 'object' && value !== null && !Array.isArray(value);
73
+
74
+ const readNestedString = (
75
+ source: Record<string, unknown>,
76
+ path: ReadonlyArray<string>
77
+ ): string | undefined => {
78
+ let cursor: unknown = source;
79
+ for (const segment of path) {
80
+ if (!isRecord(cursor)) {
81
+ return undefined;
82
+ }
83
+ cursor = cursor[segment];
84
+ }
85
+ return typeof cursor === 'string' && cursor.trim().length > 0 ? cursor.trim() : undefined;
86
+ };
87
+
88
+ const readConsumerNodeRuntimeSpec = (repoRoot?: string): ConsumerNodeRuntimeSpec | null => {
89
+ if (typeof repoRoot !== 'string' || repoRoot.trim().length === 0) {
90
+ return null;
91
+ }
92
+
93
+ const packageJsonPath = join(repoRoot, 'package.json');
94
+ if (existsSync(packageJsonPath)) {
95
+ try {
96
+ const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as unknown;
97
+ if (isRecord(parsed)) {
98
+ const voltaNode = readNestedString(parsed, ['volta', 'node']);
99
+ const voltaVersion = voltaNode ? extractNodeVersionToken(voltaNode) : null;
100
+ if (voltaVersion) {
101
+ return {
102
+ version: voltaVersion,
103
+ source: 'volta',
104
+ commandPrefix: 'volta',
105
+ };
106
+ }
107
+
108
+ const enginesNode = readNestedString(parsed, ['engines', 'node']);
109
+ const enginesVersion = enginesNode ? extractNodeVersionToken(enginesNode) : null;
110
+ if (enginesVersion) {
111
+ return {
112
+ version: enginesVersion,
113
+ source: 'package.engines',
114
+ commandPrefix: 'nvm',
115
+ };
116
+ }
117
+ }
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ const nvmrcPath = join(repoRoot, '.nvmrc');
124
+ if (existsSync(nvmrcPath)) {
125
+ try {
126
+ const nvmrcVersion = extractNodeVersionToken(readFileSync(nvmrcPath, 'utf8'));
127
+ if (nvmrcVersion) {
128
+ return {
129
+ version: nvmrcVersion,
130
+ source: '.nvmrc',
131
+ commandPrefix: 'nvm',
132
+ };
133
+ }
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ return null;
140
+ };
141
+
142
+ const buildConsumerNodeAlignmentCommand = (repoRoot?: string): string | null => {
143
+ const runtimeSpec = readConsumerNodeRuntimeSpec(repoRoot);
144
+ if (!runtimeSpec) {
145
+ return null;
146
+ }
147
+
148
+ const currentNodeVersion = normalizeNodeVersionToken(process.version);
149
+ if (currentNodeVersion === runtimeSpec.version) {
150
+ return null;
151
+ }
152
+
153
+ if (runtimeSpec.commandPrefix === 'volta') {
154
+ return `volta install node@${runtimeSpec.version} && volta pin node@${runtimeSpec.version}`;
155
+ }
156
+
157
+ return `nvm install ${runtimeSpec.version} && nvm use ${runtimeSpec.version}`;
158
+ };
159
+
52
160
  export const buildLifecycleAlignmentCommand = (
53
161
  runtimeVersion: string,
54
162
  repoRoot?: string
55
163
  ): string => {
164
+ const consumerNodeAlignmentCommand = buildConsumerNodeAlignmentCommand(repoRoot);
56
165
  const installStep = `npm install --save-exact pumuki@${runtimeVersion}`;
57
166
  const runStep = hasPathExecutionHazard(repoRoot)
58
167
  ? `${buildLocalPumukiCommand()} install`
59
168
  : `npx --yes --package pumuki@${runtimeVersion} pumuki install`;
60
- return `${installStep} && ${runStep}`;
169
+ return [consumerNodeAlignmentCommand, installStep, runStep]
170
+ .filter((value): value is string => typeof value === 'string' && value.length > 0)
171
+ .join(' && ');
61
172
  };
62
173
 
63
174
  export const resolvePumukiVersionMetadata = (params?: { repoRoot?: string }): PumukiVersionMetadata => {
@@ -114,11 +225,17 @@ export const buildLifecycleVersionReport = (params?: {
114
225
  const driftFromConsumerInstalled =
115
226
  metadata.consumerInstalledVersion !== null &&
116
227
  metadata.consumerInstalledVersion !== metadata.runtimeVersion;
228
+ const consumerNodeSpec = readConsumerNodeRuntimeSpec(params?.repoRoot);
229
+ const consumerNodeVersion = consumerNodeSpec?.version ?? null;
230
+ const currentNodeVersion = normalizeNodeVersionToken(process.version);
231
+ const driftFromConsumerNode =
232
+ consumerNodeVersion !== null && currentNodeVersion !== consumerNodeVersion;
117
233
  const driftFromLifecycleInstalled =
118
234
  lifecycleInstalled !== null && metadata.resolvedVersion !== lifecycleInstalled;
119
235
  const driftTargets = [
120
236
  driftFromRuntime ? `runtime=${metadata.runtimeVersion}` : null,
121
237
  driftFromConsumerInstalled ? `consumer=${metadata.consumerInstalledVersion}` : null,
238
+ driftFromConsumerNode ? `node=${consumerNodeVersion}` : null,
122
239
  driftFromLifecycleInstalled ? `lifecycle=${lifecycleInstalled}` : null,
123
240
  ].filter((value): value is string => value !== null);
124
241
  const pathExecutionHazard = hasPathExecutionHazard(params?.repoRoot);
@@ -49,10 +49,17 @@ export const writeLifecycleState = (params: {
49
49
  openSpecManagedArtifacts?: ReadonlyArray<string>;
50
50
  }): void => {
51
51
  const { git, repoRoot, version } = params;
52
+ const existingInstalledAt = git.localConfig(repoRoot, PUMUKI_CONFIG_KEYS.installedAt);
52
53
  git.applyLocalConfig(repoRoot, PUMUKI_CONFIG_KEYS.installed, 'true');
53
54
  git.applyLocalConfig(repoRoot, PUMUKI_CONFIG_KEYS.version, version);
54
55
  git.applyLocalConfig(repoRoot, PUMUKI_CONFIG_KEYS.hooks, PUMUKI_MANAGED_HOOKS.join(','));
55
- git.applyLocalConfig(repoRoot, PUMUKI_CONFIG_KEYS.installedAt, new Date().toISOString());
56
+ git.applyLocalConfig(
57
+ repoRoot,
58
+ PUMUKI_CONFIG_KEYS.installedAt,
59
+ typeof existingInstalledAt === 'string' && existingInstalledAt.trim().length > 0
60
+ ? existingInstalledAt
61
+ : new Date().toISOString()
62
+ );
56
63
  if (params.openSpecManagedArtifacts) {
57
64
  const serialized = serializeManagedArtifacts(params.openSpecManagedArtifacts);
58
65
  if (serialized) {