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 +19 -1
- package/VERSION +1 -1
- package/docs/REFRACTOR_PROGRESS.md +43 -2
- package/docs/RELEASE_NOTES.md +19 -0
- package/docs/USAGE.md +15 -1
- package/integrations/lifecycle/cli.ts +316 -0
- package/integrations/lifecycle/index.ts +28 -0
- package/integrations/lifecycle/loopSessionContract.ts +142 -0
- package/integrations/lifecycle/loopSessionStore.ts +167 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Pumuki
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<img src="assets/logo.png" alt="Pumuki" width="100%" />
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/pumuki)
|
|
6
6
|
[](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.
|
|
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
|
-
-
|
|
123
|
-
-
|
|
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.
|
package/docs/RELEASE_NOTES.md
CHANGED
|
@@ -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.
|
|
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": {
|