@hegemonart/get-design-done 1.39.2 → 1.40.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.
@@ -101,6 +101,56 @@ export interface DesignConfigJson {
101
101
  * Latest plugin tag (e.g. "v1.0.7.3") whose update nudge the user has dismissed. Set by /gdd:check-update --dismiss and by hooks/update-check.sh on the --dismiss code path. When a newer tag ships, the nudge reappears.
102
102
  */
103
103
  update_dismissed?: string;
104
+ /**
105
+ * Phase 40 sectional handoff. designer = Brief + Explore only; dev = Plan + Design + Verify; full = all stages (default). scripts/lib/collab/cycle-mode.cjs gates STATE writes by stage so a designer and a dev can hand a cycle back and forth without overwriting each other's sections.
106
+ */
107
+ gdd_cycle_mode?: 'designer' | 'dev' | 'full';
108
+ /**
109
+ * Phase 40 per-section write permissions (scripts/lib/collab/permissions.cjs). Permissive by default (absent = everyone is owner). A team narrows it, e.g. only @lead-designer may lock decisions. A CI gate enforces on PRs.
110
+ */
111
+ permissions?: {
112
+ /**
113
+ * Role for any actor not listed in `actors`. Default owner.
114
+ */
115
+ default?: 'owner' | 'contributor' | 'reviewer' | 'viewer';
116
+ /**
117
+ * Per-actor role map (git user/handle -> role).
118
+ */
119
+ actors?: {
120
+ [k: string]: 'owner' | 'contributor' | 'reviewer' | 'viewer';
121
+ };
122
+ /**
123
+ * Restrictive rules: a (section, action) is limited to the listed roles. No matching rule = allowed (permissive).
124
+ */
125
+ rules?: {
126
+ /**
127
+ * STATE.md section (decisions/prototyping/rollout_status/status/progress/blockers) or '*'.
128
+ */
129
+ section?: string;
130
+ action?: 'write' | 'lock' | 'unlock' | 'approve' | '*';
131
+ roles?: ('owner' | 'contributor' | 'reviewer' | 'viewer')[];
132
+ [k: string]: unknown;
133
+ }[];
134
+ [k: string]: unknown;
135
+ };
136
+ /**
137
+ * Phase 40 team-collaboration settings.
138
+ */
139
+ collab?: {
140
+ /**
141
+ * When true, the gdd-state advisory lock uses the team-mode policy (longer wait + backoff) via scripts/lib/collab/lock-policy.cjs. Default false (single-process).
142
+ */
143
+ multi_writer_enabled?: boolean;
144
+ /**
145
+ * Override the team-mode lock acquire maxWaitMs (default 30000).
146
+ */
147
+ lock_timeout_ms?: number;
148
+ /**
149
+ * Cross-machine .design/ sync backend (scripts/lib/collab/sync-backend.cjs). Default git (existing push/pull). s3 / git-lfs are opt-in declarations; a live client is not bundled this phase.
150
+ */
151
+ sync_backend?: 'git' | 's3' | 'git-lfs';
152
+ [k: string]: unknown;
153
+ };
104
154
  [k: string]: unknown;
105
155
  }
