@hegemonart/get-design-done 1.19.5 → 1.20.0
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/.claude-plugin/marketplace.json +4 -4
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +90 -0
- package/README.md +12 -0
- package/agents/design-auditor.md +12 -0
- package/agents/design-discussant.md +14 -0
- package/agents/design-reflector.md +23 -0
- package/connections/connections.md +3 -0
- package/connections/figma.md +2 -0
- package/connections/gdd-state.md +186 -0
- package/hooks/budget-enforcer.ts +716 -0
- package/hooks/context-exhaustion.ts +251 -0
- package/hooks/gdd-read-injection-scanner.ts +172 -0
- package/hooks/hooks.json +3 -3
- package/package.json +29 -7
- package/reference/authority-feeds.md +4 -2
- package/reference/checklists.md +30 -0
- package/reference/component-authoring.md +184 -0
- package/reference/config-schema.md +2 -2
- package/reference/emotional-design.md +124 -0
- package/reference/error-recovery.md +58 -0
- package/reference/first-principles.md +89 -0
- package/reference/heuristics.md +70 -0
- package/reference/motion-advanced.md +192 -3
- package/reference/registry.json +28 -0
- package/reference/schemas/budget.schema.json +42 -0
- package/reference/schemas/events.schema.json +55 -0
- package/reference/schemas/generated.d.ts +419 -0
- package/reference/schemas/iteration-budget.schema.json +36 -0
- package/reference/schemas/mcp-gdd-state-tools.schema.json +89 -0
- package/reference/schemas/rate-limits.schema.json +31 -0
- package/reference/shared-preamble.md +10 -0
- package/scripts/aggregate-agent-metrics.ts +282 -0
- package/scripts/codegen-schema-types.ts +149 -0
- package/scripts/lib/error-classifier.cjs +232 -0
- package/scripts/lib/error-classifier.d.cts +44 -0
- package/scripts/lib/event-stream/emitter.ts +88 -0
- package/scripts/lib/event-stream/index.ts +154 -0
- package/scripts/lib/event-stream/types.ts +127 -0
- package/scripts/lib/event-stream/writer.ts +154 -0
- package/scripts/lib/gdd-errors/classification.ts +124 -0
- package/scripts/lib/gdd-errors/index.ts +218 -0
- package/scripts/lib/gdd-state/gates.ts +216 -0
- package/scripts/lib/gdd-state/index.ts +167 -0
- package/scripts/lib/gdd-state/lockfile.ts +232 -0
- package/scripts/lib/gdd-state/mutator.ts +574 -0
- package/scripts/lib/gdd-state/parser.ts +523 -0
- package/scripts/lib/gdd-state/types.ts +179 -0
- package/scripts/lib/iteration-budget.cjs +205 -0
- package/scripts/lib/iteration-budget.d.cts +32 -0
- package/scripts/lib/jittered-backoff.cjs +112 -0
- package/scripts/lib/jittered-backoff.d.cts +38 -0
- package/scripts/lib/lockfile.cjs +177 -0
- package/scripts/lib/lockfile.d.cts +21 -0
- package/scripts/lib/prompt-sanitizer/index.ts +435 -0
- package/scripts/lib/prompt-sanitizer/patterns.ts +173 -0
- package/scripts/lib/rate-guard.cjs +365 -0
- package/scripts/lib/rate-guard.d.cts +38 -0
- package/scripts/mcp-servers/gdd-state/schemas/add_blocker.schema.json +67 -0
- package/scripts/mcp-servers/gdd-state/schemas/add_decision.schema.json +68 -0
- package/scripts/mcp-servers/gdd-state/schemas/add_must_have.schema.json +68 -0
- package/scripts/mcp-servers/gdd-state/schemas/checkpoint.schema.json +51 -0
- package/scripts/mcp-servers/gdd-state/schemas/frontmatter_update.schema.json +62 -0
- package/scripts/mcp-servers/gdd-state/schemas/get.schema.json +51 -0
- package/scripts/mcp-servers/gdd-state/schemas/probe_connections.schema.json +75 -0
- package/scripts/mcp-servers/gdd-state/schemas/resolve_blocker.schema.json +66 -0
- package/scripts/mcp-servers/gdd-state/schemas/set_status.schema.json +47 -0
- package/scripts/mcp-servers/gdd-state/schemas/transition_stage.schema.json +70 -0
- package/scripts/mcp-servers/gdd-state/schemas/update_progress.schema.json +58 -0
- package/scripts/mcp-servers/gdd-state/server.ts +288 -0
- package/scripts/mcp-servers/gdd-state/tools/add_blocker.ts +72 -0
- package/scripts/mcp-servers/gdd-state/tools/add_decision.ts +89 -0
- package/scripts/mcp-servers/gdd-state/tools/add_must_have.ts +113 -0
- package/scripts/mcp-servers/gdd-state/tools/checkpoint.ts +60 -0
- package/scripts/mcp-servers/gdd-state/tools/frontmatter_update.ts +91 -0
- package/scripts/mcp-servers/gdd-state/tools/get.ts +51 -0
- package/scripts/mcp-servers/gdd-state/tools/index.ts +51 -0
- package/scripts/mcp-servers/gdd-state/tools/probe_connections.ts +73 -0
- package/scripts/mcp-servers/gdd-state/tools/resolve_blocker.ts +84 -0
- package/scripts/mcp-servers/gdd-state/tools/set_status.ts +54 -0
- package/scripts/mcp-servers/gdd-state/tools/shared.ts +194 -0
- package/scripts/mcp-servers/gdd-state/tools/transition_stage.ts +80 -0
- package/scripts/mcp-servers/gdd-state/tools/update_progress.ts +81 -0
- package/scripts/validate-frontmatter.ts +114 -0
- package/scripts/validate-schemas.ts +401 -0
- package/skills/brief/SKILL.md +15 -6
- package/skills/design/SKILL.md +31 -13
- package/skills/explore/SKILL.md +41 -17
- package/skills/health/SKILL.md +15 -4
- package/skills/optimize/SKILL.md +3 -3
- package/skills/pause/SKILL.md +16 -10
- package/skills/plan/SKILL.md +33 -17
- package/skills/progress/SKILL.md +15 -11
- package/skills/resume/SKILL.md +19 -10
- package/skills/settings/SKILL.md +11 -3
- package/skills/todo/SKILL.md +12 -3
- package/skills/verify/SKILL.md +65 -29
- package/hooks/budget-enforcer.js +0 -329
- package/hooks/context-exhaustion.js +0 -127
- package/hooks/gdd-read-injection-scanner.js +0 -39
- package/scripts/aggregate-agent-metrics.js +0 -173
- package/scripts/validate-frontmatter.cjs +0 -68
- package/scripts/validate-schemas.cjs +0 -242
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// scripts/lib/gdd-errors/index.ts — unified GDD error taxonomy.
|
|
2
|
+
//
|
|
3
|
+
// Three classes exactly — mirrors the GSD errors.ts discipline:
|
|
4
|
+
//
|
|
5
|
+
// * ValidationError — throw at boundary; "fix your input"
|
|
6
|
+
// * StateConflictError — throw; lockfile contention or transition guard
|
|
7
|
+
// failed; retryable by upstream
|
|
8
|
+
// * OperationFailedError — return in data.error; "couldn't complete in
|
|
9
|
+
// this state"; expected failure mode the caller
|
|
10
|
+
// should branch on, not crash on
|
|
11
|
+
//
|
|
12
|
+
// MCP tool handlers place OperationFailedError instances into data.error
|
|
13
|
+
// so the model can see and reason about them. ValidationError and
|
|
14
|
+
// StateConflictError are thrown (non-zero exit path).
|
|
15
|
+
//
|
|
16
|
+
// Plan 20-01 introduced LockAcquisitionError and TransitionGateFailed as
|
|
17
|
+
// local Error subclasses; this module re-exports taxonomy-compliant
|
|
18
|
+
// versions so the existing `gdd-state` surface (tests + consumers) keeps
|
|
19
|
+
// working unchanged. Plan 20-05 wires `toToolError` into MCP tool
|
|
20
|
+
// handlers; Plan 20-06 emits error events to the telemetry stream.
|
|
21
|
+
|
|
22
|
+
/** Short machine-readable code. Example: "VALIDATION_MISSING_FIELD". */
|
|
23
|
+
export type GDDErrorCode = string;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Abstract base class — every GDD taxonomy error inherits from this.
|
|
27
|
+
*
|
|
28
|
+
* Subclasses set a literal `kind` discriminant so `classify()` can
|
|
29
|
+
* branch on it without `instanceof` chains, and `toJSON()` produces a
|
|
30
|
+
* lossless payload for tool-result transport.
|
|
31
|
+
*/
|
|
32
|
+
export abstract class GDDError extends Error {
|
|
33
|
+
abstract readonly kind: 'validation' | 'state_conflict' | 'operation_failed';
|
|
34
|
+
readonly code: GDDErrorCode;
|
|
35
|
+
readonly context: Readonly<Record<string, unknown>>;
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
message: string,
|
|
39
|
+
code: GDDErrorCode,
|
|
40
|
+
context: Record<string, unknown> = {},
|
|
41
|
+
) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.code = code;
|
|
44
|
+
this.context = Object.freeze({ ...context });
|
|
45
|
+
// Set .name to the concrete subclass name (LockAcquisitionError,
|
|
46
|
+
// ValidationError, etc.) so error serialization is human-meaningful.
|
|
47
|
+
this.name = new.target.name;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Serialize to a plain object safe for JSON.stringify. Round-trips
|
|
52
|
+
* through JSON without loss of `name`, `kind`, `code`, `message`, or
|
|
53
|
+
* `context`. Does NOT include the stack trace — MCP tool handlers do
|
|
54
|
+
* not forward stacks to the model.
|
|
55
|
+
*/
|
|
56
|
+
toJSON(): {
|
|
57
|
+
name: string;
|
|
58
|
+
kind: GDDError['kind'];
|
|
59
|
+
code: GDDErrorCode;
|
|
60
|
+
message: string;
|
|
61
|
+
context: Readonly<Record<string, unknown>>;
|
|
62
|
+
} {
|
|
63
|
+
return {
|
|
64
|
+
name: this.name,
|
|
65
|
+
kind: this.kind,
|
|
66
|
+
code: this.code,
|
|
67
|
+
message: this.message,
|
|
68
|
+
context: this.context,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Throw at boundary when the caller's input is malformed.
|
|
75
|
+
*
|
|
76
|
+
* Example: MCP tool handler receives an argument that fails schema
|
|
77
|
+
* validation; the correct response is `throw new ValidationError(...)`
|
|
78
|
+
* so the harness catches it and returns a structured error to the model.
|
|
79
|
+
* The model should fix its input and retry.
|
|
80
|
+
*/
|
|
81
|
+
export class ValidationError extends GDDError {
|
|
82
|
+
readonly kind = 'validation' as const;
|
|
83
|
+
constructor(
|
|
84
|
+
message: string,
|
|
85
|
+
code: GDDErrorCode = 'VALIDATION',
|
|
86
|
+
context?: Record<string, unknown>,
|
|
87
|
+
) {
|
|
88
|
+
super(message, code, context);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Throw when a concurrency primitive or transition guard vetoes the
|
|
94
|
+
* operation. Retryable by upstream — the caller may try again after a
|
|
95
|
+
* backoff, or surface the blocker to the operator.
|
|
96
|
+
*
|
|
97
|
+
* Examples: lockfile contention (LockAcquisitionError), transition
|
|
98
|
+
* gate failure (TransitionGateFailed).
|
|
99
|
+
*/
|
|
100
|
+
export class StateConflictError extends GDDError {
|
|
101
|
+
readonly kind = 'state_conflict' as const;
|
|
102
|
+
constructor(
|
|
103
|
+
message: string,
|
|
104
|
+
code: GDDErrorCode = 'STATE_CONFLICT',
|
|
105
|
+
context?: Record<string, unknown>,
|
|
106
|
+
) {
|
|
107
|
+
super(message, code, context);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Return as `data.error` — do NOT throw. This is the expected failure
|
|
113
|
+
* mode the caller should branch on: the operation is well-formed, the
|
|
114
|
+
* state is valid, but the specific request cannot complete right now.
|
|
115
|
+
*
|
|
116
|
+
* Example: "try to advance to `design`, but no plan exists yet" — the
|
|
117
|
+
* model should be told, not crashed on.
|
|
118
|
+
*/
|
|
119
|
+
export class OperationFailedError extends GDDError {
|
|
120
|
+
readonly kind = 'operation_failed' as const;
|
|
121
|
+
constructor(
|
|
122
|
+
message: string,
|
|
123
|
+
code: GDDErrorCode = 'OPERATION_FAILED',
|
|
124
|
+
context?: Record<string, unknown>,
|
|
125
|
+
) {
|
|
126
|
+
super(message, code, context);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// -----------------------------------------------------------------------
|
|
131
|
+
// Plan 20-01 compatibility re-exports.
|
|
132
|
+
//
|
|
133
|
+
// These keep the `gdd-state` module's public surface stable post-refactor:
|
|
134
|
+
// - LockAcquisitionError — lockfile contention (plan 20-01 lockfile.ts)
|
|
135
|
+
// - TransitionGateFailed — transition gate veto (plan 20-01 transition())
|
|
136
|
+
// Both are StateConflictError subclasses (retryable / contention-class
|
|
137
|
+
// errors).
|
|
138
|
+
// -----------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Error thrown when `acquire()` cannot obtain the lockfile within
|
|
142
|
+
* `maxWaitMs`. Carries the contents of the offending lockfile (as
|
|
143
|
+
* text — may be JSON, may be garbage if corrupted) so callers can
|
|
144
|
+
* surface them to operators.
|
|
145
|
+
*
|
|
146
|
+
* The `lockPath` and `lockContents` instance properties are preserved
|
|
147
|
+
* from the Plan 20-01 shape for API compat; they're also stored in
|
|
148
|
+
* the frozen `context` object for uniform GDDError serialization.
|
|
149
|
+
*/
|
|
150
|
+
export class LockAcquisitionError extends StateConflictError {
|
|
151
|
+
readonly lockPath: string;
|
|
152
|
+
readonly lockContents: string;
|
|
153
|
+
readonly waitedMs: number;
|
|
154
|
+
constructor(
|
|
155
|
+
lockPath: string,
|
|
156
|
+
lockContents: string,
|
|
157
|
+
waitedMs: number,
|
|
158
|
+
context?: Record<string, unknown>,
|
|
159
|
+
) {
|
|
160
|
+
super(
|
|
161
|
+
`failed to acquire lock at ${lockPath} after ${waitedMs}ms; current holder: ${lockContents}`,
|
|
162
|
+
'LOCK_ACQUISITION',
|
|
163
|
+
{ ...context, lockPath, lockContents, waitedMs },
|
|
164
|
+
);
|
|
165
|
+
this.lockPath = lockPath;
|
|
166
|
+
this.lockContents = lockContents;
|
|
167
|
+
this.waitedMs = waitedMs;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Error thrown when a transition gate vetoes a stage advance. Carries
|
|
173
|
+
* the frozen list of blocker messages so callers can surface them to
|
|
174
|
+
* operators or retry after resolving.
|
|
175
|
+
*
|
|
176
|
+
* The `blockers` instance property is preserved from the Plan 20-01
|
|
177
|
+
* shape (readonly string[]) for API compat; it's also mirrored into
|
|
178
|
+
* the frozen `context` object for uniform GDDError serialization.
|
|
179
|
+
*/
|
|
180
|
+
export class TransitionGateFailed extends StateConflictError {
|
|
181
|
+
readonly blockers: readonly string[];
|
|
182
|
+
readonly toStage: string;
|
|
183
|
+
constructor(
|
|
184
|
+
toStage: string,
|
|
185
|
+
blockers: string[],
|
|
186
|
+
context?: Record<string, unknown>,
|
|
187
|
+
) {
|
|
188
|
+
super(
|
|
189
|
+
`transition to "${toStage}" blocked by gate: ${blockers.join('; ') || '(no detail)'}`,
|
|
190
|
+
'TRANSITION_GATE_FAILED',
|
|
191
|
+
{ ...context, toStage, blockers: [...blockers] },
|
|
192
|
+
);
|
|
193
|
+
this.toStage = toStage;
|
|
194
|
+
this.blockers = Object.freeze([...blockers]);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Error thrown by STATE.md `parse()` when the input cannot be
|
|
200
|
+
* interpreted. `ValidationError` semantics: the caller (likely the
|
|
201
|
+
* operator or an upstream generator) gave us malformed input — fix
|
|
202
|
+
* your STATE.md and retry.
|
|
203
|
+
*
|
|
204
|
+
* The `line` instance property points at the 1-indexed line in the
|
|
205
|
+
* source markdown where the parser gave up. It's also mirrored into
|
|
206
|
+
* the frozen `context` object.
|
|
207
|
+
*/
|
|
208
|
+
export class ParseError extends ValidationError {
|
|
209
|
+
readonly line: number;
|
|
210
|
+
constructor(message: string, line: number, context?: Record<string, unknown>) {
|
|
211
|
+
super(
|
|
212
|
+
`STATE.md parse error at line ${line}: ${message}`,
|
|
213
|
+
'PARSE_ERROR',
|
|
214
|
+
{ ...context, line },
|
|
215
|
+
);
|
|
216
|
+
this.line = line;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// scripts/lib/gdd-state/gates.ts — pure transition-gate functions.
|
|
2
|
+
//
|
|
3
|
+
// Plan 20-02 (SDK-03): the typed, single-source-of-truth implementation
|
|
4
|
+
// of "can this pipeline advance?" that replaces prose-encoded guards in
|
|
5
|
+
// each stage's SKILL.md. Each gate is a pure function of a `ParsedState`
|
|
6
|
+
// and returns `{ pass, blockers }` — never throws, never does I/O, never
|
|
7
|
+
// reads the clock or random state. Deterministic for a given input.
|
|
8
|
+
//
|
|
9
|
+
// Gate semantics (distilled from the current SKILL.md guards):
|
|
10
|
+
//
|
|
11
|
+
// briefToExplore
|
|
12
|
+
// Stage-parity check: `frontmatter.stage === 'brief'` AND
|
|
13
|
+
// `position.stage === 'brief'`. No file-existence checks — gates are
|
|
14
|
+
// pure; `.design/BRIEF.md` presence is verified by the caller.
|
|
15
|
+
//
|
|
16
|
+
// exploreToPlan
|
|
17
|
+
// Stage-parity on 'explore'. Additionally `connections` map MUST
|
|
18
|
+
// have at least one entry with status `available` OR `not_configured`
|
|
19
|
+
// (i.e., the probe ran and populated the map). An empty connections
|
|
20
|
+
// map means the probe never ran — fail with an actionable blocker.
|
|
21
|
+
//
|
|
22
|
+
// planToDesign
|
|
23
|
+
// Stage-parity on 'plan'. `must_haves` MUST be non-empty (at minimum
|
|
24
|
+
// M-01 exists). `decisions` MUST contain ≥1 `locked` entry. Any
|
|
25
|
+
// `must_haves` entry with `status: fail` MUST have a matching
|
|
26
|
+
// `decisions` entry to reconcile it — otherwise fail with
|
|
27
|
+
// "M-XX marked fail without a decision to reconcile".
|
|
28
|
+
//
|
|
29
|
+
// designToVerify
|
|
30
|
+
// Stage-parity on 'design'. `must_haves` MUST NOT contain any entry
|
|
31
|
+
// with `status: pending` whose text references a design-stage
|
|
32
|
+
// deliverable (keywords: `component`, `token`, `style`, `copy`,
|
|
33
|
+
// `layout`). Pending entries outside that vocabulary are verify-stage
|
|
34
|
+
// responsibilities and are allowed through.
|
|
35
|
+
//
|
|
36
|
+
// Invalid transitions (e.g., brief→verify skipping explore/plan/design,
|
|
37
|
+
// or verify→verify no-op, or anything not in the forward chain) return
|
|
38
|
+
// `null` from `gateFor()`. The caller turns `null` into a
|
|
39
|
+
// `TransitionGateFailed` with an "Invalid transition" message.
|
|
40
|
+
|
|
41
|
+
import type { ParsedState, Stage } from './types.ts';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Result of a single gate invocation.
|
|
45
|
+
*
|
|
46
|
+
* When `pass === true`, `blockers` is `[]`. When `pass === false`,
|
|
47
|
+
* `blockers` has ≥1 entry and each entry is a non-empty, human-readable
|
|
48
|
+
* one-line reason suitable for surfacing to operators.
|
|
49
|
+
*/
|
|
50
|
+
export interface GateResult {
|
|
51
|
+
pass: boolean;
|
|
52
|
+
blockers: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Signature every gate satisfies. Pure — no I/O, no clock, no randomness. */
|
|
56
|
+
export type GateFn = (state: ParsedState) => GateResult;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Keywords whose presence in a pending `must_haves` line marks the item
|
|
60
|
+
* as a design-stage deliverable (must be resolved BEFORE verify). Kept
|
|
61
|
+
* lowercase-matched on the full text so phrasing like "Primary CTA
|
|
62
|
+
* component" or "color token" both hit.
|
|
63
|
+
*
|
|
64
|
+
* Adjust in lockstep with the designToVerify prose-guard removal in
|
|
65
|
+
* Plans 20-07 through 20-11 — the keyword set there must match this one.
|
|
66
|
+
*/
|
|
67
|
+
const DESIGN_KEYWORDS = ['component', 'token', 'style', 'copy', 'layout'] as const;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Shared parity helper — every gate requires the frontmatter + position
|
|
71
|
+
* stages to agree with the expected FROM stage. Returns `null` on pass
|
|
72
|
+
* (so the caller can keep going) or a blocker string on fail.
|
|
73
|
+
*/
|
|
74
|
+
function stageParity(state: ParsedState, expected: Stage): string | null {
|
|
75
|
+
if (!state.position || typeof state.position.stage !== 'string') {
|
|
76
|
+
return 'position block missing or malformed';
|
|
77
|
+
}
|
|
78
|
+
if (!state.frontmatter || typeof state.frontmatter.stage !== 'string') {
|
|
79
|
+
return 'frontmatter stage missing or malformed';
|
|
80
|
+
}
|
|
81
|
+
if (state.position.stage !== expected) {
|
|
82
|
+
return `position.stage is "${state.position.stage}" — expected "${expected}"`;
|
|
83
|
+
}
|
|
84
|
+
if (state.frontmatter.stage !== expected) {
|
|
85
|
+
return `frontmatter.stage is "${state.frontmatter.stage}" — expected "${expected}"`;
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Brief → Explore: only a stage-parity check.
|
|
92
|
+
*
|
|
93
|
+
* `.design/BRIEF.md` existence is verified by the calling skill — gates
|
|
94
|
+
* are pure and cannot touch the filesystem.
|
|
95
|
+
*/
|
|
96
|
+
export const briefToExplore: GateFn = (s) => {
|
|
97
|
+
const blockers: string[] = [];
|
|
98
|
+
const parity = stageParity(s, 'brief');
|
|
99
|
+
if (parity) blockers.push(parity);
|
|
100
|
+
return { pass: blockers.length === 0, blockers };
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Explore → Plan: stage parity + non-empty connections map.
|
|
105
|
+
*
|
|
106
|
+
* An empty `<connections>` map (no keys at all) means the explore-stage
|
|
107
|
+
* probe never ran. We require at least one entry so we can assert the
|
|
108
|
+
* probe wrote something — the value can be `available` or
|
|
109
|
+
* `not_configured`; both are evidence of a real probe result.
|
|
110
|
+
* `unavailable` alone also counts (probe ran, service down).
|
|
111
|
+
*/
|
|
112
|
+
export const exploreToPlan: GateFn = (s) => {
|
|
113
|
+
const blockers: string[] = [];
|
|
114
|
+
const parity = stageParity(s, 'explore');
|
|
115
|
+
if (parity) blockers.push(parity);
|
|
116
|
+
const connKeys = Object.keys(s.connections ?? {});
|
|
117
|
+
if (connKeys.length === 0) {
|
|
118
|
+
blockers.push(
|
|
119
|
+
'connections map is empty — run the explore-stage probe before advancing',
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return { pass: blockers.length === 0, blockers };
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Plan → Design: stage parity + must_haves non-empty + ≥1 locked
|
|
127
|
+
* decision + every `status: fail` must_have is reconciled by a matching
|
|
128
|
+
* decision ID.
|
|
129
|
+
*
|
|
130
|
+
* Matching rule for reconciliation: a `must_haves` entry `M-XX` with
|
|
131
|
+
* `status: fail` is considered reconciled when ANY `decisions` entry's
|
|
132
|
+
* `text` mentions the substring `M-XX`. This is the convention used in
|
|
133
|
+
* the current SKILL.md prose ("D-03 reconciles M-05 by …"); it is not
|
|
134
|
+
* a structural link yet — Plan 20-08 may promote it to a typed field.
|
|
135
|
+
*/
|
|
136
|
+
export const planToDesign: GateFn = (s) => {
|
|
137
|
+
const blockers: string[] = [];
|
|
138
|
+
const parity = stageParity(s, 'plan');
|
|
139
|
+
if (parity) blockers.push(parity);
|
|
140
|
+
if ((s.must_haves ?? []).length === 0) {
|
|
141
|
+
blockers.push('must_haves is empty — discover stage must land at least M-01');
|
|
142
|
+
}
|
|
143
|
+
const hasLocked = (s.decisions ?? []).some((d) => d.status === 'locked');
|
|
144
|
+
if (!hasLocked) {
|
|
145
|
+
blockers.push('no locked decision in <decisions> — at least one must be locked');
|
|
146
|
+
}
|
|
147
|
+
const failing = (s.must_haves ?? []).filter((m) => m.status === 'fail');
|
|
148
|
+
for (const mh of failing) {
|
|
149
|
+
const reconciled = (s.decisions ?? []).some((d) => d.text.includes(mh.id));
|
|
150
|
+
if (!reconciled) {
|
|
151
|
+
blockers.push(`${mh.id} marked fail without a decision to reconcile`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { pass: blockers.length === 0, blockers };
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Design → Verify: stage parity + no pending must_haves whose text
|
|
159
|
+
* references a design-stage deliverable.
|
|
160
|
+
*
|
|
161
|
+
* Pending items outside the design-keyword set are verify-stage
|
|
162
|
+
* responsibilities (e.g., "ARIA live region announcement") and are
|
|
163
|
+
* allowed through.
|
|
164
|
+
*/
|
|
165
|
+
export const designToVerify: GateFn = (s) => {
|
|
166
|
+
const blockers: string[] = [];
|
|
167
|
+
const parity = stageParity(s, 'design');
|
|
168
|
+
if (parity) blockers.push(parity);
|
|
169
|
+
const pending = (s.must_haves ?? []).filter((m) => m.status === 'pending');
|
|
170
|
+
for (const mh of pending) {
|
|
171
|
+
const lower = mh.text.toLowerCase();
|
|
172
|
+
const hit = DESIGN_KEYWORDS.find((kw) => lower.includes(kw));
|
|
173
|
+
if (hit) {
|
|
174
|
+
blockers.push(
|
|
175
|
+
`${mh.id} is pending and mentions "${hit}" — resolve in design before verify`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return { pass: blockers.length === 0, blockers };
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Registry of every gate keyed by `${from}To${Capitalize<to>}`. Frozen
|
|
184
|
+
* so downstream callers cannot mutate the gate set at runtime.
|
|
185
|
+
*/
|
|
186
|
+
export const GATES: Readonly<{
|
|
187
|
+
briefToExplore: GateFn;
|
|
188
|
+
exploreToPlan: GateFn;
|
|
189
|
+
planToDesign: GateFn;
|
|
190
|
+
designToVerify: GateFn;
|
|
191
|
+
}> = Object.freeze({
|
|
192
|
+
briefToExplore,
|
|
193
|
+
exploreToPlan,
|
|
194
|
+
planToDesign,
|
|
195
|
+
designToVerify,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Pick the gate for a source → target transition.
|
|
200
|
+
*
|
|
201
|
+
* Returns `null` for:
|
|
202
|
+
* - skip-stage transitions (e.g., `brief → verify`),
|
|
203
|
+
* - backward transitions (e.g., `explore → brief`),
|
|
204
|
+
* - same-stage transitions (e.g., `verify → verify`),
|
|
205
|
+
* - any transition whose `from` or `to` is outside the `Stage` union.
|
|
206
|
+
*
|
|
207
|
+
* The caller (`transition()` in `index.ts`) converts a `null` response
|
|
208
|
+
* into a `TransitionGateFailed` carrying an "Invalid transition" message.
|
|
209
|
+
*/
|
|
210
|
+
export function gateFor(from: Stage, to: Stage): GateFn | null {
|
|
211
|
+
if (from === 'brief' && to === 'explore') return briefToExplore;
|
|
212
|
+
if (from === 'explore' && to === 'plan') return exploreToPlan;
|
|
213
|
+
if (from === 'plan' && to === 'design') return planToDesign;
|
|
214
|
+
if (from === 'design' && to === 'verify') return designToVerify;
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// scripts/lib/gdd-state/index.ts — public API for the gdd-state module.
|
|
2
|
+
//
|
|
3
|
+
// This is the ONLY file consumers should import from. The module exposes
|
|
4
|
+
// exactly five surface-level names:
|
|
5
|
+
// * read(path) — parse STATE.md from disk
|
|
6
|
+
// * mutate(path, fn) — atomic read-modify-write under a lock
|
|
7
|
+
// * transition(path, toStage) — gate + stage-advance helper
|
|
8
|
+
// * ParsedState (type) — consumer-visible shape
|
|
9
|
+
// * Stage (type) — stage enum
|
|
10
|
+
//
|
|
11
|
+
// Plan 20-02 wired the real transition gates in via `gateFor(from, to)`
|
|
12
|
+
// imported from `./gates.ts`. Plan 20-04 migrated the error classes
|
|
13
|
+
// (TransitionGateFailed, LockAcquisitionError, ParseError) to the
|
|
14
|
+
// unified `gdd-errors` taxonomy — `types.ts` re-exports them verbatim
|
|
15
|
+
// so consumers of `gdd-state` need no changes.
|
|
16
|
+
|
|
17
|
+
import { readFileSync, writeFileSync, renameSync, unlinkSync, existsSync } from 'node:fs';
|
|
18
|
+
|
|
19
|
+
import { acquire } from './lockfile.ts';
|
|
20
|
+
import { parse } from './parser.ts';
|
|
21
|
+
import { serialize } from './mutator.ts';
|
|
22
|
+
import { gateFor } from './gates.ts';
|
|
23
|
+
import {
|
|
24
|
+
TransitionGateFailed,
|
|
25
|
+
isStage,
|
|
26
|
+
type ParsedState,
|
|
27
|
+
type Stage,
|
|
28
|
+
type TransitionResult,
|
|
29
|
+
} from './types.ts';
|
|
30
|
+
|
|
31
|
+
export type { ParsedState, Stage } from './types.ts';
|
|
32
|
+
export { TransitionGateFailed, LockAcquisitionError, ParseError } from './types.ts';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read STATE.md from disk and return the parsed state.
|
|
36
|
+
*
|
|
37
|
+
* Shared-read: no lock is taken. Reads are snapshot-safe for markdown
|
|
38
|
+
* (the OS guarantees a coherent view even if a writer is mid-rename —
|
|
39
|
+
* we either see the old file or the new file, never a torn write,
|
|
40
|
+
* because `mutate()` uses atomic rename).
|
|
41
|
+
*/
|
|
42
|
+
export async function read(path: string): Promise<ParsedState> {
|
|
43
|
+
const raw: string = readFileSync(path, 'utf8');
|
|
44
|
+
return parse(raw).state;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Atomic read-modify-write on STATE.md.
|
|
49
|
+
*
|
|
50
|
+
* Flow:
|
|
51
|
+
* 1. Acquire sibling `.lock` file (PID+timestamp advisory lock).
|
|
52
|
+
* 2. Read current contents.
|
|
53
|
+
* 3. Apply `fn`.
|
|
54
|
+
* 4. Serialize to a `.tmp` file next to `path`.
|
|
55
|
+
* 5. `renameSync(.tmp, path)` — POSIX-atomic; on Windows EPERM means
|
|
56
|
+
* a scanner held it briefly, retry once.
|
|
57
|
+
* 6. Release the lock (in `finally` — released even on mid-fn throw).
|
|
58
|
+
*
|
|
59
|
+
* Crash between write and rename is benign: STATE.md is untouched; the
|
|
60
|
+
* `.tmp` file is orphaned (cleaned up on the next acquire by the caller).
|
|
61
|
+
*/
|
|
62
|
+
export async function mutate(
|
|
63
|
+
path: string,
|
|
64
|
+
fn: (s: ParsedState) => ParsedState,
|
|
65
|
+
): Promise<ParsedState> {
|
|
66
|
+
const release = await acquire(path);
|
|
67
|
+
const tmpPath: string = `${path}.tmp`;
|
|
68
|
+
try {
|
|
69
|
+
const raw: string = readFileSync(path, 'utf8');
|
|
70
|
+
const { state, raw_bodies, raw_frontmatter, block_gaps, line_ending } =
|
|
71
|
+
parse(raw);
|
|
72
|
+
// Deep-clone so the consumer's fn cannot mutate the state we just
|
|
73
|
+
// parsed (defensive — apply() does this too for pure callers).
|
|
74
|
+
const clone = structuredClone(state);
|
|
75
|
+
const next = fn(clone);
|
|
76
|
+
const out = serialize(next, {
|
|
77
|
+
raw_frontmatter,
|
|
78
|
+
raw_bodies,
|
|
79
|
+
block_gaps,
|
|
80
|
+
line_ending,
|
|
81
|
+
});
|
|
82
|
+
writeFileSync(tmpPath, out, 'utf8');
|
|
83
|
+
try {
|
|
84
|
+
renameSync(tmpPath, path);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
// Windows EPERM retry — AV / indexer holding STATE.md briefly.
|
|
87
|
+
const code =
|
|
88
|
+
typeof err === 'object' && err !== null && 'code' in err
|
|
89
|
+
? (err as { code?: unknown }).code
|
|
90
|
+
: undefined;
|
|
91
|
+
if (code === 'EPERM' || code === 'EBUSY') {
|
|
92
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
93
|
+
renameSync(tmpPath, path);
|
|
94
|
+
} else {
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return next;
|
|
99
|
+
} catch (err) {
|
|
100
|
+
// Clean up the orphaned tmp file on failure so we don't pollute.
|
|
101
|
+
try {
|
|
102
|
+
if (existsSync(tmpPath)) unlinkSync(tmpPath);
|
|
103
|
+
} catch {
|
|
104
|
+
// best-effort; a leftover tmp file does not corrupt STATE.md.
|
|
105
|
+
}
|
|
106
|
+
throw err;
|
|
107
|
+
} finally {
|
|
108
|
+
await release();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Advance to `toStage` under the locked RMW protocol.
|
|
114
|
+
*
|
|
115
|
+
* Steps:
|
|
116
|
+
* 1. Read current state (outside the lock) to pass to the gate.
|
|
117
|
+
* 2. Resolve the gate via `gateFor(position.stage, toStage)`.
|
|
118
|
+
* - `null` → TransitionGateFailed "Invalid transition" (skip-stage,
|
|
119
|
+
* backward, same-stage, or from outside the Stage union).
|
|
120
|
+
* 3. Invoke the gate. If `pass: false`, throw TransitionGateFailed with
|
|
121
|
+
* the gate's blockers verbatim.
|
|
122
|
+
* 4. If `pass: true`, mutate STATE.md under the lock:
|
|
123
|
+
* - frontmatter.stage = toStage
|
|
124
|
+
* - position.stage = toStage
|
|
125
|
+
* - frontmatter.last_checkpoint = now (ISO)
|
|
126
|
+
* - timestamps[`${toStage}_started_at`] = now (ISO)
|
|
127
|
+
*
|
|
128
|
+
* Returns the updated state plus the gate response (for callers that
|
|
129
|
+
* want to log blockers — on pass, `blockers` is always `[]`).
|
|
130
|
+
*/
|
|
131
|
+
export async function transition(
|
|
132
|
+
path: string,
|
|
133
|
+
toStage: Stage,
|
|
134
|
+
): Promise<TransitionResult> {
|
|
135
|
+
// Read (outside the lock) to pass current state to the gate — the
|
|
136
|
+
// mutate() below will re-read under the lock before applying changes.
|
|
137
|
+
// This two-phase pattern matches the GSD reference implementation.
|
|
138
|
+
const beforeMutate = await read(path);
|
|
139
|
+
const from: string = beforeMutate.position.stage;
|
|
140
|
+
// `position.stage` is typed as `string` in ParsedState (parser tolerates
|
|
141
|
+
// `scan` and other pre-brief values). Narrow it to `Stage` before asking
|
|
142
|
+
// the gate registry — anything outside the union is an invalid FROM.
|
|
143
|
+
if (!isStage(from)) {
|
|
144
|
+
throw new TransitionGateFailed(toStage, [
|
|
145
|
+
`Invalid transition: from="${from}" is not a recognized Stage`,
|
|
146
|
+
]);
|
|
147
|
+
}
|
|
148
|
+
const gate = gateFor(from, toStage);
|
|
149
|
+
if (gate === null) {
|
|
150
|
+
throw new TransitionGateFailed(toStage, [
|
|
151
|
+
`Invalid transition: ${from} → ${toStage}`,
|
|
152
|
+
]);
|
|
153
|
+
}
|
|
154
|
+
const gateResult = gate(beforeMutate);
|
|
155
|
+
if (!gateResult.pass) {
|
|
156
|
+
throw new TransitionGateFailed(toStage, gateResult.blockers);
|
|
157
|
+
}
|
|
158
|
+
const nowIso: string = new Date().toISOString();
|
|
159
|
+
const nextState = await mutate(path, (s): ParsedState => {
|
|
160
|
+
s.frontmatter.stage = toStage;
|
|
161
|
+
s.frontmatter.last_checkpoint = nowIso;
|
|
162
|
+
s.position.stage = toStage;
|
|
163
|
+
s.timestamps[`${toStage}_started_at`] = nowIso;
|
|
164
|
+
return s;
|
|
165
|
+
});
|
|
166
|
+
return { pass: true, blockers: gateResult.blockers, state: nextState };
|
|
167
|
+
}
|