pumuki 6.3.22 → 6.3.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Pumuki
2
2
 
3
- ![Pumuki](assets/logo_banner.png)
3
+ <img src="assets/logo.png" alt="Pumuki" width="100%" />
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/pumuki?color=1d4ed8)](https://www.npmjs.com/package/pumuki)
6
6
  [![CI](https://github.com/SwiftEnProfundidad/ast-intelligence-hooks/actions/workflows/ci.yml/badge.svg)](https://github.com/SwiftEnProfundidad/ast-intelligence-hooks/actions/workflows/ci.yml)
@@ -46,6 +46,13 @@ npx --yes pumuki sdd session --open --change=<change-id>
46
46
  npx --yes pumuki sdd validate --stage=PRE_COMMIT
47
47
  ```
48
48
 
49
+ Optional loop runner session (local, deterministic):
50
+
51
+ ```bash
52
+ npx --yes pumuki loop run --objective="stabilize gate before commit" --max-attempts=3 --json
53
+ npx --yes pumuki loop list --json
54
+ ```
55
+
49
56
  Run local gates:
50
57
 
51
58
  ```bash
@@ -180,6 +187,17 @@ npx --yes pumuki sdd session --close
180
187
  npx --yes pumuki sdd validate --stage=PRE_COMMIT
181
188
  ```
182
189
 
190
+ ### Loop Runner (Consumer)
191
+
192
+ ```bash
193
+ npx --yes pumuki loop run --objective="stabilize gate before commit" --max-attempts=3 --json
194
+ npx --yes pumuki loop status --session=<session-id> --json
195
+ npx --yes pumuki loop stop --session=<session-id> --json
196
+ npx --yes pumuki loop resume --session=<session-id> --json
197
+ npx --yes pumuki loop list --json
198
+ npx --yes pumuki loop export --session=<session-id> --output-json=.audit-reports/loop-session.json
199
+ ```
200
+
183
201
  ### Stage Gates (Consumer)
184
202
 
185
203
  ```bash
package/VERSION CHANGED
@@ -1 +1 @@
1
- v6.3.21
1
+ v6.3.24
@@ -119,8 +119,49 @@ Fuente unica de seguimiento operativo. No se abren nuevos MDs temporales de trac
119
119
  - PR `#451` (`docs/readme-enterprise-reliability-followup` -> `develop`) merged.
120
120
  - PR `#452` (`release/6.3.21` -> `develop`) merged.
121
121
  - PR `#453` (`develop` -> `main`) merged.
122
- - 🚧 `P3.T17` Hotfix visual definitivo de hero + bump `6.3.22` + cierre GitFlow + publicación npm.
123
- - objetivo: forzar render full-width estable en GitHub y npm usando asset raster (`assets/logo_banner.png`) y referencia directa en README.
122
+ - `P3.T17` Hotfix visual definitivo de hero + bump `6.3.22` + cierre GitFlow + publicación npm completados.
123
+ - render hero estabilizado para npm/GitHub con asset raster (`assets/logo_banner.png`) y referencia directa markdown en `README.md`.
124
+ - versión publicada: `pumuki@6.3.22`
125
+ - evidencia npm: `npm publish --access public` en verde; `npm view pumuki version dist-tags --json` => `latest: 6.3.22`
126
+ - cierre GitFlow:
127
+ - PR `#456` (`docs/readme-banner-fullwidth-png-6-3-22` -> `develop`) merged.
128
+ - PR `#457` (`develop` -> `main`) merged.
129
+ - sincronización final: `main`, `develop`, `origin/main`, `origin/develop` alineadas en `40a6872`.
130
+ - ✅ `P3.T18` Restauración visual del hero al estilo previo estable del grafo y publicación `v6.3.23` completadas.
131
+ - hero raíz restaurado a imagen clásica full-width: `<img src="assets/logo.png" alt="Pumuki" width="100%" />`.
132
+ - versión publicada: `pumuki@6.3.23`
133
+ - evidencia npm: `npm publish --access public` en verde; `npm view pumuki version dist-tags --json` => `latest: 6.3.23`
134
+ - ✅ `P3.T19` Standby post-release `6.3.23` cerrado por validación explícita del usuario.
135
+ - ✅ `P3.T20` Cierre administrativo final del bloque `P3` en modo espera operativa (sin cambios funcionales pendientes).
136
+
137
+ ### Fase P4 — Integración `loop runner` local (inspiración Ralph Loop, tool-agnostic)
138
+ - ✅ `P4.T1` Definir contrato local de sesión de loop (estado, intentos, límites y transiciones) y sus fixtures de test.
139
+ - implementación: `integrations/lifecycle/loopSessionContract.ts` (`create/parse/transition`).
140
+ - tests RED->GREEN: `integrations/lifecycle/__tests__/loopSessionContract.test.ts`.
141
+ - ✅ `P4.T2` Implementar store determinista local de sesiones loop (`.pumuki/loop-sessions`) con operaciones `create/read/update/list`.
142
+ - implementación: `integrations/lifecycle/loopSessionStore.ts`.
143
+ - validación: `integrations/lifecycle/__tests__/loopSessionStore.test.ts` en verde.
144
+ - ✅ `P4.T3` Integrar comando CLI `pumuki loop` (`run|status|stop|resume|list|export`) con parseo estricto.
145
+ - implementación: `integrations/lifecycle/cli.ts` (`parseLifecycleCliArgs` + `runLifecycleCli` loop commands).
146
+ - validación: `integrations/lifecycle/__tests__/cli.test.ts` (subcomandos loop parse/runtime).
147
+ - ✅ `P4.T4` Acoplar ejecución de `loop run` al gate (`runPlatformGate`) con política fail-fast y evidencia por intento.
148
+ - implementación: `integrations/lifecycle/cli.ts` (`LOOP_RUN_POLICY`, intento gate por `workingTree`, evidencia `.attempt-<n>.json`).
149
+ - validación: test de bloqueo fail-fast en `integrations/lifecycle/__tests__/cli.test.ts`.
150
+ - ✅ `P4.T5` Completar suite TDD end-to-end (`parse + store + runtime`) y validación de typecheck.
151
+ - tests en verde: `npx --yes tsx@4.21.0 --test integrations/lifecycle/__tests__/loopSessionContract.test.ts integrations/lifecycle/__tests__/loopSessionStore.test.ts integrations/lifecycle/__tests__/cli.test.ts`.
152
+ - typecheck en verde: `npm run typecheck`.
153
+ - ✅ `P4.T6` Consolidar documentación estable (`README/docs`) y cierre técnico del bloque P4.
154
+ - documentación actualizada: `README.md`, `docs/USAGE.md` (sección `loop` + semántica fail-fast + evidencia).
155
+ - ✅ `P4.T7` Revalidación global post-P4 ejecutada y fix de no-determinismo temporal en waiver TDD/BDD aplicado.
156
+ - fix aplicado: `integrations/git/__tests__/tddBddEnforcement.test.ts` (`expires_at` en futuro estable).
157
+ - validación en verde:
158
+ - `npx --yes tsx@4.21.0 --test integrations/git/__tests__/tddBddEnforcement.test.ts`
159
+ - `npm test`
160
+ - ✅ `P4.T8` Cierre GitFlow post-P4 (feature -> develop -> main) y sincronización final.
161
+ - PR `#462` merged (`feature/p4-loop-runner-core` -> `develop`).
162
+ - PR `#463` merged (`develop` -> `main`, merge admin por policy de rama).
163
+ - sincronización final completada con `main` y `develop` alineadas.
164
+ - 🚧 `P4.T9` Release de cierre post-P4 (`v6.3.24`): bump de versión, validación final y publicación npm.
124
165
 
125
166
  ## Plan Por Fases (Ciclo 014)
126
167
  Plan base visible para seguimiento previo y durante la implementacion.
@@ -5,6 +5,25 @@ Detailed commit history remains available through Git history (`git log` / `git
5
5
 
6
6
  ## 2026-02 (enterprise-refactor updates)
7
7
 
8
+ ### 2026-02-27 (v6.3.24)
9
+
10
+ - Lifecycle loop runner (local deterministic mode):
11
+ - Added `pumuki loop` command surface (`run/status/stop/resume/list/export`).
12
+ - Added session contract + store for deterministic state transitions and persistence.
13
+ - Gate integration:
14
+ - `loop run` now executes one strict fail-fast gate attempt on `workingTree`.
15
+ - Per-attempt evidence is emitted to `.pumuki/loop-sessions/<session-id>.attempt-<n>.json`.
16
+ - Stability and governance:
17
+ - Fixed time-based flake in waiver enforcement test by using stable future expiry.
18
+ - Synced `VERSION` to package release line.
19
+ - Documentation updated in `README.md` and `docs/USAGE.md`.
20
+
21
+ ### 2026-02-27 (v6.3.23)
22
+
23
+ - README visual rollback to the previous stable style from git history:
24
+ - root hero restored to classic logo image at full width:
25
+ - `<img src="assets/logo.png" alt="Pumuki" width="100%" />`
26
+
8
27
  ### 2026-02-27 (v6.3.22)
9
28
 
10
29
  - README hero render compatibility fix:
package/docs/USAGE.md CHANGED
@@ -223,7 +223,7 @@ npx --yes pumuki-ci
223
223
  npx --yes pumuki-pre-write
224
224
  ```
225
225
 
226
- ### 2.1) Lifecycle + SDD CLI (install / uninstall / remove / update / doctor / status / sdd)
226
+ ### 2.1) Lifecycle + SDD + Loop CLI (install / uninstall / remove / update / doctor / status / sdd / loop)
227
227
 
228
228
  Canonical npm package commands:
229
229
 
@@ -260,6 +260,14 @@ npx --yes pumuki sdd session --close
260
260
  npx --yes pumuki analytics hotspots report --top=10 --since-days=90 --json
261
261
  npx --yes pumuki analytics hotspots diagnose --json
262
262
 
263
+ # local deterministic loop sessions (fail-fast gate per attempt)
264
+ npx --yes pumuki loop run --objective="stabilize gate before commit" --max-attempts=3 --json
265
+ npx --yes pumuki loop status --session=<session-id> --json
266
+ npx --yes pumuki loop stop --session=<session-id> --json
267
+ npx --yes pumuki loop resume --session=<session-id> --json
268
+ npx --yes pumuki loop list --json
269
+ npx --yes pumuki loop export --session=<session-id> --output-json=.audit-reports/loop-session.json
270
+
263
271
  # update dependency to latest and re-apply hooks
264
272
  npx --yes pumuki update --latest
265
273
 
@@ -285,6 +293,12 @@ npm run skills:import:custom -- --source /abs/path/to/SKILL.md --source ./skills
285
293
  When no modules remain, it also prunes orphan `node_modules/.package-lock.json` residue.
286
294
  Plain `npm uninstall pumuki` removes only the dependency; it does not remove managed hooks or lifecycle state.
287
295
 
296
+ Loop runtime behavior:
297
+ - `pumuki loop run` creates a session in `.pumuki/loop-sessions/`.
298
+ - Each run executes one gate attempt on `workingTree`.
299
+ - Gate policy is strict fail-fast: a blocked attempt returns exit code `1` and status `blocked`.
300
+ - Per-attempt evidence is persisted as `.pumuki/loop-sessions/<session-id>.attempt-<n>.json`.
301
+
288
302
  OpenSpec integration behavior:
289
303
  - `pumuki install` auto-bootstraps OpenSpec (`@fission-ai/openspec`) when missing/incompatible and scaffolds `openspec/` project baseline when absent.
290
304
  - `pumuki update --latest` migrates legacy `openspec` package to `@fission-ai/openspec` before hook reinstall.
@@ -1,5 +1,7 @@
1
1
  import { mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, isAbsolute, relative, resolve } from 'node:path';
3
+ import type { GatePolicy } from '../../core/gate/GatePolicy';
4
+ import { runPlatformGate } from '../git/runPlatformGate';
3
5
  import { runLifecycleDoctor, type LifecycleDoctorReport } from './doctor';
4
6
  import { runLifecycleInstall } from './install';
5
7
  import { runLifecycleRemove } from './remove';
@@ -7,6 +9,13 @@ import { readLifecycleStatus } from './status';
7
9
  import { runLifecycleUninstall } from './uninstall';
8
10
  import { runLifecycleUpdate } from './update';
9
11
  import { runLifecycleAdapterInstall, type AdapterAgent } from './adapter';
12
+ import { createLoopSessionContract, isLoopSessionTransitionAllowed } from './loopSessionContract';
13
+ import {
14
+ createLoopSession,
15
+ listLoopSessions,
16
+ readLoopSession,
17
+ updateLoopSession,
18
+ } from './loopSessionStore';
10
19
  import {
11
20
  closeSddSession,
12
21
  evaluateSddPolicy,
@@ -34,11 +43,13 @@ type LifecycleCommand =
34
43
  | 'update'
35
44
  | 'doctor'
36
45
  | 'status'
46
+ | 'loop'
37
47
  | 'sdd'
38
48
  | 'adapter'
39
49
  | 'analytics';
40
50
 
41
51
  type SddCommand = 'status' | 'validate' | 'session';
52
+ type LoopCommand = 'run' | 'status' | 'stop' | 'resume' | 'list' | 'export';
42
53
  type AnalyticsCommand = 'hotspots';
43
54
  type AnalyticsHotspotsCommand = 'report' | 'diagnose';
44
55
 
@@ -50,6 +61,11 @@ type ParsedArgs = {
50
61
  updateSpec?: string;
51
62
  json: boolean;
52
63
  sddCommand?: SddCommand;
64
+ loopCommand?: LoopCommand;
65
+ loopSessionId?: string;
66
+ loopObjective?: string;
67
+ loopMaxAttempts?: number;
68
+ loopOutputJsonPath?: string;
53
69
  sddStage?: SddStage;
54
70
  sddSessionAction?: SddSessionAction;
55
71
  sddChangeId?: string;
@@ -73,6 +89,12 @@ Pumuki lifecycle commands:
73
89
  pumuki update [--latest|--spec=<package-spec>]
74
90
  pumuki doctor
75
91
  pumuki status
92
+ pumuki loop run --objective=<text> [--max-attempts=<n>] [--json]
93
+ pumuki loop status --session=<session-id> [--json]
94
+ pumuki loop stop --session=<session-id> [--json]
95
+ pumuki loop resume --session=<session-id> [--json]
96
+ pumuki loop list [--json]
97
+ pumuki loop export --session=<session-id> [--output-json=<path>] [--json]
76
98
  pumuki adapter install --agent=<name> [--dry-run] [--json]
77
99
  pumuki analytics hotspots report [--top=<n>] [--since-days=<n>] [--json] [--output-json=<path>] [--output-markdown=<path>]
78
100
  pumuki analytics hotspots diagnose [--json]
@@ -83,6 +105,12 @@ Pumuki lifecycle commands:
83
105
  pumuki sdd session --close [--json]
84
106
  `.trim();
85
107
 
108
+ const LOOP_RUN_POLICY: GatePolicy = {
109
+ stage: 'STAGED',
110
+ blockOnOrAbove: 'ERROR',
111
+ warnOnOrAbove: 'WARN',
112
+ };
113
+
86
114
  const writeInfo = (message: string): void => {
87
115
  process.stdout.write(`${message}\n`);
88
116
  };
@@ -105,6 +133,7 @@ const isLifecycleCommand = (value: string): value is LifecycleCommand =>
105
133
  value === 'update' ||
106
134
  value === 'doctor' ||
107
135
  value === 'status' ||
136
+ value === 'loop' ||
108
137
  value === 'sdd' ||
109
138
  value === 'adapter' ||
110
139
  value === 'analytics';
@@ -361,6 +390,11 @@ export const parseLifecycleCliArgs = (argv: ReadonlyArray<string>): ParsedArgs =
361
390
  let updateSpec: ParsedArgs['updateSpec'];
362
391
  let json = false;
363
392
  let sddCommand: ParsedArgs['sddCommand'];
393
+ let loopCommand: ParsedArgs['loopCommand'];
394
+ let loopSessionId: ParsedArgs['loopSessionId'];
395
+ let loopObjective: ParsedArgs['loopObjective'];
396
+ let loopMaxAttempts: ParsedArgs['loopMaxAttempts'];
397
+ let loopOutputJsonPath: ParsedArgs['loopOutputJsonPath'];
364
398
  let sddStage: ParsedArgs['sddStage'];
365
399
  let sddSessionAction: ParsedArgs['sddSessionAction'];
366
400
  let sddChangeId: ParsedArgs['sddChangeId'];
@@ -447,6 +481,96 @@ export const parseLifecycleCliArgs = (argv: ReadonlyArray<string>): ParsedArgs =
447
481
  return parsedAnalyticsArgs;
448
482
  }
449
483
 
484
+ if (commandRaw === 'loop') {
485
+ const subcommandRaw = argv[1] ?? '';
486
+ if (
487
+ subcommandRaw !== 'run' &&
488
+ subcommandRaw !== 'status' &&
489
+ subcommandRaw !== 'stop' &&
490
+ subcommandRaw !== 'resume' &&
491
+ subcommandRaw !== 'list' &&
492
+ subcommandRaw !== 'export'
493
+ ) {
494
+ throw new Error(`Unsupported loop subcommand "${subcommandRaw}".\n\n${HELP_TEXT}`);
495
+ }
496
+ loopCommand = subcommandRaw;
497
+ for (const arg of argv.slice(2)) {
498
+ if (arg === '--json') {
499
+ json = true;
500
+ continue;
501
+ }
502
+ if (arg.startsWith('--session=')) {
503
+ loopSessionId = arg.slice('--session='.length).trim();
504
+ continue;
505
+ }
506
+ if (arg.startsWith('--objective=')) {
507
+ loopObjective = arg.slice('--objective='.length).trim();
508
+ continue;
509
+ }
510
+ if (arg.startsWith('--max-attempts=')) {
511
+ const parsedAttempts = Number.parseInt(arg.slice('--max-attempts='.length), 10);
512
+ if (!Number.isInteger(parsedAttempts) || parsedAttempts <= 0) {
513
+ throw new Error(`Invalid --max-attempts value "${arg}".`);
514
+ }
515
+ loopMaxAttempts = parsedAttempts;
516
+ continue;
517
+ }
518
+ if (arg.startsWith('--output-json=')) {
519
+ loopOutputJsonPath = parseOutputPathFlag(
520
+ arg.slice('--output-json='.length),
521
+ '--output-json'
522
+ );
523
+ continue;
524
+ }
525
+ throw new Error(`Unsupported argument "${arg}".\n\n${HELP_TEXT}`);
526
+ }
527
+
528
+ if (loopCommand === 'run') {
529
+ if (!loopObjective || loopObjective.length === 0) {
530
+ throw new Error(`Missing --objective=<text> for "pumuki loop run".\n\n${HELP_TEXT}`);
531
+ }
532
+ return {
533
+ command: commandRaw,
534
+ purgeArtifacts: false,
535
+ json,
536
+ loopCommand,
537
+ loopObjective,
538
+ loopMaxAttempts: loopMaxAttempts ?? 3,
539
+ };
540
+ }
541
+ if (loopCommand === 'list') {
542
+ if (loopSessionId || loopObjective || typeof loopMaxAttempts === 'number' || loopOutputJsonPath) {
543
+ throw new Error(`"pumuki loop list" only supports [--json].\n\n${HELP_TEXT}`);
544
+ }
545
+ return {
546
+ command: commandRaw,
547
+ purgeArtifacts: false,
548
+ json,
549
+ loopCommand,
550
+ };
551
+ }
552
+ if (!loopSessionId || loopSessionId.length === 0) {
553
+ throw new Error(`Missing --session=<session-id> for "pumuki loop ${loopCommand}".\n\n${HELP_TEXT}`);
554
+ }
555
+ if (loopObjective || typeof loopMaxAttempts === 'number') {
556
+ throw new Error(`Unsupported run-only flags for "pumuki loop ${loopCommand}".\n\n${HELP_TEXT}`);
557
+ }
558
+ if (loopCommand !== 'export' && loopOutputJsonPath) {
559
+ throw new Error(`--output-json is only supported with "pumuki loop export".\n\n${HELP_TEXT}`);
560
+ }
561
+ const parsedLoopArgs: ParsedArgs = {
562
+ command: commandRaw,
563
+ purgeArtifacts: false,
564
+ json,
565
+ loopCommand,
566
+ loopSessionId,
567
+ };
568
+ if (loopOutputJsonPath) {
569
+ parsedLoopArgs.loopOutputJsonPath = loopOutputJsonPath;
570
+ }
571
+ return parsedLoopArgs;
572
+ }
573
+
450
574
  if (commandRaw === 'sdd') {
451
575
  const subcommandRaw = argv[1] ?? 'status';
452
576
  if (
@@ -637,10 +761,12 @@ type PreWriteValidationEnvelope = {
637
761
 
638
762
  type LifecycleCliDependencies = {
639
763
  emitAuditSummaryNotificationFromAiGate: typeof emitAuditSummaryNotificationFromAiGate;
764
+ runPlatformGate: typeof runPlatformGate;
640
765
  };
641
766
 
642
767
  const defaultLifecycleCliDependencies: LifecycleCliDependencies = {
643
768
  emitAuditSummaryNotificationFromAiGate,
769
+ runPlatformGate,
644
770
  };
645
771
 
646
772
  const resolveSddDecisionLocation = (
@@ -693,6 +819,29 @@ const buildPreWriteValidationEnvelope = (
693
819
  },
694
820
  });
695
821
 
822
+ const writeLoopAttemptEvidence = (params: {
823
+ repoRoot: string;
824
+ sessionId: string;
825
+ attempt: number;
826
+ payload: {
827
+ session_id: string;
828
+ attempt: number;
829
+ started_at: string;
830
+ finished_at: string;
831
+ gate_exit_code: number;
832
+ gate_allowed: boolean;
833
+ gate_code: string;
834
+ policy: GatePolicy;
835
+ scope: 'workingTree';
836
+ };
837
+ }): string => {
838
+ const relativePath = `.pumuki/loop-sessions/${params.sessionId}.attempt-${params.attempt}.json`;
839
+ const absolutePath = resolve(params.repoRoot, relativePath);
840
+ mkdirSync(dirname(absolutePath), { recursive: true });
841
+ writeFileSync(absolutePath, `${JSON.stringify(params.payload, null, 2)}\n`, 'utf8');
842
+ return relativePath;
843
+ };
844
+
696
845
  export const runLifecycleCli = async (
697
846
  argv: ReadonlyArray<string>,
698
847
  dependencies: Partial<LifecycleCliDependencies> = {}
@@ -779,6 +928,173 @@ export const runLifecycleCli = async (
779
928
  }
780
929
  return 0;
781
930
  }
931
+ case 'loop': {
932
+ const repoRoot = process.cwd();
933
+ if (parsed.loopCommand === 'run') {
934
+ const session = createLoopSessionContract({
935
+ objective: parsed.loopObjective ?? '',
936
+ maxAttempts: parsed.loopMaxAttempts ?? 3,
937
+ });
938
+ createLoopSession({
939
+ repoRoot,
940
+ session,
941
+ });
942
+ const attemptNumber = session.current_attempt + 1;
943
+ const startedAt = new Date().toISOString();
944
+ const gateExitCode = await activeDependencies.runPlatformGate({
945
+ policy: LOOP_RUN_POLICY,
946
+ scope: {
947
+ kind: 'workingTree',
948
+ },
949
+ auditMode: 'engine',
950
+ });
951
+ const finishedAt = new Date().toISOString();
952
+ const gateAllowed = gateExitCode === 0;
953
+ const gateCode = gateAllowed ? 'ALLOW' : 'BLOCK';
954
+ const evidencePath = writeLoopAttemptEvidence({
955
+ repoRoot,
956
+ sessionId: session.session_id,
957
+ attempt: attemptNumber,
958
+ payload: {
959
+ session_id: session.session_id,
960
+ attempt: attemptNumber,
961
+ started_at: startedAt,
962
+ finished_at: finishedAt,
963
+ gate_exit_code: gateExitCode,
964
+ gate_allowed: gateAllowed,
965
+ gate_code: gateCode,
966
+ policy: LOOP_RUN_POLICY,
967
+ scope: 'workingTree',
968
+ },
969
+ });
970
+ const updatedSession: typeof session = {
971
+ ...session,
972
+ status: gateAllowed ? 'running' : 'blocked',
973
+ updated_at: finishedAt,
974
+ current_attempt: attemptNumber,
975
+ attempts: [
976
+ ...session.attempts,
977
+ {
978
+ attempt: attemptNumber,
979
+ started_at: startedAt,
980
+ finished_at: finishedAt,
981
+ outcome: gateAllowed ? 'pass' : 'block',
982
+ gate_allowed: gateAllowed,
983
+ gate_code: gateCode,
984
+ evidence_path: evidencePath,
985
+ },
986
+ ],
987
+ };
988
+ updateLoopSession({
989
+ repoRoot,
990
+ session: updatedSession,
991
+ });
992
+ if (parsed.json) {
993
+ writeInfo(JSON.stringify(updatedSession, null, 2));
994
+ } else {
995
+ writeInfo(
996
+ `[pumuki][loop] session created: id=${updatedSession.session_id} status=${updatedSession.status} attempt=${updatedSession.current_attempt}/${updatedSession.max_attempts}`
997
+ );
998
+ writeInfo(
999
+ `[pumuki][loop] attempt #${attemptNumber} gate=${gateCode} evidence=${evidencePath}`
1000
+ );
1001
+ }
1002
+ return gateAllowed ? 0 : 1;
1003
+ }
1004
+ if (parsed.loopCommand === 'list') {
1005
+ const sessions = listLoopSessions(repoRoot);
1006
+ if (parsed.json) {
1007
+ writeInfo(JSON.stringify(sessions, null, 2));
1008
+ } else {
1009
+ writeInfo(`[pumuki][loop] sessions=${sessions.length}`);
1010
+ for (const session of sessions) {
1011
+ writeInfo(
1012
+ `[pumuki][loop] ${session.session_id} status=${session.status} attempt=${session.current_attempt}/${session.max_attempts} updated_at=${session.updated_at}`
1013
+ );
1014
+ }
1015
+ }
1016
+ return 0;
1017
+ }
1018
+
1019
+ const sessionRead = readLoopSession({
1020
+ repoRoot,
1021
+ sessionId: parsed.loopSessionId ?? '',
1022
+ });
1023
+ if (sessionRead.kind === 'missing') {
1024
+ writeError(`[pumuki][loop] session not found: ${parsed.loopSessionId}`);
1025
+ return 1;
1026
+ }
1027
+ if (sessionRead.kind === 'invalid') {
1028
+ writeError(
1029
+ `[pumuki][loop] invalid session "${parsed.loopSessionId}": ${sessionRead.reason}`
1030
+ );
1031
+ return 1;
1032
+ }
1033
+ if (parsed.loopCommand === 'status') {
1034
+ if (parsed.json) {
1035
+ writeInfo(JSON.stringify(sessionRead.session, null, 2));
1036
+ } else {
1037
+ writeInfo(
1038
+ `[pumuki][loop] ${sessionRead.session.session_id} status=${sessionRead.session.status} attempt=${sessionRead.session.current_attempt}/${sessionRead.session.max_attempts} updated_at=${sessionRead.session.updated_at}`
1039
+ );
1040
+ }
1041
+ return 0;
1042
+ }
1043
+ if (parsed.loopCommand === 'export') {
1044
+ const outputPath = toLocalOutputAbsolutePath(
1045
+ repoRoot,
1046
+ parsed.loopOutputJsonPath ??
1047
+ `.audit-reports/loop-session-${sessionRead.session.session_id}.json`
1048
+ );
1049
+ mkdirSync(dirname(outputPath), { recursive: true });
1050
+ writeFileSync(outputPath, `${JSON.stringify(sessionRead.session, null, 2)}\n`, 'utf8');
1051
+ if (parsed.json) {
1052
+ writeInfo(
1053
+ JSON.stringify(
1054
+ {
1055
+ session_id: sessionRead.session.session_id,
1056
+ output_json: parsed.loopOutputJsonPath ??
1057
+ `.audit-reports/loop-session-${sessionRead.session.session_id}.json`,
1058
+ },
1059
+ null,
1060
+ 2
1061
+ )
1062
+ );
1063
+ } else {
1064
+ writeInfo(
1065
+ `[pumuki][loop] export completed: session=${sessionRead.session.session_id} path=${parsed.loopOutputJsonPath ??
1066
+ `.audit-reports/loop-session-${sessionRead.session.session_id}.json`}`
1067
+ );
1068
+ }
1069
+ return 0;
1070
+ }
1071
+
1072
+ const nextStatus: 'stopped' | 'running' =
1073
+ parsed.loopCommand === 'stop' ? 'stopped' : 'running';
1074
+ if (!isLoopSessionTransitionAllowed(sessionRead.session.status, nextStatus)) {
1075
+ writeError(
1076
+ `[pumuki][loop] invalid transition ${sessionRead.session.status} -> ${nextStatus} for ${sessionRead.session.session_id}`
1077
+ );
1078
+ return 1;
1079
+ }
1080
+ const updated: typeof sessionRead.session = {
1081
+ ...sessionRead.session,
1082
+ status: nextStatus,
1083
+ updated_at: new Date().toISOString(),
1084
+ };
1085
+ updateLoopSession({
1086
+ repoRoot,
1087
+ session: updated,
1088
+ });
1089
+ if (parsed.json) {
1090
+ writeInfo(JSON.stringify(updated, null, 2));
1091
+ } else {
1092
+ writeInfo(
1093
+ `[pumuki][loop] session ${updated.session_id} status=${updated.status}`
1094
+ );
1095
+ }
1096
+ return 0;
1097
+ }
782
1098
  case 'analytics': {
783
1099
  if (parsed.analyticsCommand === 'hotspots' && parsed.analyticsHotspotsCommand === 'report') {
784
1100
  const repoRoot = process.cwd();
@@ -74,6 +74,20 @@ export {
74
74
  appendOperationalMemorySnapshotFromLocalSignals,
75
75
  resolveOperationalMemorySnapshotsPath,
76
76
  } from './operationalMemorySnapshot';
77
+ export {
78
+ createLoopSessionContract,
79
+ createLoopSessionId,
80
+ isLoopSessionTransitionAllowed,
81
+ parseLoopSessionContract,
82
+ } from './loopSessionContract';
83
+ export {
84
+ createLoopSession,
85
+ listLoopSessions,
86
+ readLoopSession,
87
+ resolveLoopSessionPath,
88
+ resolveLoopSessionsDirectory,
89
+ updateLoopSession,
90
+ } from './loopSessionStore';
77
91
  export type {
78
92
  CreateHotspotsSaasIngestionPayloadParams,
79
93
  HotspotsSaasIngestionPayloadBodyCompat,
@@ -107,6 +121,20 @@ export type {
107
121
  AppendOperationalMemorySnapshotFromLocalSignalsResult,
108
122
  OperationalMemorySnapshotV1,
109
123
  } from './operationalMemorySnapshot';
124
+ export type {
125
+ CreateLoopSessionContractParams,
126
+ LoopSessionAttemptOutcome,
127
+ LoopSessionAttemptV1,
128
+ LoopSessionContractV1,
129
+ LoopSessionStatus,
130
+ ParseLoopSessionContractResult,
131
+ } from './loopSessionContract';
132
+ export type {
133
+ ReadLoopSessionParams,
134
+ ReadLoopSessionResult,
135
+ UpsertLoopSessionParams,
136
+ WriteLoopSessionResult,
137
+ } from './loopSessionStore';
110
138
  export type { BuildHotspotsSaasIngestionPayloadFromLocalParams } from './saasIngestionBuilder';
111
139
  export type {
112
140
  HotspotsSaasIngestionAuthPolicy,
@@ -0,0 +1,142 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { z } from 'zod';
3
+
4
+ const loopSessionAttemptSchema = z
5
+ .object({
6
+ attempt: z.number().int().positive(),
7
+ started_at: z.string().datetime({ offset: true }),
8
+ finished_at: z.string().datetime({ offset: true }),
9
+ outcome: z.enum(['pass', 'block', 'error', 'stopped']),
10
+ gate_allowed: z.boolean(),
11
+ gate_code: z.string().min(1),
12
+ evidence_path: z.string().min(1).optional(),
13
+ notes: z.string().min(1).optional(),
14
+ })
15
+ .strict();
16
+
17
+ const loopSessionContractSchema = z
18
+ .object({
19
+ version: z.literal('1'),
20
+ session_id: z.string().min(1),
21
+ objective: z.string().min(1),
22
+ status: z.enum(['running', 'blocked', 'stopped', 'completed']),
23
+ created_at: z.string().datetime({ offset: true }),
24
+ updated_at: z.string().datetime({ offset: true }),
25
+ max_attempts: z.number().int().positive(),
26
+ current_attempt: z.number().int().min(0),
27
+ attempts: z.array(loopSessionAttemptSchema),
28
+ })
29
+ .strict();
30
+
31
+ const loopSessionTransitions: Readonly<Record<LoopSessionStatus, ReadonlyArray<LoopSessionStatus>>> = {
32
+ running: ['blocked', 'stopped', 'completed'],
33
+ blocked: ['running', 'stopped', 'completed'],
34
+ stopped: ['running'],
35
+ completed: [],
36
+ };
37
+
38
+ const parseValidationError = (error: z.ZodError): string =>
39
+ error.issues[0]
40
+ ? `${error.issues[0].path.join('.') || 'contract'}: ${error.issues[0].message}`
41
+ : 'invalid_loop_session_contract';
42
+
43
+ export type LoopSessionStatus = z.infer<typeof loopSessionContractSchema>['status'];
44
+ export type LoopSessionAttemptOutcome = z.infer<typeof loopSessionAttemptSchema>['outcome'];
45
+ export type LoopSessionAttemptV1 = z.infer<typeof loopSessionAttemptSchema>;
46
+ export type LoopSessionContractV1 = z.infer<typeof loopSessionContractSchema>;
47
+
48
+ export type CreateLoopSessionContractParams = {
49
+ sessionId?: string;
50
+ objective: string;
51
+ generatedAt?: string;
52
+ maxAttempts: number;
53
+ };
54
+
55
+ export type ParseLoopSessionContractResult =
56
+ | {
57
+ kind: 'valid';
58
+ contract: LoopSessionContractV1;
59
+ }
60
+ | {
61
+ kind: 'invalid';
62
+ reason: string;
63
+ };
64
+
65
+ export const createLoopSessionId = (): string => `loop-${randomUUID()}`;
66
+
67
+ export const isLoopSessionTransitionAllowed = (
68
+ current: LoopSessionStatus,
69
+ next: LoopSessionStatus
70
+ ): boolean => {
71
+ if (current === next) {
72
+ return false;
73
+ }
74
+ return loopSessionTransitions[current].includes(next);
75
+ };
76
+
77
+ export const createLoopSessionContract = (
78
+ params: CreateLoopSessionContractParams
79
+ ): LoopSessionContractV1 => {
80
+ const generatedAt = params.generatedAt ?? new Date().toISOString();
81
+ return loopSessionContractSchema.parse({
82
+ version: '1',
83
+ session_id: params.sessionId?.trim() || createLoopSessionId(),
84
+ objective: params.objective.trim(),
85
+ status: 'running',
86
+ created_at: generatedAt,
87
+ updated_at: generatedAt,
88
+ max_attempts: params.maxAttempts,
89
+ current_attempt: 0,
90
+ attempts: [],
91
+ });
92
+ };
93
+
94
+ const hasValidAttemptOrder = (attempts: ReadonlyArray<LoopSessionAttemptV1>): boolean => {
95
+ let expected = 1;
96
+ for (const attempt of attempts) {
97
+ if (attempt.attempt !== expected) {
98
+ return false;
99
+ }
100
+ expected += 1;
101
+ }
102
+ return true;
103
+ };
104
+
105
+ export const parseLoopSessionContract = (candidate: unknown): ParseLoopSessionContractResult => {
106
+ const parsed = loopSessionContractSchema.safeParse(candidate);
107
+ if (!parsed.success) {
108
+ return {
109
+ kind: 'invalid',
110
+ reason: parseValidationError(parsed.error),
111
+ };
112
+ }
113
+ const contract = parsed.data;
114
+ if (contract.current_attempt > contract.max_attempts) {
115
+ return {
116
+ kind: 'invalid',
117
+ reason: `current_attempt exceeds max_attempts (${contract.current_attempt} > ${contract.max_attempts})`,
118
+ };
119
+ }
120
+ if (contract.attempts.length > contract.max_attempts) {
121
+ return {
122
+ kind: 'invalid',
123
+ reason: `attempts length exceeds max_attempts (${contract.attempts.length} > ${contract.max_attempts})`,
124
+ };
125
+ }
126
+ if (contract.current_attempt !== contract.attempts.length) {
127
+ return {
128
+ kind: 'invalid',
129
+ reason: `current_attempt must match attempts length (${contract.current_attempt} != ${contract.attempts.length})`,
130
+ };
131
+ }
132
+ if (!hasValidAttemptOrder(contract.attempts)) {
133
+ return {
134
+ kind: 'invalid',
135
+ reason: 'attempt sequence must be contiguous starting at 1',
136
+ };
137
+ }
138
+ return {
139
+ kind: 'valid',
140
+ contract,
141
+ };
142
+ };
@@ -0,0 +1,167 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { isAbsolute, resolve, join } from 'node:path';
3
+ import {
4
+ parseLoopSessionContract,
5
+ type LoopSessionContractV1,
6
+ } from './loopSessionContract';
7
+
8
+ const DEFAULT_LOOP_SESSIONS_DIRECTORY = '.pumuki/loop-sessions';
9
+
10
+ const normalizeSessionId = (sessionId: string): string => {
11
+ const normalized = sessionId.trim();
12
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(normalized)) {
13
+ throw new Error(`Invalid loop session id "${sessionId}".`);
14
+ }
15
+ return normalized;
16
+ };
17
+
18
+ const toAbsolutePath = (repoRoot: string, maybeRelative: string): string => {
19
+ if (isAbsolute(maybeRelative)) {
20
+ return resolve(maybeRelative);
21
+ }
22
+ return resolve(repoRoot, maybeRelative);
23
+ };
24
+
25
+ export type ReadLoopSessionParams = {
26
+ repoRoot: string;
27
+ sessionId: string;
28
+ };
29
+
30
+ export type UpsertLoopSessionParams = {
31
+ repoRoot: string;
32
+ session: LoopSessionContractV1;
33
+ };
34
+
35
+ export type ReadLoopSessionResult =
36
+ | {
37
+ kind: 'missing';
38
+ path: string;
39
+ }
40
+ | {
41
+ kind: 'invalid';
42
+ path: string;
43
+ reason: string;
44
+ }
45
+ | {
46
+ kind: 'valid';
47
+ path: string;
48
+ session: LoopSessionContractV1;
49
+ };
50
+
51
+ export type WriteLoopSessionResult = {
52
+ path: string;
53
+ bytes: number;
54
+ };
55
+
56
+ export const resolveLoopSessionsDirectory = (repoRoot: string): string => {
57
+ const configured = process.env.PUMUKI_LOOP_SESSIONS_DIR?.trim();
58
+ const candidate =
59
+ configured && configured.length > 0
60
+ ? configured
61
+ : DEFAULT_LOOP_SESSIONS_DIRECTORY;
62
+ return toAbsolutePath(repoRoot, candidate);
63
+ };
64
+
65
+ export const resolveLoopSessionPath = (repoRoot: string, sessionId: string): string =>
66
+ join(resolveLoopSessionsDirectory(repoRoot), `${normalizeSessionId(sessionId)}.json`);
67
+
68
+ const parseStoredSession = (path: string): ReadLoopSessionResult => {
69
+ try {
70
+ const raw = readFileSync(path, 'utf8');
71
+ const parsed = JSON.parse(raw) as unknown;
72
+ const contractParsed = parseLoopSessionContract(parsed);
73
+ if (contractParsed.kind === 'invalid') {
74
+ return {
75
+ kind: 'invalid',
76
+ path,
77
+ reason: contractParsed.reason,
78
+ };
79
+ }
80
+ return {
81
+ kind: 'valid',
82
+ path,
83
+ session: contractParsed.contract,
84
+ };
85
+ } catch (error) {
86
+ const reason = error instanceof Error ? error.message : 'invalid_loop_session_json';
87
+ return {
88
+ kind: 'invalid',
89
+ path,
90
+ reason,
91
+ };
92
+ }
93
+ };
94
+
95
+ export const readLoopSession = (params: ReadLoopSessionParams): ReadLoopSessionResult => {
96
+ const path = resolveLoopSessionPath(params.repoRoot, params.sessionId);
97
+ if (!existsSync(path)) {
98
+ return {
99
+ kind: 'missing',
100
+ path,
101
+ };
102
+ }
103
+ return parseStoredSession(path);
104
+ };
105
+
106
+ const validateSessionBeforeWrite = (session: LoopSessionContractV1): LoopSessionContractV1 => {
107
+ const parsed = parseLoopSessionContract(session);
108
+ if (parsed.kind === 'invalid') {
109
+ throw new Error(`Invalid loop session contract: ${parsed.reason}`);
110
+ }
111
+ return parsed.contract;
112
+ };
113
+
114
+ export const createLoopSession = (params: UpsertLoopSessionParams): WriteLoopSessionResult => {
115
+ const contract = validateSessionBeforeWrite(params.session);
116
+ const path = resolveLoopSessionPath(params.repoRoot, contract.session_id);
117
+ if (existsSync(path)) {
118
+ throw new Error(`Loop session already exists at "${path}".`);
119
+ }
120
+ mkdirSync(resolveLoopSessionsDirectory(params.repoRoot), { recursive: true });
121
+ const serialized = `${JSON.stringify(contract, null, 2)}\n`;
122
+ writeFileSync(path, serialized, 'utf8');
123
+ return {
124
+ path,
125
+ bytes: Buffer.byteLength(serialized, 'utf8'),
126
+ };
127
+ };
128
+
129
+ export const updateLoopSession = (params: UpsertLoopSessionParams): WriteLoopSessionResult => {
130
+ const contract = validateSessionBeforeWrite(params.session);
131
+ const path = resolveLoopSessionPath(params.repoRoot, contract.session_id);
132
+ if (!existsSync(path)) {
133
+ throw new Error(`Loop session does not exist at "${path}".`);
134
+ }
135
+ mkdirSync(resolveLoopSessionsDirectory(params.repoRoot), { recursive: true });
136
+ const serialized = `${JSON.stringify(contract, null, 2)}\n`;
137
+ writeFileSync(path, serialized, 'utf8');
138
+ return {
139
+ path,
140
+ bytes: Buffer.byteLength(serialized, 'utf8'),
141
+ };
142
+ };
143
+
144
+ export const listLoopSessions = (repoRoot: string): ReadonlyArray<LoopSessionContractV1> => {
145
+ const directory = resolveLoopSessionsDirectory(repoRoot);
146
+ if (!existsSync(directory)) {
147
+ return [];
148
+ }
149
+ const sessions: Array<LoopSessionContractV1> = [];
150
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
151
+ if (!entry.isFile() || !entry.name.endsWith('.json')) {
152
+ continue;
153
+ }
154
+ const result = parseStoredSession(join(directory, entry.name));
155
+ if (result.kind === 'valid') {
156
+ sessions.push(result.session);
157
+ }
158
+ }
159
+ return sessions.sort((a, b) => {
160
+ const updatedA = new Date(a.updated_at).getTime();
161
+ const updatedB = new Date(b.updated_at).getTime();
162
+ if (updatedA !== updatedB) {
163
+ return updatedB - updatedA;
164
+ }
165
+ return a.session_id.localeCompare(b.session_id);
166
+ });
167
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pumuki",
3
- "version": "6.3.22",
3
+ "version": "6.3.24",
4
4
  "description": "Enterprise-grade AST Intelligence System with multi-platform support (iOS, Android, Backend, Frontend) and Feature-First + DDD + Clean Architecture enforcement. Includes dynamic violations API for intelligent querying.",
5
5
  "main": "index.js",
6
6
  "bin": {