106
156
 
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+ // Phase 40 — attribution.cjs — PURE, dep-free parser/formatter for multi-author decision attribution.
3
+ //
4
+ // A STATE.md decision line carries an OPTIONAL attribution suffix so multiple developers' decisions
5
+ // survive a merge with provenance intact (SC#5). The canonical line form is:
6
+ //
7
+ // D-NN: <text> (<status>) [author=<git-user> co-author=<gdd-instance-id>]
8
+ //
9
+ // The suffix is optional and backward-compatible — a plain `D-01: text (locked)` parses with
10
+ // author/coAuthor = null. This module does ONLY string parse/format + grouping; no fs, no clock.
11
+ //
12
+ // No `require` — pure. Deterministic.
13
+
14
+ const STATUSES = Object.freeze(['locked', 'tentative']);
15
+
16
+ /**
17
+ * Parse a single decision line into its parts. Returns null when the line is not a decision line.
18
+ * @returns {{id, text, status, author, coAuthor} | null}
19
+ */
20
+ function parseDecisionLine(line) {
21
+ const s = String(line).trim().replace(/^[-*]\s+/, ''); // tolerate list-bullet prefixes
22
+ const m = s.match(/^(D-\d+)\s*:\s*(.*)$/);
23
+ if (!m) return null;
24
+ const id = m[1];
25
+ let rest = m[2].trim();
26
+ let author = null;
27
+ let coAuthor = null;
28
+ // Pull a trailing [author=... co-author=...] suffix (order-independent, both optional).
29
+ const attr = rest.match(/\[([^\]]*)\]\s*$/);
30
+ if (attr) {
31
+ const inner = attr[1];
32
+ const am = inner.match(/\bauthor=([^\s\]]+)/);
33
+ const cm = inner.match(/\bco-author=([^\s\]]+)/);
34
+ if (am) author = am[1];
35
+ if (cm) coAuthor = cm[1];
36
+ if (am || cm) rest = rest.slice(0, attr.index).trim();
37
+ }
38
+ // Pull a trailing (status).
39
+ let status = null;
40
+ const st = rest.match(/\(([a-z]+)\)\s*$/i);
41
+ if (st && STATUSES.includes(st[1].toLowerCase())) {
42
+ status = st[1].toLowerCase();
43
+ rest = rest.slice(0, st.index).trim();
44
+ }
45
+ return { id, text: rest, status, author, coAuthor };
46
+ }
47
+
48
+ /** Format a decision object back into the canonical line (omitting absent optional parts). */
49
+ function formatDecisionLine(d) {
50
+ if (!d || !d.id) throw new Error('attribution: formatDecisionLine needs {id}');
51
+ let line = `${d.id}: ${String(d.text || '').trim()}`;
52
+ if (d.status) line += ` (${d.status})`;
53
+ const bits = [];
54
+ if (d.author) bits.push(`author=${d.author}`);
55
+ if (d.coAuthor) bits.push(`co-author=${d.coAuthor}`);
56
+ if (bits.length) line += ` [${bits.join(' ')}]`;
57
+ return line;
58
+ }
59
+
60
+ /** Group an array of decision objects by author → { '<author>': [decision,...], '<unattributed>': [...] }. */
61
+ function groupByAuthor(decisions) {
62
+ if (!Array.isArray(decisions)) throw new Error('attribution: groupByAuthor needs an array');
63
+ const out = {};
64
+ for (const d of decisions) {
65
+ const key = d && d.author ? d.author : '<unattributed>';
66
+ (out[key] = out[key] || []).push(d);
67
+ }
68
+ return out;
69
+ }
70
+
71
+ /** Parse a whole `<decisions>` block body into decision objects (skips blanks/comments). */
72
+ function parseDecisionsBlock(body) {
73
+ const out = [];
74
+ for (const line of String(body).replace(/\r\n/g, '\n').split('\n')) {
75
+ const t = line.trim();
76
+ if (!t || t.startsWith('<!--')) continue;
77
+ const d = parseDecisionLine(t);
78
+ if (d) out.push(d);
79
+ }
80
+ return out;
81
+ }
82
+
83
+ module.exports = { STATUSES, parseDecisionLine, formatDecisionLine, groupByAuthor, parseDecisionsBlock };
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+ // Phase 40 — cycle-mode.cjs — PURE, dep-free sectional-handoff gate (SC#8).
3
+ //
4
+ // `.design/config.json#gdd_cycle_mode` partitions a cycle by role so a designer can hand a brief to a
5
+ // dev (and vice versa) without either overwriting the other's stages:
6
+ // designer → Brief, Explore
7
+ // dev → Plan, Design, Verify
8
+ // full → all stages (the default / current single-operator behavior)
9
+ // `stagePermitted(mode, stage)` gates a STATE write by the stage that produced it.
10
+ //
11
+ // No `require` — pure. Deterministic.
12
+
13
+ const MODES = Object.freeze(['designer', 'dev', 'full']);
14
+ const ALL_STAGES = Object.freeze(['brief', 'explore', 'plan', 'design', 'verify']);
15
+
16
+ const STAGES_BY_MODE = Object.freeze({
17
+ designer: Object.freeze(['brief', 'explore']),
18
+ dev: Object.freeze(['plan', 'design', 'verify']),
19
+ full: ALL_STAGES,
20
+ });
21
+
22
+ /** Normalize a mode value; unknown/missing → 'full' (the safe, backward-compatible default). */
23
+ function normalizeMode(mode) {
24
+ const m = String(mode || 'full').toLowerCase().trim();
25
+ return MODES.includes(m) ? m : 'full';
26
+ }
27
+
28
+ /** Resolve the cycle mode from a parsed `.design/config.json` (defaults to 'full'). */
29
+ function resolveMode(config) {
30
+ return normalizeMode(config && config.gdd_cycle_mode);
31
+ }
32
+
33
+ /** True when `stage` is writable under `mode`. Unknown stage → false. */
34
+ function stagePermitted(mode, stage) {
35
+ const m = normalizeMode(mode);
36
+ const s = String(stage || '').toLowerCase().trim();
37
+ if (!ALL_STAGES.includes(s)) return false;
38
+ return STAGES_BY_MODE[m].includes(s);
39
+ }
40
+
41
+ module.exports = { MODES, ALL_STAGES, STAGES_BY_MODE, normalizeMode, resolveMode, stagePermitted };
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+ // Phase 40 — lock-policy.cjs — PURE, dep-free advisory-lock policy for gdd-state multi-writer mode (SC#6).
3
+ //
4
+ // Phase 20's sdk/state/lockfile.ts already implements PID+timestamp advisory locks with retry
5
+ // (staleMs / maxWaitMs / pollMs). "Multi-writer mode" is a POLICY layered on top: when a project
6
+ // enables team mode (`.design/config.json#collab.multi_writer_enabled`), the state write path should
7
+ // wait LONGER and poll on a backoff before giving up, because a teammate's write may be in flight.
8
+ // This module derives the acquire-options object from config; the MCP write path passes it to acquire().
9
+ //
10
+ // No `require` — pure. Deterministic.
11
+
12
+ // sdk/state/lockfile.ts defaults (single-process baseline).
13
+ const SINGLE = Object.freeze({ staleMs: 60000, maxWaitMs: 5000, pollMs: 50 });
14
+ // Team mode: a stuck teammate write is rare but a normal queued one is not — wait up to 30s,
15
+ // poll slower (100ms) to reduce contention, and treat a lock older than 2min as stale.
16
+ const TEAM = Object.freeze({ staleMs: 120000, maxWaitMs: 30000, pollMs: 100 });
17
+
18
+ /** True when team multi-writer mode is enabled in config. */
19
+ function isMultiWriter(config) {
20
+ return !!(config && config.collab && config.collab.multi_writer_enabled === true);
21
+ }
22
+
23
+ /**
24
+ * Resolve the acquire() options for the current config. A numeric `collab.lock_timeout_ms`
25
+ * overrides the team-mode maxWaitMs. Returns a fresh object (never the frozen constants).
26
+ */
27
+ function acquireOpts(config) {
28
+ const base = isMultiWriter(config) ? TEAM : SINGLE;
29
+ const out = { staleMs: base.staleMs, maxWaitMs: base.maxWaitMs, pollMs: base.pollMs };
30
+ if (isMultiWriter(config)) {
31
+ const t = Number(config.collab.lock_timeout_ms);
32
+ if (Number.isFinite(t) && t > 0) out.maxWaitMs = t;
33
+ }
34
+ return out;
35
+ }
36
+
37
+ module.exports = { isMultiWriter, acquireOpts, SINGLE, TEAM };
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+ // Phase 40 — permissions.cjs — PURE, dep-free section-write permission model (SC#10).
3
+ //
4
+ // `.design/config.json#permissions` declares who may perform which action on which STATE.md section.
5
+ // The model is permissive-by-default (single-operator projects are unaffected): with no `permissions`
6
+ // block, everyone is an `owner` and `can(...)` is always true. A team narrows it, e.g. "only
7
+ // @lead-designer can `lock` decisions". A CI gate calls `can()` to enforce on PRs.
8
+ //
9
+ // Shape of config.permissions:
10
+ // {
11
+ // "default": "owner", // role for any actor not listed
12
+ // "actors": { "@alice": "reviewer", ... },// per-actor role
13
+ // "rules": [ { "section": "decisions", "action": "lock", "roles": ["owner"] }, ... ]
14
+ // }
15
+ // A rule restricts (section, action) to the listed roles. No matching rule ⇒ allowed (permissive).
16
+ //
17
+ // No `require` — pure. Deterministic.
18
+
19
+ const SECTIONS = Object.freeze(['decisions', 'prototyping', 'rollout_status', 'status', 'progress', 'blockers']);
20
+ const ACTIONS = Object.freeze(['write', 'lock', 'unlock', 'approve']);
21
+ const ROLES = Object.freeze(['owner', 'contributor', 'reviewer', 'viewer']);
22
+
23
+ /** The role assigned to `actor` by the config (falls back to config.default, then 'owner'). */
24
+ function roleOf(config, actor) {
25
+ const perms = (config && config.permissions) || {};
26
+ const actors = perms.actors || {};
27
+ if (actor && Object.prototype.hasOwnProperty.call(actors, actor)) return actors[actor];
28
+ return perms.default || 'owner';
29
+ }
30
+
31
+ /**
32
+ * May `actor` perform `action` on `section` under this config?
33
+ * Permissive by default: a (section, action) with no matching rule is allowed. A matching rule
34
+ * allows only its listed roles. `viewer` is denied any mutating action even absent a rule.
35
+ */
36
+ function can(config, actor, section, action) {
37
+ const role = roleOf(config, actor);
38
+ if (role === 'viewer') return false; // viewers never mutate
39
+ const perms = (config && config.permissions) || {};
40
+ const rules = Array.isArray(perms.rules) ? perms.rules : [];
41
+ const matching = rules.filter(
42
+ (r) => r && (r.section === section || r.section === '*') && (r.action === action || r.action === '*'),
43
+ );
44
+ if (matching.length === 0) return true; // no restriction → allowed
45
+ return matching.some((r) => Array.isArray(r.roles) && r.roles.includes(role));
46
+ }
47
+
48
+ /** The default permissive policy (used when config has no permissions block). */
49
+ function defaultPolicy() {
50
+ return { default: 'owner', actors: {}, rules: [] };
51
+ }
52
+
53
+ module.exports = { SECTIONS, ACTIONS, ROLES, roleOf, can, defaultPolicy };
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+ // Phase 40 — review-queue.cjs — PURE, dep-free async-review state machine for decisions (SC#7).
3
+ //
4
+ // Each decision under review lives at .design/reviews/<decision-id>/ and moves through:
5
+ // proposed → reviewing → approved → locked
6
+ // `locked` is terminal and HARD: a locked decision cannot be amended. The only way back is an
7
+ // explicit, AUDITED unlock (/gdd:unlock-decision <id> --approver <who>), which records who reopened
8
+ // it and why. This module is the pure transition core — the skill does the filesystem I/O.
9
+ //
10
+ // No `require` — pure. Deterministic.
11
+
12
+ const STATES = Object.freeze(['proposed', 'reviewing', 'approved', 'locked']);
13
+
14
+ // Allowed forward transitions per state. `locked` has none (terminal — unlock() is the escape hatch).
15
+ const TRANSITIONS = Object.freeze({
16
+ proposed: ['reviewing'],
17
+ reviewing: ['approved', 'proposed'], // can bounce back for revision
18
+ approved: ['locked', 'reviewing'],
19
+ locked: [],
20
+ });
21
+
22
+ /** True while a decision may still be edited (before it is locked). */
23
+ function canAmend(state) {
24
+ return state === 'proposed' || state === 'reviewing';
25
+ }
26
+
27
+ /**
28
+ * Apply a transition. `event` is the TARGET state.
29
+ * @returns the new state string. Throws on an invalid transition.
30
+ */
31
+ function transition(state, event) {
32
+ if (!STATES.includes(state)) throw new Error(`review-queue: unknown state "${state}"`);
33
+ if (!STATES.includes(event)) throw new Error(`review-queue: unknown target "${event}"`);
34
+ const allowed = TRANSITIONS[state];
35
+ if (!allowed.includes(event)) {
36
+ throw new Error(`review-queue: illegal transition ${state} -> ${event} (allowed: ${allowed.join(', ') || 'none'})`);
37
+ }
38
+ return event;
39
+ }
40
+
41
+ /**
42
+ * Explicit audited unlock of a locked decision. Moves locked -> reviewing and returns the new entry
43
+ * with an appended audit record. Requires a non-empty approver. Throws if the entry is not locked.
44
+ * @param {{id, state, audit?: Array}} entry
45
+ * @param {{approver: string, reason?: string}} opts
46
+ */
47
+ function unlock(entry, opts) {
48
+ if (!entry || entry.state !== 'locked') {
49
+ throw new Error('review-queue: unlock() requires a locked decision');
50
+ }
51
+ const approver = opts && opts.approver ? String(opts.approver).trim() : '';
52
+ if (!approver) throw new Error('review-queue: unlock() requires an approver');
53
+ const audit = Array.isArray(entry.audit) ? entry.audit.slice() : [];
54
+ audit.push({ action: 'unlock', from: 'locked', to: 'reviewing', approver, reason: (opts && opts.reason) || '' });
55
+ return Object.assign({}, entry, { state: 'reviewing', audit });
56
+ }
57
+
58
+ /** Filter queue entries that still need a human action (not yet locked). */
59
+ function pending(entries) {
60
+ if (!Array.isArray(entries)) throw new Error('review-queue: pending() needs an array');
61
+ return entries.filter((e) => e && e.state !== 'locked');
62
+ }
63
+
64
+ module.exports = { STATES, TRANSITIONS, canAmend, transition, unlock, pending };
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+ // Phase 40 — section-merge.cjs — PURE, dep-free per-section semantic merge for STATE.md (SC#1).
3
+ //
4
+ // The chosen multi-writer model (ROADMAP default) is a git-merge-driver with PER-SECTION semantic
5
+ // conflict detection — strictly safer than a line-based merge for the append-mostly `<decisions>`
6
+ // block. Two developers each adding a new D-NN on parallel branches should merge cleanly (union by
7
+ // id); a real conflict is ONLY when the SAME id diverges in text or status. This module is the merge
8
+ // core: it takes three decision arrays (base/ours/theirs, already parsed by attribution.cjs) and
9
+ // returns the merged set plus any genuine conflicts for the conflict-resolver agent to resolve.
10
+ //
11
+ // No `require` — pure. Deterministic (stable id-sorted output).
12
+
13
+ function byId(list) {
14
+ const m = new Map();
15
+ for (const d of list || []) if (d && d.id) m.set(d.id, d);
16
+ return m;
17
+ }
18
+
19
+ function sameDecision(a, b) {
20
+ return a.text === b.text && (a.status || null) === (b.status || null) &&
21
+ (a.author || null) === (b.author || null) && (a.coAuthor || null) === (b.coAuthor || null);
22
+ }
23
+
24
+ /** Numeric sort by the D-NN id (D-2 before D-10). */
25
+ function idNum(id) {
26
+ const m = String(id).match(/(\d+)/);
27
+ return m ? parseInt(m[1], 10) : 0;
28
+ }
29
+
30
+ /**
31
+ * Three-way merge of `<decisions>` (base = common ancestor; ours/theirs = the two branches).
32
+ * @returns {{merged: Decision[], conflicts: [{id, ours, theirs}], added: string[]}}
33
+ * - id only in one side (vs base) → kept (union).
34
+ * - id in both sides, equal → kept once.
35
+ * - id in both sides, divergent → CONFLICT (and `ours` is kept provisionally so the merged set is
36
+ * still complete; the resolver overwrites after the human picks).
37
+ * - id removed on one side but unchanged on the other → kept (deletions are never auto-applied —
38
+ * decisions are durable; an explicit unlock/removal flow handles that).
39
+ */
40
+ function mergeDecisions(base, ours, theirs) {
41
+ const B = byId(base);
42
+ const O = byId(ours);
43
+ const T = byId(theirs);
44
+ const ids = new Set([...O.keys(), ...T.keys(), ...B.keys()]);
45
+ const merged = [];
46
+ const conflicts = [];
47
+ const added = [];
48
+ for (const id of ids) {
49
+ const o = O.get(id);
50
+ const t = T.get(id);
51
+ const b = B.get(id);
52
+ if (o && t) {
53
+ if (sameDecision(o, t)) { merged.push(o); }
54
+ else { conflicts.push({ id, ours: o, theirs: t }); merged.push(o); }
55
+ } else if (o && !t) {
56
+ merged.push(o);
57
+ if (!b) added.push(id); // ours added it
58
+ } else if (!o && t) {
59
+ merged.push(t);
60
+ if (!b) added.push(id); // theirs added it
61
+ }
62
+ // (!o && !t) — present only in base, removed on both → drop.
63
+ }
64
+ merged.sort((a, x) => idNum(a.id) - idNum(x.id));
65
+ added.sort((a, x) => idNum(a) - idNum(x));
66
+ return { merged, conflicts, added };
67
+ }
68
+
69
+ /** Scalar merge for a single-value section (e.g. status): equal → value; divergent → conflict marker. */
70
+ function mergeStatusScalar(base, ours, theirs) {
71
+ if (ours === theirs) return { value: ours, conflict: false };
72
+ if (ours === base) return { value: theirs, conflict: false }; // only theirs changed
73
+ if (theirs === base) return { value: ours, conflict: false }; // only ours changed
74
+ return { value: ours, conflict: true }; // both changed differently
75
+ }
76
+
77
+ module.exports = { mergeDecisions, mergeStatusScalar, sameDecision };
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+ // Phase 40 — sync-backend.cjs — PURE, dep-free cross-machine sync backend selector (SC#9).
3
+ //
4
+ // `.design/` syncs between teammates over git by DEFAULT (existing behavior). Orgs whose git
5
+ // push/pull cadence is too slow can opt into an `s3` or `git-lfs` backend. This module is the
6
+ // SELECTOR + contract: it resolves which backend a config asks for and whether sync is opted in.
7
+ // A live S3/LFS client is explicitly out of scope this phase — the selector ships, the backend is
8
+ // pluggable. Defaulting to `git` means single-operator + most-team projects are unaffected.
9
+ //
10
+ // No `require` — pure. Deterministic.
11
+
12
+ const BACKENDS = Object.freeze(['git', 's3', 'git-lfs']);
13
+ const DEFAULT_BACKEND = 'git';
14
+
15
+ /** True when the project opted into a non-git sync backend. */
16
+ function isOptIn(config) {
17
+ const b = config && config.collab && config.collab.sync_backend;
18
+ return !!b && b !== 'git' && BACKENDS.includes(b);
19
+ }
20
+
21
+ /**
22
+ * Resolve the sync backend. Unknown/missing → 'git' (the safe default).
23
+ * @returns {{backend, optIn, supported}} supported=false for opt-in backends whose live client
24
+ * is not bundled this phase (the caller falls back to git + warns).
25
+ */
26
+ function resolveBackend(config) {
27
+ const raw = config && config.collab && config.collab.sync_backend;
28
+ const backend = BACKENDS.includes(raw) ? raw : DEFAULT_BACKEND;
29
+ const optIn = backend !== DEFAULT_BACKEND;
30
+ // Phase 40 ships the selector only; no live S3/LFS client → opt-in backends are "declared but not
31
+ // yet executable". git is always supported (it's the existing push/pull path).
32
+ const supported = backend === DEFAULT_BACKEND;
33
+ return { backend, optIn, supported };
34
+ }
35
+
36
+ module.exports = { BACKENDS, DEFAULT_BACKEND, isOptIn, resolveBackend };
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+ // Phase 39.5 — deprecation-registry.cjs — PURE, dep-free reader for GDD's own path-migration registry.
3
+ //
4
+ // The canonical registry is the `## Path migrations (machine-readable)` table in
5
+ // reference/DEPRECATIONS.md. This module parses that table and derives each entry's status against a
6
+ // running plugin version, so /gdd:migrate, the /gdd:update advisory, and the completeness gate all
7
+ // share one version-logic core. It reads NO files itself (callers pass the markdown text) — so it is
8
+ // trivially unit-testable.
9
+ //
10
+ // No `require` — pure. Deterministic.
11
+
12
+ /**
13
+ * Compare two dotted-numeric versions. Tolerant of decimals/patch (1.39, 1.39.2, 1.39.5).
14
+ * Missing components are treated as 0. Non-numeric components compare as 0.
15
+ * @returns -1 if a<b, 0 if equal, 1 if a>b
16
+ */
17
+ function compareVersions(a, b) {
18
+ const pa = String(a).split('.').map((x) => parseInt(x, 10) || 0);
19
+ const pb = String(b).split('.').map((x) => parseInt(x, 10) || 0);
20
+ const n = Math.max(pa.length, pb.length);
21
+ for (let i = 0; i < n; i++) {
22
+ const da = pa[i] || 0;
23
+ const db = pb[i] || 0;
24
+ if (da < db) return -1;
25
+ if (da > db) return 1;
26
+ }
27
+ return 0;
28
+ }
29
+
30
+ /** Strip surrounding whitespace + a single pair of backticks from a table cell. */
31
+ function cell(s) {
32
+ return String(s).trim().replace(/^`(.*)`$/, '$1').trim();
33
+ }
34
+
35
+ /**
36
+ * Parse the path-migrations table out of reference/DEPRECATIONS.md text.
37
+ * Finds the header row containing Since / Removed in / Old / New, skips the `|---|` separator, and
38
+ * reads pipe-delimited data rows until a non-table line. Returns [] when the table is absent.
39
+ * @returns {Array<{since, removedIn, old, new, hint}>}
40
+ */
41
+ function parseDeprecations(mdText) {
42
+ const lines = String(mdText).replace(/\r\n/g, '\n').split('\n');
43
+ const entries = [];
44
+ let inTable = false;
45
+ for (const line of lines) {
46
+ const isRow = /^\s*\|.*\|\s*$/.test(line);
47
+ if (!inTable) {
48
+ if (isRow && /\bSince\b/i.test(line) && /Removed in/i.test(line) && /\bOld\b/i.test(line) && /\bNew\b/i.test(line)) {
49
+ inTable = true; // header found; the next line is the separator
50
+ }
51
+ continue;
52
+ }
53
+ if (!isRow) { inTable = false; continue; } // table ended
54
+ const cells = line.split('|').slice(1, -1).map(cell);
55
+ if (cells.length < 5) continue;
56
+ if (/^-+$/.test(cells[0].replace(/\s/g, ''))) continue; // separator row
57
+ if (/^since$/i.test(cells[0])) continue; // a repeated header, ignore
58
+ entries.push({ since: cells[0], removedIn: cells[1], old: cells[2], new: cells[3], hint: cells[4] });
59
+ }
60
+ return entries;
61
+ }
62
+
63
+ /**
64
+ * Status of an entry at `currentVersion`:
65
+ * pending — current < since
66
+ * deprecated — since <= current < removedIn (or removedIn blank ⇒ never 'removed')
67
+ * removed — current >= removedIn
68
+ */
69
+ function classify(entry, currentVersion) {
70
+ if (!entry || !entry.since) throw new Error('deprecation-registry: entry needs a `since` version');
71
+ if (compareVersions(currentVersion, entry.since) < 0) return 'pending';
72
+ if (entry.removedIn && String(entry.removedIn).trim() && compareVersions(currentVersion, entry.removedIn) >= 0) {
73
+ return 'removed';
74
+ }
75
+ return 'deprecated';
76
+ }
77
+
78
+ /**
79
+ * Look up a reference (an old path/name) against the registry at currentVersion.
80
+ * @returns {{entry, status, message} | null} null when `ref` is not a known deprecated path.
81
+ */
82
+ function checkReference(entries, ref, currentVersion) {
83
+ if (!Array.isArray(entries)) throw new Error('deprecation-registry: entries must be an array');
84
+ const r = cell(ref);
85
+ const entry = entries.find((e) => e.old === r);
86
+ if (!entry) return null;
87
+ const status = classify(entry, currentVersion);
88
+ let message;
89
+ if (status === 'removed') {
90
+ message = `${entry.old} was removed in v${entry.removedIn}; use ${entry.new}. ${entry.hint}`;
91
+ } else if (status === 'deprecated') {
92
+ message = `${entry.old} is deprecated since v${entry.since} (removed in v${entry.removedIn}); use ${entry.new}. ${entry.hint}`;
93
+ } else {
94
+ message = `${entry.old} will be deprecated in v${entry.since}; the replacement is ${entry.new}.`;
95
+ }
96
+ return { entry, status, message };
97
+ }
98
+
99
+ module.exports = { compareVersions, parseDeprecations, classify, checkReference };
@@ -0,0 +1,70 @@
1
+ ---
2
+ name: gdd-migrate
3
+ description: "Migrates a project off GDD's own deprecated paths after an upgrade. Reads the machine-readable registry in reference/DEPRECATIONS.md (via scripts/lib/deprecation-registry.cjs), scans the project's .design/config.json + any local skill/agent references for paths that are now deprecated or removed at the installed version, and PREVIEWS a diff before changing anything. Interactive by default (confirm per change); --yes auto-applies; --dry-run previews only. Read-first, never silent. Use after /gdd:update flags a breaking change."
4
+ argument-hint: "[--yes] [--dry-run]"
5
+ user-invocable: true
6
+ tools: Read, Write, Bash, Grep, Glob
7
+ ---
8
+
9
+ # /gdd:migrate
10
+
11
+ Closes the GDD-on-GDD gap: when GDD moves or removes its own paths (e.g. the Phase 31.5
12
+ `scripts/lib/**` → `sdk/**` reorg), a project that referenced the old paths needs updating. This skill
13
+ consults the deprecation registry, finds the stale references **in this project**, and rewrites them —
14
+ **after showing you the diff**. It never edits silently. Contract: `../../reference/DEPRECATIONS.md`.
15
+
16
+ ## Invocation
17
+
18
+ | Command | Behavior |
19
+ |---|---|
20
+ | `/gdd:migrate` | Scan + preview every applicable migration, then confirm each before applying. |
21
+ | `/gdd:migrate --dry-run` | Preview only — print the diff, change nothing. |
22
+ | `/gdd:migrate --yes` | Apply every applicable migration without the per-change prompt (still prints what changed). |
23
+
24
+ ## Steps
25
+
26
+ 1. **Resolve the installed version** from `.claude-plugin/plugin.json` (`version`).
27
+ 2. **Load the registry.** Parse `reference/DEPRECATIONS.md` with the pure helper:
28
+
29
+ ```bash
30
+ node -e '
31
+ const fs = require("fs");
32
+ const dr = require("./scripts/lib/deprecation-registry.cjs");
33
+ const entries = dr.parseDeprecations(fs.readFileSync("reference/DEPRECATIONS.md","utf8"));
34
+ const version = require("./.claude-plugin/plugin.json").version;
35
+ // Only entries that are deprecated/removed at the installed version are actionable.
36
+ const actionable = entries.filter(e => dr.classify(e, version) !== "pending");
37
+ console.log(JSON.stringify({ version, actionable }));
38
+ '
39
+ ```
40
+
41
+ 3. **Scan the project** for each actionable entry's `Old` path:
42
+ - `.design/config.json` (string values referencing an old path),
43
+ - project-local skill/agent references (`grep` the repo, excluding `.git/`, `node_modules/`, and
44
+ GDD's own `reference/DEPRECATIONS.md`),
45
+ - any `require(...)`/import of a removed SDK path.
46
+ For a code rewrite, scaffold the edit with `scripts/lib/migration/codemod-gen.cjs` (Phase 39.1) —
47
+ you emit the change as a reviewable patch, you do not run a codemod engine.
48
+ 4. **Preview.** Print a unified-diff-style preview per file: `Old → New`, the registry status
49
+ (`deprecated` vs `removed`), and the migration hint. If `--dry-run`, stop here.
50
+ 5. **Confirm + apply.** Without `--yes`, ask per change. With `--yes`, apply all. Use `Write` to make
51
+ the edits; never touch a file the user didn't consent to.
52
+ 6. **Report.** Summarize: files changed, references rewritten, and any `removed`-status reference that
53
+ still has no replacement wired (flag it loudly — that one breaks at the installed version).
54
+
55
+ ## Boundaries
56
+
57
+ - **Preview-first.** Nothing changes before you've shown the diff (or `--yes` was passed).
58
+ - Migrates **this project's references** to GDD paths — it does not rewrite GDD's own source, and it
59
+ never performs a downgrade (reverse migrations are out of scope).
60
+ - Read the registry; never invent a migration that isn't in `reference/DEPRECATIONS.md`.
61
+
62
+ ## Record
63
+
64
+ Print a `## Migration summary` (version, actionable entries, files changed, unresolved `removed`
65
+ references) and append one JSONL line to `.design/intel/insights.jsonl` recording the migration run.
66
+ Close with:
67
+
68
+ ```
69
+ ## MIGRATE COMPLETE
70
+ ```
@@ -0,0 +1,42 @@
1
+ ---
2
+ name: gdd-review-decisions
3
+ description: "Surfaces the async decision-review queue for team mode. Reads .design/reviews/<decision-id>/ entries and reports each decision's state in the proposed → reviewing → approved → locked machine (via scripts/lib/collab/review-queue.cjs), so a team can see what's awaiting review, what's approved, and what's locked. --pending shows only decisions still needing action. Read-only — it reports the queue; it never advances a decision (that's a reviewer's explicit call). Use to run an async design-decision review without a meeting."
4
+ argument-hint: "[<decision-id>] [--pending]"
5
+ user-invocable: true
6
+ tools: Read, Bash, Grep, Glob
7
+ ---
8
+
9
+ # /gdd:review-decisions
10
+
11
+ Closes the async-review gap for team mode: design decisions move through an explicit review queue
12
+ instead of being decided in a single operator's head. This skill reports where each decision is.
13
+ **Read-only** — it surfaces state; advancing a decision is a reviewer's explicit action. Contract:
14
+ `../../reference/multi-author-model.md`.
15
+
16
+ ## Invocation
17
+
18
+ | Command | Behavior |
19
+ |---|---|
20
+ | `/gdd:review-decisions` | Every decision in the queue, grouped by state. |
21
+ | `/gdd:review-decisions <decision-id>` | One decision's state + audit trail. |
22
+ | `/gdd:review-decisions --pending` | Only decisions not yet `locked` (awaiting action). |
23
+
24
+ ## Steps
25
+
26
+ 1. **Find the queue.** List `.design/reviews/*/` (each dir is a `<decision-id>`). No reviews dir →
27
+ print `review-decisions: no review queue yet (team mode not in use).` and exit.
28
+ 2. **Read each entry** (`.design/reviews/<id>/state.json` → `{ id, state, audit }`). Validate the
29
+ state against `scripts/lib/collab/review-queue.cjs` `STATES`.
30
+ 3. **Render** grouped by state: `proposed` / `reviewing` / `approved` / `locked`, each listing the
31
+ decision id + a one-line summary. For `--pending`, use `review-queue.pending(entries)` to show only
32
+ non-locked ones. For a single `<decision-id>`, also print its audit trail (transitions + approvers).
33
+ 4. **Do not advance.** Reporting only — moving a decision forward (or `/gdd:unlock-decision`) is the
34
+ reviewer's explicit call.
35
+
36
+ ## Output
37
+
38
+ End with:
39
+
40
+ ```
41
+ ## REVIEW-DECISIONS COMPLETE
42
+ ```