@hegemonart/get-design-done 1.39.5 → 1.40.5
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 +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +63 -0
- package/README.md +8 -0
- package/SKILL.md +3 -0
- package/agents/README.md +1 -0
- package/agents/conflict-resolver.md +80 -0
- package/agents/decision-journal-exporter.md +68 -0
- package/agents/design-reflector.md +2 -0
- package/agents/pr-commenter.md +1 -0
- package/package.json +1 -1
- package/reference/STATE-TEMPLATE.md +3 -0
- package/reference/cli-localization.md +61 -0
- package/reference/config-schema.md +27 -0
- package/reference/multi-author-model.md +112 -0
- package/reference/registry.json +14 -0
- package/reference/schemas/config.schema.json +42 -0
- package/reference/schemas/generated.d.ts +54 -0
- package/scripts/lib/collab/attribution.cjs +83 -0
- package/scripts/lib/collab/cycle-mode.cjs +41 -0
- package/scripts/lib/collab/lock-policy.cjs +37 -0
- package/scripts/lib/collab/permissions.cjs +53 -0
- package/scripts/lib/collab/review-queue.cjs +64 -0
- package/scripts/lib/collab/section-merge.cjs +77 -0
- package/scripts/lib/collab/sync-backend.cjs +36 -0
- package/scripts/lib/i18n/index.cjs +95 -0
- package/scripts/lib/i18n/messages/de.json +7 -0
- package/scripts/lib/i18n/messages/en.json +27 -0
- package/scripts/lib/i18n/messages/fr.json +7 -0
- package/scripts/lib/i18n/messages/ja.json +7 -0
- package/scripts/lib/i18n/messages/ru.json +27 -0
- package/scripts/lib/i18n/messages/uk.json +7 -0
- package/scripts/lib/i18n/messages/zh.json +7 -0
- package/skills/locale/SKILL.md +51 -0
- package/skills/review-decisions/SKILL.md +42 -0
- package/skills/unlock-decision/SKILL.md +54 -0
|
@@ -101,6 +101,60 @@ 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.5 CLI locale override for GDD's own --help, error messages, and skill prompt headers. Set via /gdd:locale <code>. Precedence: this key > env LANG/LC_ALL > en. Missing message keys fall back to English per scripts/lib/i18n/index.cjs (locale -> base -> en). en + ru are complete; uk/de/fr/zh/ja are placeholders that fall back to English.
|
|
106
|
+
*/
|
|
107
|
+
locale?: 'en' | 'ru' | 'uk' | 'de' | 'fr' | 'zh' | 'ja';
|
|
108
|
+
/**
|
|
109
|
+
* 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.
|
|
110
|
+
*/
|
|
111
|
+
gdd_cycle_mode?: 'designer' | 'dev' | 'full';
|
|
112
|
+
/**
|
|
113
|
+
* 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.
|
|
114
|
+
*/
|
|
115
|
+
permissions?: {
|
|
116
|
+
/**
|
|
117
|
+
* Role for any actor not listed in `actors`. Default owner.
|
|
118
|
+
*/
|
|
119
|
+
default?: 'owner' | 'contributor' | 'reviewer' | 'viewer';
|
|
120
|
+
/**
|
|
121
|
+
* Per-actor role map (git user/handle -> role).
|
|
122
|
+
*/
|
|
123
|
+
actors?: {
|
|
124
|
+
[k: string]: 'owner' | 'contributor' | 'reviewer' | 'viewer';
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* Restrictive rules: a (section, action) is limited to the listed roles. No matching rule = allowed (permissive).
|
|
128
|
+
*/
|
|
129
|
+
rules?: {
|
|
130
|
+
/**
|
|
131
|
+
* STATE.md section (decisions/prototyping/rollout_status/status/progress/blockers) or '*'.
|
|
132
|
+
*/
|
|
133
|
+
section?: string;
|
|
134
|
+
action?: 'write' | 'lock' | 'unlock' | 'approve' | '*';
|
|
135
|
+
roles?: ('owner' | 'contributor' | 'reviewer' | 'viewer')[];
|
|
136
|
+
[k: string]: unknown;
|
|
137
|
+
}[];
|
|
138
|
+
[k: string]: unknown;
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* Phase 40 team-collaboration settings.
|
|
142
|
+
*/
|
|
143
|
+
collab?: {
|
|
144
|
+
/**
|
|
145
|
+
* 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).
|
|
146
|
+
*/
|
|
147
|
+
multi_writer_enabled?: boolean;
|
|
148
|
+
/**
|
|
149
|
+
* Override the team-mode lock acquire maxWaitMs (default 30000).
|
|
150
|
+
*/
|
|
151
|
+
lock_timeout_ms?: number;
|
|
152
|
+
/**
|
|
153
|
+
* 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.
|
|
154
|
+
*/
|
|
155
|
+
sync_backend?: 'git' | 's3' | 'git-lfs';
|
|
156
|
+
[k: string]: unknown;
|
|
157
|
+
};
|
|
104
158
|
[k: string]: unknown;
|
|
105
159
|
}
|
|
106
160
|
|
|
@@ -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,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Phase 40.5 — i18n/index.cjs — GDD CLI localization resolver.
|
|
3
|
+
//
|
|
4
|
+
// Resolves a locale (config override > env LANG > English), a fallback chain (locale → base → en),
|
|
5
|
+
// and translates a message key against per-locale flat-JSON tables. The pure functions (baseLocale,
|
|
6
|
+
// fallbackChain, resolveLocale, translate, descriptionFor) take their data as arguments and touch
|
|
7
|
+
// neither fs nor env directly — so they are trivially unit-testable. `loadTable` is the only fs reader.
|
|
8
|
+
//
|
|
9
|
+
// Contract: reference/cli-localization.md. Fallback is always to `en` (the complete source table).
|
|
10
|
+
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
|
|
14
|
+
const KNOWN_LOCALES = Object.freeze(['en', 'ru', 'uk', 'de', 'fr', 'zh', 'ja']);
|
|
15
|
+
const DEFAULT_LOCALE = 'en';
|
|
16
|
+
const MESSAGES_DIR = path.join(__dirname, 'messages');
|
|
17
|
+
|
|
18
|
+
/** Normalize a raw locale token: lowercase, `_`→`-`, strip an encoding suffix (`ru_RU.UTF-8` → `ru-ru`). */
|
|
19
|
+
function normalizeLocale(code) {
|
|
20
|
+
if (!code) return '';
|
|
21
|
+
return String(code).split('.')[0].split(':')[0].trim().toLowerCase().replace(/_/g, '-');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The base of a locale (`de-de` → `de`; `en` → `en`). */
|
|
25
|
+
function baseLocale(code) {
|
|
26
|
+
return normalizeLocale(code).split('-')[0];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Resolution chain: [normalized, base, 'en'] with duplicates removed. */
|
|
30
|
+
function fallbackChain(code) {
|
|
31
|
+
const norm = normalizeLocale(code);
|
|
32
|
+
const chain = [];
|
|
33
|
+
for (const c of [norm, baseLocale(norm), DEFAULT_LOCALE]) {
|
|
34
|
+
if (c && !chain.includes(c)) chain.push(c);
|
|
35
|
+
}
|
|
36
|
+
return chain.length ? chain : [DEFAULT_LOCALE];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the active locale. Precedence: explicit config.locale > env.LANG/LC_ALL > 'en'.
|
|
41
|
+
* @param {{env?: object, configLocale?: string}} [opts]
|
|
42
|
+
*/
|
|
43
|
+
function resolveLocale(opts) {
|
|
44
|
+
const o = opts || {};
|
|
45
|
+
const fromConfig = normalizeLocale(o.configLocale);
|
|
46
|
+
if (fromConfig) return fromConfig;
|
|
47
|
+
const env = o.env || {};
|
|
48
|
+
const fromEnv = normalizeLocale(env.LC_ALL || env.LC_MESSAGES || env.LANG || env.LANGUAGE);
|
|
49
|
+
if (fromEnv && fromEnv !== 'c' && fromEnv !== 'posix') return fromEnv;
|
|
50
|
+
return DEFAULT_LOCALE;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Translate `key` for `locale` against the supplied `tables` map ({ <locale>: {<key>: <string>} }).
|
|
55
|
+
* Walks the fallback chain; returns the first hit, else the key itself (so a missing key is visible,
|
|
56
|
+
* never throws).
|
|
57
|
+
*/
|
|
58
|
+
function translate(tables, key, locale) {
|
|
59
|
+
const map = tables || {};
|
|
60
|
+
for (const loc of fallbackChain(locale)) {
|
|
61
|
+
const t = map[loc];
|
|
62
|
+
if (t && Object.prototype.hasOwnProperty.call(t, key) && typeof t[key] === 'string') return t[key];
|
|
63
|
+
}
|
|
64
|
+
return key;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve a skill/agent description for `locale`: frontmatter.description_i18n[<chain>] || .description.
|
|
69
|
+
* Opt-in + backward-compatible — absent description_i18n falls straight back to the English description.
|
|
70
|
+
*/
|
|
71
|
+
function descriptionFor(frontmatter, locale) {
|
|
72
|
+
const fm = frontmatter || {};
|
|
73
|
+
const i18n = fm.description_i18n;
|
|
74
|
+
if (i18n && typeof i18n === 'object') {
|
|
75
|
+
for (const loc of fallbackChain(locale)) {
|
|
76
|
+
if (typeof i18n[loc] === 'string' && i18n[loc].trim()) return i18n[loc];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return fm.description || '';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Read a locale's flat-JSON table from disk. Returns {} on missing/parse error (fail-safe → fallback). */
|
|
83
|
+
function loadTable(locale, dir) {
|
|
84
|
+
const file = path.join(dir || MESSAGES_DIR, `${normalizeLocale(locale) || DEFAULT_LOCALE}.json`);
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
87
|
+
} catch {
|
|
88
|
+
return {};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = {
|
|
93
|
+
KNOWN_LOCALES, DEFAULT_LOCALE, MESSAGES_DIR,
|
|
94
|
+
normalizeLocale, baseLocale, fallbackChain, resolveLocale, translate, descriptionFor, loadTable,
|
|
95
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_meta": { "locale": "en", "coverage": "complete", "fallback": null },
|
|
3
|
+
"help.usage": "Usage: gdd <command> [options]",
|
|
4
|
+
"help.tagline": "A design-quality pipeline for AI coding agents.",
|
|
5
|
+
"help.commands_header": "Commands",
|
|
6
|
+
"help.options_header": "Options",
|
|
7
|
+
"help.more": "Run `gdd help <command>` for details on a command.",
|
|
8
|
+
"help.pipeline": "Pipeline: brief -> explore -> plan -> design -> verify",
|
|
9
|
+
"error.no_state": "No .design/STATE.md found. Run /gdd:start to initialize a cycle.",
|
|
10
|
+
"error.no_config": "No .design/config.json found; using defaults.",
|
|
11
|
+
"error.stage_locked": "This stage is locked under the current cycle mode.",
|
|
12
|
+
"error.permission_denied": "Permission denied: your role cannot perform this action on this section.",
|
|
13
|
+
"error.budget_cap": "Budget cap reached. Raise the cap in .design/budget.json or wait for the next task.",
|
|
14
|
+
"error.connection_unavailable": "Connection unavailable. Configure it via /gdd:connections.",
|
|
15
|
+
"error.invalid_locale": "Unknown locale. Known locales: en, ru, uk, de, fr, zh, ja.",
|
|
16
|
+
"error.merge_conflict": "STATE.md has a merge conflict. Run the conflict-resolver to reconcile per section.",
|
|
17
|
+
"error.decision_locked": "This decision is locked. Use /gdd:unlock-decision <id> --approver <who> to reopen it.",
|
|
18
|
+
"error.no_telemetry": "No cost telemetry yet. Run a cycle first.",
|
|
19
|
+
"prompt.brief_header": "Stage 1 of 5 - Brief: capture the problem, audience, constraints, and metrics.",
|
|
20
|
+
"prompt.explore_header": "Stage 2 of 5 - Explore: inventory the design surface and interview for intent.",
|
|
21
|
+
"prompt.plan_header": "Stage 3 of 5 - Plan: decompose the work into tasks.",
|
|
22
|
+
"prompt.design_header": "Stage 4 of 5 - Design: execute the plan.",
|
|
23
|
+
"prompt.verify_header": "Stage 5 of 5 - Verify: score and audit against the design system.",
|
|
24
|
+
"status.complete": "Complete.",
|
|
25
|
+
"status.in_progress": "In progress.",
|
|
26
|
+
"status.blocked": "Blocked."
|
|
27
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_meta": { "locale": "fr", "coverage": "placeholder", "fallback": "en" },
|
|
3
|
+
"help.tagline": "Un pipeline de qualité de conception pour les agents de codage IA.",
|
|
4
|
+
"status.complete": "Terminé.",
|
|
5
|
+
"status.in_progress": "En cours.",
|
|
6
|
+
"status.blocked": "Bloqué."
|
|
7
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_meta": { "locale": "ru", "coverage": "complete", "fallback": "en" },
|
|
3
|
+
"help.usage": "Использование: gdd <команда> [параметры]",
|
|
4
|
+
"help.tagline": "Конвейер качества дизайна для ИИ-агентов программирования.",
|
|
5
|
+
"help.commands_header": "Команды",
|
|
6
|
+
"help.options_header": "Параметры",
|
|
7
|
+
"help.more": "Выполните `gdd help <команда>` для подробностей о команде.",
|
|
8
|
+
"help.pipeline": "Конвейер: brief -> explore -> plan -> design -> verify",
|
|
9
|
+
"error.no_state": "Файл .design/STATE.md не найден. Выполните /gdd:start, чтобы инициализировать цикл.",
|
|
10
|
+
"error.no_config": "Файл .design/config.json не найден; используются значения по умолчанию.",
|
|
11
|
+
"error.stage_locked": "Этот этап заблокирован в текущем режиме цикла.",
|
|
12
|
+
"error.permission_denied": "Доступ запрещён: ваша роль не может выполнить это действие в этом разделе.",
|
|
13
|
+
"error.budget_cap": "Достигнут лимит бюджета. Увеличьте лимит в .design/budget.json или дождитесь следующей задачи.",
|
|
14
|
+
"error.connection_unavailable": "Подключение недоступно. Настройте его через /gdd:connections.",
|
|
15
|
+
"error.invalid_locale": "Неизвестная локаль. Доступные локали: en, ru, uk, de, fr, zh, ja.",
|
|
16
|
+
"error.merge_conflict": "В STATE.md конфликт слияния. Запустите conflict-resolver для согласования по разделам.",
|
|
17
|
+
"error.decision_locked": "Это решение заблокировано. Используйте /gdd:unlock-decision <id> --approver <кто>, чтобы открыть его повторно.",
|
|
18
|
+
"error.no_telemetry": "Телеметрия затрат пока отсутствует. Сначала выполните цикл.",
|
|
19
|
+
"prompt.brief_header": "Этап 1 из 5 - Brief: зафиксируйте задачу, аудиторию, ограничения и метрики.",
|
|
20
|
+
"prompt.explore_header": "Этап 2 из 5 - Explore: исследуйте поверхность дизайна и уточните намерение.",
|
|
21
|
+
"prompt.plan_header": "Этап 3 из 5 - Plan: разбейте работу на задачи.",
|
|
22
|
+
"prompt.design_header": "Этап 4 из 5 - Design: выполните план.",
|
|
23
|
+
"prompt.verify_header": "Этап 5 из 5 - Verify: оцените и проверьте по дизайн-системе.",
|
|
24
|
+
"status.complete": "Готово.",
|
|
25
|
+
"status.in_progress": "В процессе.",
|
|
26
|
+
"status.blocked": "Заблокировано."
|
|
27
|
+
}
|