@pugi/cli 0.1.0-beta.27 → 0.1.0-beta.29
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/dist/core/artifact-chain/dispatcher.js +148 -0
- package/dist/core/artifact-chain/exporter.js +164 -0
- package/dist/core/artifact-chain/state.js +243 -0
- package/dist/core/artifact-chain/steps.js +169 -0
- package/dist/core/repl/session.js +32 -0
- package/dist/core/repl/slash-commands.js +10 -0
- package/dist/runtime/cli.js +42 -0
- package/dist/runtime/commands/chain.js +489 -0
- package/dist/runtime/commands/delegate.js +23 -0
- package/dist/runtime/version.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact chain dispatcher — Pugi α7 Wave 6 (2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* Bridges the chain state machine (`state.ts`) to the existing
|
|
5
|
+
* `pugi delegate` surface. Each step renders a brief from the
|
|
6
|
+
* step's template + chain context, dispatches the brief to the
|
|
7
|
+
* step's persona, and writes the persona's response verbatim to the
|
|
8
|
+
* artifact file on disk.
|
|
9
|
+
*
|
|
10
|
+
* Mock-friendly by contract — the dispatcher accepts an injected
|
|
11
|
+
* `dispatch` function so specs can drive every branch without
|
|
12
|
+
* standing up a real runtime + Anvil round-trip. The real wire-up
|
|
13
|
+
* binds `dispatch` to the existing `submitDelegate` SDK helper +
|
|
14
|
+
* the SSE waiter; that wiring lives in `runtime/commands/chain.ts`
|
|
15
|
+
* so this module stays pure with respect to the network.
|
|
16
|
+
*
|
|
17
|
+
* Module contract:
|
|
18
|
+
*
|
|
19
|
+
* - `dispatchStep` is the ONLY entry point. It reads the chain,
|
|
20
|
+
* renders the brief, calls the injected dispatcher, persists the
|
|
21
|
+
* artifact, and flips the step state. Callers do not touch the
|
|
22
|
+
* state machine directly — that surface is intentionally narrow.
|
|
23
|
+
*
|
|
24
|
+
* - The dispatcher never auto-advances. After a step lands the
|
|
25
|
+
* cursor stays on the same step until the operator runs
|
|
26
|
+
* `pugi chain next` again (or until `markComplete` is invoked
|
|
27
|
+
* by the CLI handler after operator approval).
|
|
28
|
+
*/
|
|
29
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
import { chainDir, markComplete, markDispatched, markError, readChain, } from './state.js';
|
|
32
|
+
import { findStep, renderBrief, } from './steps.js';
|
|
33
|
+
/**
|
|
34
|
+
* Dispatch one step in the chain. The function is non-throwing — every
|
|
35
|
+
* failure mode returns a structured result so the CLI handler can map
|
|
36
|
+
* to an exit code without a try/catch wrapper.
|
|
37
|
+
*/
|
|
38
|
+
export async function dispatchStep(options) {
|
|
39
|
+
const now = options.now ?? (() => new Date());
|
|
40
|
+
const state = readChain(options.workspaceCwd, options.chainId);
|
|
41
|
+
if (!state) {
|
|
42
|
+
// Gemini P1 fix (PR #608): the docblock above promises a non-
|
|
43
|
+
// throwing surface, but the historical implementation threw here.
|
|
44
|
+
// Return a structured result keyed on `reason: 'chain_not_found'`
|
|
45
|
+
// so callers can switch on the failure family deterministically.
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
stepId: 'prd',
|
|
49
|
+
error: `chain ${options.chainId} not found`,
|
|
50
|
+
reason: 'chain_not_found',
|
|
51
|
+
state: null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const targetStepId = options.stepId ?? state.nextStep;
|
|
55
|
+
if (!targetStepId) {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
stepId: 'code',
|
|
59
|
+
error: 'chain is finalised — every step is already complete',
|
|
60
|
+
state,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const step = findStep(targetStepId);
|
|
64
|
+
if (!step) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
stepId: targetStepId,
|
|
68
|
+
error: `unknown step id '${targetStepId}'`,
|
|
69
|
+
state,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const stepRecord = state.steps.find((s) => s.id === targetStepId);
|
|
73
|
+
if (stepRecord?.status === 'complete') {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
stepId: targetStepId,
|
|
77
|
+
error: `step '${targetStepId}' is already complete`,
|
|
78
|
+
state,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const brief = renderBrief(step.briefTemplate, {
|
|
82
|
+
chainId: state.id,
|
|
83
|
+
intent: state.intent,
|
|
84
|
+
});
|
|
85
|
+
let outcome;
|
|
86
|
+
try {
|
|
87
|
+
outcome = await options.dispatch({ chainId: state.id, step, brief });
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
91
|
+
const errorState = markError(options.workspaceCwd, options.chainId, targetStepId, message, { now });
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
stepId: targetStepId,
|
|
95
|
+
error: message,
|
|
96
|
+
state: errorState,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (outcome.kind === 'failed') {
|
|
100
|
+
const errorState = markError(options.workspaceCwd, options.chainId, targetStepId, outcome.error, { now });
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
stepId: targetStepId,
|
|
104
|
+
error: outcome.error,
|
|
105
|
+
state: errorState,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
// Success path: persist the artifact, flip the step to dispatched
|
|
109
|
+
// (or complete when autoApprove is set).
|
|
110
|
+
const dir = chainDir(options.workspaceCwd, state.id);
|
|
111
|
+
mkdirSync(dir, { recursive: true });
|
|
112
|
+
const artifactPath = join(dir, step.artifactFilename);
|
|
113
|
+
writeFileSync(artifactPath, ensureTrailingNewline(outcome.artifact), 'utf8');
|
|
114
|
+
let finalState = markDispatched(options.workspaceCwd, options.chainId, targetStepId, outcome.dispatchId, { now });
|
|
115
|
+
if (options.autoApprove) {
|
|
116
|
+
finalState = markComplete(options.workspaceCwd, options.chainId, targetStepId, { now });
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
ok: true,
|
|
120
|
+
stepId: targetStepId,
|
|
121
|
+
dispatchId: outcome.dispatchId,
|
|
122
|
+
artifactPath,
|
|
123
|
+
state: finalState,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Synthesize a dispatcher that captures every call for inspection.
|
|
128
|
+
* Test-only — the real wire-up never uses this.
|
|
129
|
+
*/
|
|
130
|
+
export function createRecordingDispatcher(outcomes) {
|
|
131
|
+
const calls = [];
|
|
132
|
+
let index = 0;
|
|
133
|
+
const fn = async ({ chainId, step, brief }) => {
|
|
134
|
+
calls.push({ chainId, stepId: step.id, brief });
|
|
135
|
+
const outcome = outcomes[index];
|
|
136
|
+
index += 1;
|
|
137
|
+
if (!outcome) {
|
|
138
|
+
throw new Error(`recording dispatcher exhausted at call #${calls.length}`);
|
|
139
|
+
}
|
|
140
|
+
return outcome;
|
|
141
|
+
};
|
|
142
|
+
fn.calls = calls;
|
|
143
|
+
return fn;
|
|
144
|
+
}
|
|
145
|
+
function ensureTrailingNewline(text) {
|
|
146
|
+
return text.endsWith('\n') ? text : `${text}\n`;
|
|
147
|
+
}
|
|
148
|
+
//# sourceMappingURL=dispatcher.js.map
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact chain exporter — Pugi α7 Wave 6 (2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* Bundles the seven step artifacts under `.pugi/chains/<id>/` into a
|
|
5
|
+
* single markdown report (default) or a deterministic JSON envelope
|
|
6
|
+
* the operator can feed into a future zip bundler. The exporter is
|
|
7
|
+
* read-only — it never mutates state.
|
|
8
|
+
*
|
|
9
|
+
* Why markdown instead of an actual `.zip`: the CLI ships zero binary
|
|
10
|
+
* dependencies (HARD rule for `@pugi/cli` install footprint — the
|
|
11
|
+
* 4.4MB bundled-zip toolchains would land the install at ~20MB).
|
|
12
|
+
* Operators that want a true zip pipe `pugi chain export` through
|
|
13
|
+
* `zip -j chain.zip` or similar; the markdown form covers 95% of the
|
|
14
|
+
* "share with my CTO" workflow without the dep.
|
|
15
|
+
*
|
|
16
|
+
* Module contract:
|
|
17
|
+
*
|
|
18
|
+
* - `renderMarkdownReport` is pure. Pass it a snapshot + a map of
|
|
19
|
+
* filename → contents and it returns the rendered report. The
|
|
20
|
+
* CLI handler does the disk read; the spec drives the renderer
|
|
21
|
+
* with synthetic content so it never touches the filesystem.
|
|
22
|
+
*
|
|
23
|
+
* - `exportChain` does the disk dance — reads every artifact that
|
|
24
|
+
* exists, skips missing ones (a partially-finished chain still
|
|
25
|
+
* exports cleanly), and returns the report + the list of
|
|
26
|
+
* skipped steps so the renderer can warn the operator.
|
|
27
|
+
*/
|
|
28
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
import { chainDir, readChain } from './state.js';
|
|
31
|
+
import { CHAIN_STEPS } from './steps.js';
|
|
32
|
+
/**
|
|
33
|
+
* Render the markdown report. The format is intentionally stable so
|
|
34
|
+
* downstream tools can grep it; do NOT reformat without bumping the
|
|
35
|
+
* report version comment in the header.
|
|
36
|
+
*
|
|
37
|
+
* Report layout:
|
|
38
|
+
*
|
|
39
|
+
* # Pugi artifact chain: <chain id>
|
|
40
|
+
*
|
|
41
|
+
* - **Intent**: ...
|
|
42
|
+
* - **Created**: ...
|
|
43
|
+
* - **Finalised**: ... (or "in progress")
|
|
44
|
+
*
|
|
45
|
+
* ## 1. PRD — Olivia (PM)
|
|
46
|
+
* <contents>
|
|
47
|
+
*
|
|
48
|
+
* ## 2. ADR — Marcus (CTO)
|
|
49
|
+
* <contents>
|
|
50
|
+
*
|
|
51
|
+
* ... (one section per step)
|
|
52
|
+
*/
|
|
53
|
+
export function renderMarkdownReport(envelope) {
|
|
54
|
+
const lines = [];
|
|
55
|
+
lines.push(`# Pugi artifact chain: ${envelope.chainId}`);
|
|
56
|
+
lines.push('');
|
|
57
|
+
lines.push(`- **Intent**: ${envelope.intent}`);
|
|
58
|
+
lines.push(`- **Created**: ${envelope.createdAt}`);
|
|
59
|
+
lines.push(`- **Finalised**: ${envelope.finalisedAt ?? 'in progress'}`);
|
|
60
|
+
if (envelope.missingSteps.length > 0) {
|
|
61
|
+
lines.push(`- **Missing steps**: ${envelope.missingSteps.join(', ')}`);
|
|
62
|
+
}
|
|
63
|
+
lines.push('');
|
|
64
|
+
for (const entry of envelope.artifacts) {
|
|
65
|
+
lines.push(`## ${entry.step.ordinal}. ${entry.step.id.toUpperCase()} — ${entry.step.personaLabel}`);
|
|
66
|
+
lines.push('');
|
|
67
|
+
if (entry.contents === null) {
|
|
68
|
+
lines.push(`_artifact not yet produced (${entry.step.artifactFilename})_`);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
const trimmed = entry.contents.trimEnd();
|
|
72
|
+
lines.push(trimmed.length === 0 ? '_empty artifact_' : trimmed);
|
|
73
|
+
}
|
|
74
|
+
lines.push('');
|
|
75
|
+
}
|
|
76
|
+
return lines.join('\n');
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Render a deterministic JSON envelope. Same shape as
|
|
80
|
+
* `ChainExportEnvelope` minus the descriptor object (we serialise the
|
|
81
|
+
* step id + ordinal + persona so the consumer does not need access to
|
|
82
|
+
* the in-process descriptor table).
|
|
83
|
+
*/
|
|
84
|
+
export function renderJsonReport(envelope) {
|
|
85
|
+
const serialisable = {
|
|
86
|
+
chainId: envelope.chainId,
|
|
87
|
+
intent: envelope.intent,
|
|
88
|
+
createdAt: envelope.createdAt,
|
|
89
|
+
finalisedAt: envelope.finalisedAt,
|
|
90
|
+
missingSteps: envelope.missingSteps,
|
|
91
|
+
artifacts: envelope.artifacts.map((entry) => ({
|
|
92
|
+
stepId: entry.step.id,
|
|
93
|
+
ordinal: entry.step.ordinal,
|
|
94
|
+
persona: entry.step.persona,
|
|
95
|
+
personaLabel: entry.step.personaLabel,
|
|
96
|
+
filename: entry.step.artifactFilename,
|
|
97
|
+
bytes: entry.bytes,
|
|
98
|
+
contents: entry.contents,
|
|
99
|
+
})),
|
|
100
|
+
};
|
|
101
|
+
return `${JSON.stringify(serialisable, null, 2)}\n`;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Read the chain + every artifact on disk, build the envelope, and
|
|
105
|
+
* render either markdown OR JSON. Returns BOTH the envelope and the
|
|
106
|
+
* rendered string so callers can route to stdout, a file, or both.
|
|
107
|
+
*
|
|
108
|
+
* Non-throwing per the artifact-chain module contract — every failure
|
|
109
|
+
* mode (chain not found, malformed state) returns a structured
|
|
110
|
+
* `ok: false` result so callers can map to an exit code without a
|
|
111
|
+
* try/catch wrapper.
|
|
112
|
+
*/
|
|
113
|
+
export function exportChain(options) {
|
|
114
|
+
const state = readChain(options.workspaceCwd, options.chainId);
|
|
115
|
+
if (!state) {
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
error: `chain ${options.chainId} not found`,
|
|
119
|
+
reason: 'chain_not_found',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const envelope = buildEnvelope(state, options.workspaceCwd);
|
|
123
|
+
const format = options.format ?? 'markdown';
|
|
124
|
+
const rendered = format === 'json' ? renderJsonReport(envelope) : renderMarkdownReport(envelope);
|
|
125
|
+
return { ok: true, envelope, rendered };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Build the export envelope from a chain snapshot + the workspace
|
|
129
|
+
* directory. Exported so the spec can drive the renderer against a
|
|
130
|
+
* fixture state without an explicit disk read.
|
|
131
|
+
*/
|
|
132
|
+
export function buildEnvelope(state, workspaceCwd, options = {}) {
|
|
133
|
+
const dir = chainDir(workspaceCwd, state.id);
|
|
134
|
+
const readArtifact = options.readArtifact ?? defaultReadArtifact;
|
|
135
|
+
const artifacts = [];
|
|
136
|
+
const missing = [];
|
|
137
|
+
for (const step of CHAIN_STEPS) {
|
|
138
|
+
const path = join(dir, step.artifactFilename);
|
|
139
|
+
const contents = readArtifact(path);
|
|
140
|
+
if (contents === null) {
|
|
141
|
+
missing.push(step.id);
|
|
142
|
+
}
|
|
143
|
+
artifacts.push({
|
|
144
|
+
step,
|
|
145
|
+
contents,
|
|
146
|
+
bytes: contents === null ? 0 : Buffer.byteLength(contents, 'utf8'),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
const finalisedAt = state.nextStep === null ? state.updatedAt : null;
|
|
150
|
+
return {
|
|
151
|
+
chainId: state.id,
|
|
152
|
+
intent: state.intent,
|
|
153
|
+
createdAt: state.createdAt,
|
|
154
|
+
finalisedAt,
|
|
155
|
+
artifacts,
|
|
156
|
+
missingSteps: missing,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function defaultReadArtifact(path) {
|
|
160
|
+
if (!existsSync(path))
|
|
161
|
+
return null;
|
|
162
|
+
return readFileSync(path, 'utf8');
|
|
163
|
+
}
|
|
164
|
+
//# sourceMappingURL=exporter.js.map
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact chain state machine — Pugi α7 Wave 6 (2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* Owns the on-disk persisted state for a single chain
|
|
5
|
+
* (`.pugi/chains/<chain-id>/chain.json`). A chain is a deterministic
|
|
6
|
+
* 7-step pipeline: each step is `pending` → `dispatched` → `complete`
|
|
7
|
+
* (terminal). The machine never auto-advances; operator review between
|
|
8
|
+
* steps is the gate. This file is pure with respect to the network and
|
|
9
|
+
* stays small enough to import from the REPL hot path without dragging
|
|
10
|
+
* the dispatcher graph along.
|
|
11
|
+
*
|
|
12
|
+
* Concurrency: the CLI is single-process per invocation; the on-disk
|
|
13
|
+
* format tolerates two simultaneous readers (snapshot is atomic via
|
|
14
|
+
* write-then-rename) but writers MUST take the per-chain directory as
|
|
15
|
+
* an implicit lock. Multi-writer races are out-of-scope — the operator
|
|
16
|
+
* runs one `pugi chain next` at a time.
|
|
17
|
+
*
|
|
18
|
+
* Backwards-compat: `schemaVersion` is pinned. Older chains with no
|
|
19
|
+
* version field default to `1`. Bump + add migration logic when the
|
|
20
|
+
* persisted shape changes.
|
|
21
|
+
*/
|
|
22
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, renameSync, readdirSync } from 'node:fs';
|
|
23
|
+
import { join, resolve } from 'node:path';
|
|
24
|
+
import { randomBytes } from 'node:crypto';
|
|
25
|
+
import { CHAIN_STEPS, CHAIN_STEP_IDS, CHAIN_STEP_COUNT, findStep, } from './steps.js';
|
|
26
|
+
/**
|
|
27
|
+
* Generate a new chain id. Format: `chn_<8 lowercase hex>`. Short
|
|
28
|
+
* enough to fit on one terminal column; collision probability is fine
|
|
29
|
+
* for the operator-scale workload (chains per workspace per session).
|
|
30
|
+
*/
|
|
31
|
+
export function generateChainId(rng = () => randomBytes(4)) {
|
|
32
|
+
return `chn_${rng().toString('hex')}`;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the directory that holds a single chain. Encapsulates the
|
|
36
|
+
* `.pugi/chains/<id>/` convention so every caller agrees.
|
|
37
|
+
*/
|
|
38
|
+
export function chainDir(workspaceCwd, chainId) {
|
|
39
|
+
return resolve(workspaceCwd, '.pugi', 'chains', chainId);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Path to the chain's state JSON. Atomic writes go through a sibling
|
|
43
|
+
* `chain.json.tmp` + rename so a crashed CLI cannot leave a truncated
|
|
44
|
+
* state file behind.
|
|
45
|
+
*/
|
|
46
|
+
export function chainStatePath(workspaceCwd, chainId) {
|
|
47
|
+
return join(chainDir(workspaceCwd, chainId), 'chain.json');
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Initialise on-disk state for a fresh chain. Returns the persisted
|
|
51
|
+
* snapshot. Throws when the chain id already exists on disk so the
|
|
52
|
+
* caller is forced to handle the collision explicitly.
|
|
53
|
+
*/
|
|
54
|
+
export function createChain(workspaceCwd, intent, options = {}) {
|
|
55
|
+
const now = options.now ?? (() => new Date());
|
|
56
|
+
const id = options.chainId ?? generateChainId();
|
|
57
|
+
const dir = chainDir(workspaceCwd, id);
|
|
58
|
+
if (existsSync(dir)) {
|
|
59
|
+
throw new Error(`chain ${id} already exists at ${dir}`);
|
|
60
|
+
}
|
|
61
|
+
mkdirSync(dir, { recursive: true });
|
|
62
|
+
const createdAt = now().toISOString();
|
|
63
|
+
const trimmedIntent = intent.trim();
|
|
64
|
+
if (trimmedIntent.length === 0) {
|
|
65
|
+
throw new Error('chain intent must be non-empty');
|
|
66
|
+
}
|
|
67
|
+
const steps = CHAIN_STEPS.map((descriptor) => ({
|
|
68
|
+
id: descriptor.id,
|
|
69
|
+
status: 'pending',
|
|
70
|
+
updatedAt: createdAt,
|
|
71
|
+
dispatchId: null,
|
|
72
|
+
errorMessage: null,
|
|
73
|
+
}));
|
|
74
|
+
const state = {
|
|
75
|
+
schemaVersion: 1,
|
|
76
|
+
id,
|
|
77
|
+
intent: trimmedIntent,
|
|
78
|
+
createdAt,
|
|
79
|
+
updatedAt: createdAt,
|
|
80
|
+
nextStep: CHAIN_STEP_IDS[0] ?? null,
|
|
81
|
+
steps,
|
|
82
|
+
};
|
|
83
|
+
writeStateAtomic(workspaceCwd, state);
|
|
84
|
+
return Object.freeze(state);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Read the persisted state for a chain. Returns `null` when the chain
|
|
88
|
+
* does not exist on disk — callers render a friendly "no such chain"
|
|
89
|
+
* message instead of throwing.
|
|
90
|
+
*/
|
|
91
|
+
export function readChain(workspaceCwd, chainId) {
|
|
92
|
+
const path = chainStatePath(workspaceCwd, chainId);
|
|
93
|
+
if (!existsSync(path))
|
|
94
|
+
return null;
|
|
95
|
+
const raw = readFileSync(path, 'utf8');
|
|
96
|
+
const parsed = JSON.parse(raw);
|
|
97
|
+
if (parsed.schemaVersion !== 1 || typeof parsed.id !== 'string') {
|
|
98
|
+
throw new Error(`chain ${chainId} state is malformed (schemaVersion mismatch)`);
|
|
99
|
+
}
|
|
100
|
+
// Migrate any missing optional fields to defaults so a partially-
|
|
101
|
+
// hand-edited state file still loads.
|
|
102
|
+
const steps = Array.isArray(parsed.steps) ? parsed.steps : [];
|
|
103
|
+
if (steps.length !== CHAIN_STEP_COUNT) {
|
|
104
|
+
throw new Error(`chain ${chainId} state has ${steps.length} steps; expected ${CHAIN_STEP_COUNT}`);
|
|
105
|
+
}
|
|
106
|
+
return Object.freeze({
|
|
107
|
+
schemaVersion: 1,
|
|
108
|
+
id: parsed.id,
|
|
109
|
+
intent: parsed.intent ?? '',
|
|
110
|
+
createdAt: parsed.createdAt ?? new Date(0).toISOString(),
|
|
111
|
+
updatedAt: parsed.updatedAt ?? new Date(0).toISOString(),
|
|
112
|
+
nextStep: parsed.nextStep ?? null,
|
|
113
|
+
steps: steps.map((s) => ({
|
|
114
|
+
id: s.id,
|
|
115
|
+
status: (s.status ?? 'pending'),
|
|
116
|
+
updatedAt: s.updatedAt ?? parsed.updatedAt ?? new Date(0).toISOString(),
|
|
117
|
+
dispatchId: s.dispatchId ?? null,
|
|
118
|
+
errorMessage: s.errorMessage ?? null,
|
|
119
|
+
})),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* List every chain id present under `.pugi/chains/`. Returns an empty
|
|
124
|
+
* array when the directory does not exist — operators rarely call this
|
|
125
|
+
* before `pugi chain new`.
|
|
126
|
+
*/
|
|
127
|
+
export function listChainIds(workspaceCwd) {
|
|
128
|
+
const root = join(workspaceCwd, '.pugi', 'chains');
|
|
129
|
+
if (!existsSync(root))
|
|
130
|
+
return [];
|
|
131
|
+
return readdirSync(root, { withFileTypes: true })
|
|
132
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith('chn_'))
|
|
133
|
+
.map((entry) => entry.name)
|
|
134
|
+
.sort();
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Mark a step as dispatched. Idempotent re-dispatch is allowed (the
|
|
138
|
+
* operator may re-run a step after a transient failure); the previous
|
|
139
|
+
* dispatchId is overwritten.
|
|
140
|
+
*/
|
|
141
|
+
export function markDispatched(workspaceCwd, chainId, stepId, dispatchId, options = {}) {
|
|
142
|
+
return transition(workspaceCwd, chainId, options.now ?? (() => new Date()), (state) => {
|
|
143
|
+
const step = state.steps.find((s) => s.id === stepId);
|
|
144
|
+
if (!step) {
|
|
145
|
+
throw new Error(`chain ${chainId}: unknown step ${stepId}`);
|
|
146
|
+
}
|
|
147
|
+
if (step.status === 'complete') {
|
|
148
|
+
throw new Error(`chain ${chainId}: step ${stepId} already complete`);
|
|
149
|
+
}
|
|
150
|
+
step.status = 'dispatched';
|
|
151
|
+
step.dispatchId = dispatchId;
|
|
152
|
+
step.errorMessage = null;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Mark a step as complete. Operator approves between steps so this is
|
|
157
|
+
* the final gate — when the operator runs `pugi chain next` AFTER the
|
|
158
|
+
* artifact lands, the dispatcher first flips the current step to
|
|
159
|
+
* complete then advances the cursor.
|
|
160
|
+
*/
|
|
161
|
+
export function markComplete(workspaceCwd, chainId, stepId, options = {}) {
|
|
162
|
+
return transition(workspaceCwd, chainId, options.now ?? (() => new Date()), (state) => {
|
|
163
|
+
const step = state.steps.find((s) => s.id === stepId);
|
|
164
|
+
if (!step) {
|
|
165
|
+
throw new Error(`chain ${chainId}: unknown step ${stepId}`);
|
|
166
|
+
}
|
|
167
|
+
step.status = 'complete';
|
|
168
|
+
step.errorMessage = null;
|
|
169
|
+
state.nextStep = firstPendingStep(state.steps);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Capture an error against the current step without flipping it to
|
|
174
|
+
* complete. The step stays in `dispatched` so the operator can re-run.
|
|
175
|
+
*/
|
|
176
|
+
export function markError(workspaceCwd, chainId, stepId, errorMessage, options = {}) {
|
|
177
|
+
return transition(workspaceCwd, chainId, options.now ?? (() => new Date()), (state) => {
|
|
178
|
+
const step = state.steps.find((s) => s.id === stepId);
|
|
179
|
+
if (!step) {
|
|
180
|
+
throw new Error(`chain ${chainId}: unknown step ${stepId}`);
|
|
181
|
+
}
|
|
182
|
+
step.errorMessage = errorMessage;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Compute the next pending step. Returns `null` when every step is
|
|
187
|
+
* complete (chain finalised).
|
|
188
|
+
*/
|
|
189
|
+
export function firstPendingStep(steps) {
|
|
190
|
+
for (const id of CHAIN_STEP_IDS) {
|
|
191
|
+
const record = steps.find((s) => s.id === id);
|
|
192
|
+
if (!record || record.status !== 'complete')
|
|
193
|
+
return id;
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Resolve the descriptor for the current cursor. Returns `null` when
|
|
199
|
+
* the chain is finalised. Convenience wrapper so renderers do not need
|
|
200
|
+
* to handle the lookup themselves.
|
|
201
|
+
*/
|
|
202
|
+
export function currentStepDescriptor(state) {
|
|
203
|
+
if (!state.nextStep)
|
|
204
|
+
return null;
|
|
205
|
+
return findStep(state.nextStep) ?? null;
|
|
206
|
+
}
|
|
207
|
+
/* ------------------------------------------------------------------ */
|
|
208
|
+
/* Internal: transactional mutate helper */
|
|
209
|
+
/* ------------------------------------------------------------------ */
|
|
210
|
+
function transition(workspaceCwd, chainId, now, mutate) {
|
|
211
|
+
const current = readChain(workspaceCwd, chainId);
|
|
212
|
+
if (!current) {
|
|
213
|
+
throw new Error(`chain ${chainId} not found at ${chainStatePath(workspaceCwd, chainId)}`);
|
|
214
|
+
}
|
|
215
|
+
// Deep clone so the frozen snapshot is never mutated.
|
|
216
|
+
const draft = JSON.parse(JSON.stringify(current));
|
|
217
|
+
mutate(draft);
|
|
218
|
+
draft.updatedAt = now().toISOString();
|
|
219
|
+
// Update step-level timestamp for whichever step the mutation
|
|
220
|
+
// touched. We diff status / dispatchId / errorMessage against the
|
|
221
|
+
// previous snapshot — any change bumps the step's updatedAt.
|
|
222
|
+
for (const draftStep of draft.steps) {
|
|
223
|
+
const previousStep = current.steps.find((s) => s.id === draftStep.id);
|
|
224
|
+
if (!previousStep)
|
|
225
|
+
continue;
|
|
226
|
+
if (draftStep.status !== previousStep.status ||
|
|
227
|
+
draftStep.dispatchId !== previousStep.dispatchId ||
|
|
228
|
+
draftStep.errorMessage !== previousStep.errorMessage) {
|
|
229
|
+
draftStep.updatedAt = draft.updatedAt;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
writeStateAtomic(workspaceCwd, draft);
|
|
233
|
+
return Object.freeze(draft);
|
|
234
|
+
}
|
|
235
|
+
function writeStateAtomic(workspaceCwd, state) {
|
|
236
|
+
const dir = chainDir(workspaceCwd, state.id);
|
|
237
|
+
mkdirSync(dir, { recursive: true });
|
|
238
|
+
const finalPath = chainStatePath(workspaceCwd, state.id);
|
|
239
|
+
const tmpPath = `${finalPath}.tmp`;
|
|
240
|
+
writeFileSync(tmpPath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
|
241
|
+
renameSync(tmpPath, finalPath);
|
|
242
|
+
}
|
|
243
|
+
//# sourceMappingURL=state.js.map
|