create-claude-cabinet 0.29.14 → 0.31.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.
Files changed (30) hide show
  1. package/lib/cli.js +34 -1
  2. package/package.json +1 -1
  3. package/templates/README.md +4 -2
  4. package/templates/cabinet/checkpoint-protocol.md +113 -0
  5. package/templates/handoff/capture-and-encrypt.mjs +67 -0
  6. package/templates/handoff/decrypt-credential.mjs +72 -0
  7. package/templates/handoff/example-checklist.yaml +81 -0
  8. package/templates/handoff/generate-keys.mjs +35 -0
  9. package/templates/handoff/handoff-checklist.mjs +172 -0
  10. package/templates/handoff/handoff-crypto.mjs +102 -0
  11. package/templates/handoff/handoff-transport.mjs +105 -0
  12. package/templates/handoff/schema.md +92 -0
  13. package/templates/handoff/secure-input.mjs +99 -0
  14. package/templates/hooks/action-completion-gate.sh +70 -0
  15. package/templates/skills/cabinet-mantine-quality/SKILL.md +21 -0
  16. package/templates/skills/cc-upgrade/SKILL.md +14 -0
  17. package/templates/skills/cc-upgrade/phases/execute-plans-rename-detect.md +77 -0
  18. package/templates/skills/execute/SKILL.md +30 -46
  19. package/templates/skills/execute-group/SKILL.md +183 -0
  20. package/templates/skills/{execute-plans → generate-plan-groups}/SKILL.md +72 -89
  21. package/templates/skills/handoff/SKILL.md +122 -0
  22. package/templates/skills/handoff-add/SKILL.md +44 -0
  23. package/templates/skills/handoff-ask/SKILL.md +45 -0
  24. package/templates/skills/handoff-create/SKILL.md +161 -0
  25. package/templates/skills/handoff-progress/SKILL.md +55 -0
  26. package/templates/skills/handoff-status/SKILL.md +86 -0
  27. package/templates/skills/plan/SKILL.md +2 -1
  28. package/templates/skills/validate/phases/validators.md +37 -0
  29. package/templates/workflows/execute-group.js +495 -0
  30. /package/templates/skills/{execute-plans → generate-plan-groups}/scripts/build-conflict-graph.js +0 -0
package/lib/cli.js CHANGED
@@ -485,7 +485,7 @@ const MODULES = {
485
485
  mandatory: false,
486
486
  default: true,
487
487
  lean: true,
488
- templates: ['skills/plan', 'skills/execute', 'skills/execute-plans', 'skills/investigate'],
488
+ templates: ['skills/plan', 'skills/execute', 'skills/generate-plan-groups', 'skills/execute-group', 'workflows/execute-group.js', 'skills/investigate', 'cabinet/checkpoint-protocol.md'],
489
489
  },
490
490
  'compliance': {
491
491
  name: 'Compliance Stack (rules + enforcement)',
@@ -592,6 +592,22 @@ const MODULES = {
592
592
  ],
593
593
  postInstall: 'site-audit-setup',
594
594
  },
595
+ handoff: {
596
+ name: 'Handoff (secure credential collection)',
597
+ description: 'Skill-based credential handoff for consulting engagements. Encrypted credential capture via OS dialog, pluggable transport (email/MCP/file), bidirectional messaging. Six skills across consultant and client sides.',
598
+ mandatory: false,
599
+ default: false,
600
+ lean: false,
601
+ templates: [
602
+ 'skills/handoff',
603
+ 'skills/handoff-progress',
604
+ 'skills/handoff-ask',
605
+ 'skills/handoff-create',
606
+ 'skills/handoff-add',
607
+ 'skills/handoff-status',
608
+ 'handoff',
609
+ ],
610
+ },
595
611
  };
596
612
 
597
613
  /** Recursively collect all relative file paths under a directory. */
@@ -1263,6 +1279,23 @@ async function run() {
1263
1279
  }
1264
1280
  }
1265
1281
  }
1282
+ // execute-plans/ → generate-plan-groups/ (the plan→parallel split).
1283
+ // Key-matched, not version-gated: if the old key is present it needs
1284
+ // migrating; if it isn't, this no-ops. Idempotent on re-run.
1285
+ for (const key of Object.keys(existingManifest)) {
1286
+ const match = key.match(/\.claude\/skills\/execute-plans\//);
1287
+ if (match) {
1288
+ const newKey = key.replace('skills/execute-plans/', 'skills/generate-plan-groups/');
1289
+ // Partial-state guard: if the project already tracks the new key
1290
+ // (a prior partial migration), keep its hash — don't clobber it with
1291
+ // the stale execute-plans hash, which would force a needless re-copy.
1292
+ if (!existingManifest[newKey]) {
1293
+ existingManifest[newKey] = existingManifest[key];
1294
+ }
1295
+ delete existingManifest[key];
1296
+ migrationCount++;
1297
+ }
1298
+ }
1266
1299
  // Future manifest key migrations go here
1267
1300
  if (migrationCount > 0) {
1268
1301
  console.log(` 🔄 Migrated ${migrationCount} manifest key${migrationCount === 1 ? '' : 's'} for directory rename`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-cabinet",
3
- "version": "0.29.14",
3
+ "version": "0.31.0",
4
4
  "description": "Claude Cabinet — opinionated process scaffolding for Claude Code projects",
5
5
  "bin": {
6
6
  "create-claude-cabinet": "bin/create-claude-cabinet.js"
@@ -27,7 +27,7 @@ templates, see [EXTENSIONS.md](EXTENSIONS.md).
27
27
  | `rules/enforcement-pipeline.md` | Generic enforcement pipeline: capture, classify, promote, encode, monitor. Describes the compliance stack and promotion criteria. |
28
28
  | `rules/memory-capture.md` | When and how to capture memories via /cc-remember to the per-file curated layout at ~/.claude/projects/<slug>/memory/. What to capture, what not to, cadence guidance. |
29
29
 
30
- ### Skills (22 workflow + 31 cabinet members)
30
+ ### Skills (24 workflow + 31 cabinet members)
31
31
 
32
32
  **Workflow Skills:**
33
33
 
@@ -39,7 +39,8 @@ templates, see [EXTENSIONS.md](EXTENSIONS.md).
39
39
  | `skills/debrief/` | Session close. Inventory work, close items, run cabinet consultations, update state, persist, record lessons. 9 phase files. |
40
40
  | `skills/debrief-quick/` | Quick debrief variant — core phases only, skip presentation. |
41
41
  | `skills/execute/` | Execute a plan with cabinet member checkpoints. 3-checkpoint protocol (pre-implementation, per-file-group, pre-commit). 5 phase files. |
42
- | `skills/execute-plans/` | Batch execution of multiple plans with conflict detection. |
42
+ | `skills/generate-plan-groups/` | Scheduler: find plans with surface-area declarations, build a conflict graph, persist conflict-free parallel groups as pib-db `grp:` tags. Does not execute — hands each group to /execute-group. |
43
+ | `skills/execute-group/` | Runner: execute one generated group via the `execute-group.js` workflow — cabinet pre-review, parallel worktree implementation, sequential merge with per-plan review, integration, informed final review, completion report. |
43
44
  | `skills/cc-extract/` | Analyze project artifacts and propose upstream extraction candidates for Claude Cabinet. |
44
45
  | `skills/investigate/` | Structured codebase exploration: frame, observe, hypothesize, test, conclude. |
45
46
  | `skills/cc-link/` | Set up local development linking for Claude Cabinet source repo work. |
@@ -103,6 +104,7 @@ mandates and scoped directives.
103
104
  | `cabinet/eval-protocol.md` | Structured assessment framework for evaluating skill/cabinet member effectiveness. |
104
105
  | `cabinet/lifecycle.md` | When to adopt, retire, and assess cabinet members. |
105
106
  | `cabinet/output-contract.md` | How cabinet members produce structured findings for the audit system. |
107
+ | `cabinet/checkpoint-protocol.md` | The cabinet checkpoint mechanism (member selection, verdict schema, escalation) shared by /execute and /execute-group — read, not copied, so both stay in sync. |
106
108
  | `cabinet/prompt-guide.md` | Craft knowledge for writing cabinet member prompts. 17 principles. |
107
109
 
108
110
  ### Scripts (12)
@@ -0,0 +1,113 @@
1
+ # Cabinet Checkpoint Protocol
2
+
3
+ The single source of truth for how cabinet members review work in
4
+ progress. `/execute` and `/execute-group` both **read this file and
5
+ follow it** rather than copying the mechanism — so a change here flows to
6
+ every checkpoint, everywhere, with no copy-drift.
7
+
8
+ A checkpoint is a chance to stop before the cost of fixing goes up. The
9
+ mechanism is the same at every scale; only the **scope** of what's
10
+ reviewed changes.
11
+
12
+ ## When you are told to "follow the checkpoint protocol scoped to X"
13
+
14
+ The caller names a scope. The scope determines what each spawned agent
15
+ reviews — everything else (how to spawn, what to collect, how to
16
+ escalate) is identical.
17
+
18
+ | Scope | Reviews | Runs |
19
+ |-------|---------|------|
20
+ | `pre-impl` | The plan text + the list of files it will change | Before any code is written |
21
+ | `this file group` | The git diff for one logical group of changed files | After each file group is implemented |
22
+ | `pre-commit` | The full git diff of all changes | After implementation, before commit |
23
+ | `this group's aggregate` *(group runs only)* | The combined diff of all plans in a parallel group | After a parallel group merges |
24
+
25
+ A *parallel group* (the last row) is `/execute-group`'s unit of work: a
26
+ set of conflict-free plans implemented concurrently in separate worktrees,
27
+ then merged together. `/execute` never exercises that scope — it runs one
28
+ plan at a time and uses only the first three.
29
+
30
+ ## Step 1 — Select which members to spawn
31
+
32
+ Spawn one Agent per cabinet member that matches **either**:
33
+
34
+ - **Standing mandate** — `standingMandate` includes the current verb
35
+ (`execute`). Read `.claude/skills/_index.json` to find them. These run
36
+ at every checkpoint regardless of surface area.
37
+ - **Surface area** — a file in the reviewed scope matches the member's
38
+ file patterns, or a keyword in the plan description matches the
39
+ member's topic keywords.
40
+
41
+ Fall back to reading `cabinet-*/SKILL.md` frontmatter if the index is
42
+ missing.
43
+
44
+ **Err toward inclusion.** A member that activates unnecessarily costs a
45
+ few seconds; one that stays silent when it was needed costs rework. For
46
+ `this file group` scope, narrow to members matching *that group's* files
47
+ — a member reviewing 3 changed files gives sharper feedback than one
48
+ reviewing 30.
49
+
50
+ If the project has no cabinet members, skip the checkpoint and proceed —
51
+ checkpoints add depth, not structure.
52
+
53
+ ## Step 2 — Spawn the agents (concurrently)
54
+
55
+ Spawn the selected members concurrently — they don't depend on each
56
+ other. **How** you spawn depends on the caller:
57
+
58
+ - From `/execute` (main session): issue all Agent-tool calls in a single
59
+ message so they run in parallel.
60
+ - From `/execute-group` (workflow script): issue the spawns as `agent()`
61
+ calls inside a `parallel()` block. Worktree agents cannot spawn
62
+ reviewers themselves — the workflow orchestrator does it.
63
+
64
+ Either way, each spawned agent receives:
65
+
66
+ - The cabinet member's full `SKILL.md` content
67
+ - Essential project briefing from `.claude/cabinet/_briefing.md` (read it
68
+ once, reuse for every agent)
69
+ - The member's `directives.execute`, if present — paste it in to sharpen
70
+ the member's focus
71
+ - **The scoped material:** plan text + file list (`pre-impl`), or the
72
+ relevant git diff (`this file group`, `pre-commit`, aggregate)
73
+ - An instruction to return the verdict object below
74
+
75
+ ## Step 3 — Collect verdicts
76
+
77
+ Each agent returns exactly this shape:
78
+
79
+ ```json
80
+ {
81
+ "cabinet_member": "name",
82
+ "verdict": "continue" | "pause" | "stop",
83
+ "concerns": [
84
+ { "description": "...", "evidence": "...", "severity": "blocking" | "advisory" }
85
+ ]
86
+ }
87
+ ```
88
+
89
+ ## Step 4 — Apply escalation
90
+
91
+ Collect every verdict, then:
92
+
93
+ - **Any `stop`** → halt. Show the concern. Require an explicit override
94
+ from the user before proceeding.
95
+ - **Any `pause`** → show the concern with options: proceed / address /
96
+ abort.
97
+ - **3+ `pause`** → escalate to stop-equivalent (halt, require override).
98
+ - **All `continue`** → proceed with a brief one-line summary.
99
+
100
+ At `pre-commit` and aggregate scopes, re-check earlier `continue`
101
+ concerns: a concern that was minor in one file group can become
102
+ significant once all changes are viewed together.
103
+
104
+ ## Principles
105
+
106
+ - **Cabinet members are guardrails, not gates.** The user always has the
107
+ final say. A `stop` requires explicit override — it is not an automatic
108
+ rejection.
109
+ - **Scope tightly.** The narrower the diff a member reviews, the better
110
+ the feedback.
111
+ - **The pre-commit sweep catches emergent issues.** File groups that look
112
+ fine alone create problems in combination — type mismatches across
113
+ boundaries, security gaps from API + frontend changes landing together.
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ //
3
+ // Single-subprocess credential capture + encryption.
4
+ // Plaintext never exits this process, never enters Claude's context or Anthropic's API.
5
+ // stdout: serialized encrypted envelope (base64) only.
6
+ //
7
+ import { readFile } from 'node:fs/promises';
8
+ import { captureSecureInput, detectPlatform, DialogCancelledError, DialogUnavailableError } from './secure-input.mjs';
9
+ import { encryptCredential, serializeEnvelope } from './handoff-crypto.mjs';
10
+
11
+ const args = process.argv.slice(2);
12
+
13
+ function getArg(name) {
14
+ const i = args.indexOf(name);
15
+ return i >= 0 ? args[i + 1] : null;
16
+ }
17
+
18
+ const prompt = getArg('--prompt');
19
+ const publicKeyPath = getArg('--public-key');
20
+ const testMode = args.includes('--test');
21
+
22
+ if (!testMode && (!prompt || !publicKeyPath)) {
23
+ console.error('Usage: node capture-and-encrypt.mjs --prompt <text> --public-key <path>');
24
+ console.error(' node capture-and-encrypt.mjs --test');
25
+ process.exit(1);
26
+ }
27
+
28
+ try {
29
+ if (testMode) {
30
+ const { generateKeypair, decryptCredential, deserializeEnvelope } = await import('./handoff-crypto.mjs');
31
+ const plaintext = 'test-credential-value';
32
+ const { publicKey, privateKey } = await generateKeypair();
33
+ const envelope = await encryptCredential(plaintext, publicKey);
34
+ const serialized = serializeEnvelope(envelope);
35
+ const recovered = deserializeEnvelope(serialized);
36
+ const decrypted = await decryptCredential(recovered, privateKey);
37
+
38
+ if (decrypted !== plaintext) {
39
+ console.error(JSON.stringify({ error: 'test_failed', detail: 'Roundtrip mismatch' }));
40
+ process.exit(4);
41
+ }
42
+ console.log(JSON.stringify({
43
+ ok: true,
44
+ platform: detectPlatform(),
45
+ envelope_id: envelope.envelope_id,
46
+ }));
47
+ process.exit(0);
48
+ }
49
+
50
+ const publicKeyJWK = JSON.parse(await readFile(publicKeyPath, 'utf-8'));
51
+ const plaintext = await captureSecureInput(prompt);
52
+ const envelope = await encryptCredential(plaintext, publicKeyJWK);
53
+ // Plaintext is now only in local `plaintext` var — GC will collect it.
54
+ // Only the encrypted envelope reaches stdout.
55
+ process.stdout.write(serializeEnvelope(envelope));
56
+ } catch (err) {
57
+ if (err instanceof DialogCancelledError) {
58
+ console.error(JSON.stringify({ error: 'cancelled' }));
59
+ process.exit(2);
60
+ }
61
+ if (err instanceof DialogUnavailableError) {
62
+ console.error(JSON.stringify({ error: 'no_dialog', platform: err.platform }));
63
+ process.exit(3);
64
+ }
65
+ console.error(JSON.stringify({ error: 'encrypt_failed', detail: err.message }));
66
+ process.exit(4);
67
+ }
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ //
3
+ // Decrypt a credential envelope using the consultant's passphrase-protected private key.
4
+ // Passphrase captured via secure OS dialog — never enters Claude's context.
5
+ // stdout: decrypted plaintext (displayed once by the skill, not stored).
6
+ //
7
+ import { readFile } from 'node:fs/promises';
8
+ import { captureSecureInput, DialogCancelledError, DialogUnavailableError } from './secure-input.mjs';
9
+ import { decryptCredential, decryptPrivateKey, deserializeEnvelope } from './handoff-crypto.mjs';
10
+
11
+ const args = process.argv.slice(2);
12
+
13
+ function getArg(name) {
14
+ const i = args.indexOf(name);
15
+ return i >= 0 ? args[i + 1] : null;
16
+ }
17
+
18
+ const envelopeInput = getArg('--envelope');
19
+ const privateKeyPath = getArg('--private-key') || './keys/consultant.priv.jwk.enc';
20
+ const testMode = args.includes('--test');
21
+
22
+ if (!testMode && !envelopeInput) {
23
+ console.error('Usage: node decrypt-credential.mjs --envelope <base64> [--private-key <path>]');
24
+ console.error(' node decrypt-credential.mjs --test');
25
+ process.exit(1);
26
+ }
27
+
28
+ try {
29
+ if (testMode) {
30
+ const { generateKeypair, encryptCredential, encryptPrivateKey, serializeEnvelope } = await import('./handoff-crypto.mjs');
31
+ const testPassphrase = 'test-pass-123';
32
+ const testPlaintext = 'test-secret-value';
33
+
34
+ const { publicKey, privateKey } = await generateKeypair();
35
+ const encrypted = await encryptPrivateKey(privateKey, testPassphrase);
36
+ const envelope = await encryptCredential(testPlaintext, publicKey);
37
+ const serialized = serializeEnvelope(envelope);
38
+
39
+ const recovered = decryptPrivateKey(encrypted, testPassphrase);
40
+ const decrypted = await decryptCredential(deserializeEnvelope(serialized), await recovered);
41
+
42
+ console.log(JSON.stringify({
43
+ ok: decrypted === testPlaintext,
44
+ detail: decrypted === testPlaintext ? 'Roundtrip passed' : 'Mismatch',
45
+ }));
46
+ process.exit(decrypted === testPlaintext ? 0 : 4);
47
+ }
48
+
49
+ const encryptedKey = JSON.parse(await readFile(privateKeyPath, 'utf-8'));
50
+ const envelope = deserializeEnvelope(envelopeInput);
51
+
52
+ const passphrase = await captureSecureInput('Enter your private key passphrase');
53
+ const privateKeyJWK = await decryptPrivateKey(encryptedKey, passphrase);
54
+ const plaintext = await decryptCredential(envelope, privateKeyJWK);
55
+
56
+ process.stdout.write(plaintext);
57
+ } catch (err) {
58
+ if (err instanceof DialogCancelledError) {
59
+ console.error(JSON.stringify({ error: 'cancelled' }));
60
+ process.exit(2);
61
+ }
62
+ if (err instanceof DialogUnavailableError) {
63
+ console.error(JSON.stringify({ error: 'no_dialog', platform: err.platform }));
64
+ process.exit(3);
65
+ }
66
+ if (err.message?.includes('decrypt') || err.message?.includes('OperationError')) {
67
+ console.error(JSON.stringify({ error: 'wrong_passphrase' }));
68
+ process.exit(5);
69
+ }
70
+ console.error(JSON.stringify({ error: 'decrypt_failed', detail: err.message }));
71
+ process.exit(4);
72
+ }
@@ -0,0 +1,81 @@
1
+ # Example handoff checklist — customize for your engagement.
2
+ # See schema.md for the full format reference.
3
+
4
+ meta:
5
+ title: "Project Go-Live Credentials"
6
+ consultant: "Your Name"
7
+ public_key: "./keys/consultant.pub.jwk"
8
+
9
+ transport:
10
+ type: email
11
+ consultant: "consultant@example.com"
12
+ client: "client@example.com"
13
+
14
+ sections:
15
+ - key: hosting
16
+ title: "Hosting Setup"
17
+ items:
18
+ - key: hosting_provider
19
+ prompt: "Which hosting provider are you using?"
20
+ kind: decide
21
+ options: [Railway, Vercel, Fly.io, Other]
22
+
23
+ - key: railway_token
24
+ prompt: "Your Railway API token"
25
+ kind: credential
26
+ help: "Find this at railway.app → Account Settings → Tokens"
27
+ visibility:
28
+ depends_on: hosting_provider
29
+ value_in: [Railway]
30
+
31
+ - key: vercel_token
32
+ prompt: "Your Vercel access token"
33
+ kind: credential
34
+ help: "Find this at vercel.com → Settings → Tokens"
35
+ visibility:
36
+ depends_on: hosting_provider
37
+ value_in: [Vercel]
38
+
39
+ - key: email_service
40
+ title: "Email Service"
41
+ items:
42
+ - key: email_provider
43
+ prompt: "Which email service are you using?"
44
+ kind: decide
45
+ options: [Postmark, SendGrid, Resend, None]
46
+
47
+ - key: postmark_token
48
+ prompt: "Your Postmark server API token"
49
+ kind: credential
50
+ help: "Find this in your Postmark server → API Tokens tab"
51
+ visibility:
52
+ depends_on: email_provider
53
+ value_in: [Postmark]
54
+
55
+ - key: sendgrid_key
56
+ prompt: "Your SendGrid API key"
57
+ kind: credential
58
+ visibility:
59
+ depends_on: email_provider
60
+ value_in: [SendGrid]
61
+
62
+ - key: resend_key
63
+ prompt: "Your Resend API key"
64
+ kind: credential
65
+ help: "Find this at resend.com → API Keys"
66
+ visibility:
67
+ depends_on: email_provider
68
+ value_in: [Resend]
69
+
70
+ - key: domain
71
+ title: "Domain & DNS"
72
+ items:
73
+ - key: domain_name
74
+ prompt: "What domain will this project use?"
75
+ kind: provide
76
+ help: "e.g., example.com or app.example.com"
77
+
78
+ - key: dns_access
79
+ prompt: "Do you have access to your domain's DNS settings?"
80
+ kind: confirm
81
+ help: "We'll need to add records for the hosting provider and email service."
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ import { generateKeypair, encryptPrivateKey } from './handoff-crypto.mjs';
3
+ import { captureSecureInput } from './secure-input.mjs';
4
+ import { mkdir, writeFile } from 'node:fs/promises';
5
+ import { resolve } from 'node:path';
6
+
7
+ const args = process.argv.slice(2);
8
+
9
+ function getArg(name) {
10
+ const i = args.indexOf(name);
11
+ return i >= 0 ? args[i + 1] : null;
12
+ }
13
+
14
+ const outdir = getArg('--outdir') || './keys';
15
+ const testPassphrase = getArg('--test-passphrase');
16
+
17
+ let passphrase;
18
+ if (testPassphrase) {
19
+ passphrase = testPassphrase;
20
+ } else {
21
+ console.error('A passphrase dialog will appear — enter a passphrase to protect your private key.');
22
+ console.error('You will need this passphrase to decrypt credentials via /handoff-status.');
23
+ passphrase = await captureSecureInput('Create a passphrase for your private key');
24
+ }
25
+
26
+ const { publicKey, privateKey } = await generateKeypair();
27
+ const encrypted = await encryptPrivateKey(privateKey, passphrase);
28
+
29
+ await mkdir(resolve(outdir), { recursive: true });
30
+ await writeFile(resolve(outdir, 'consultant.pub.jwk'), JSON.stringify(publicKey, null, 2));
31
+ await writeFile(resolve(outdir, 'consultant.priv.jwk.enc'), JSON.stringify(encrypted, null, 2));
32
+
33
+ console.log(`Keys written to ${outdir}/`);
34
+ console.log(' consultant.pub.jwk — share with client (ships with plugin)');
35
+ console.log(' consultant.priv.jwk.enc — keep private (encrypted with your passphrase)');
@@ -0,0 +1,172 @@
1
+ import { readFile, writeFile, rename } from 'node:fs/promises';
2
+
3
+ const VALID_KINDS = ['decide', 'provide', 'confirm', 'credential'];
4
+ const VALID_TRANSPORT_TYPES = ['email', 'mcp', 'file'];
5
+
6
+ export function validateChecklist(checklist) {
7
+ const errors = [];
8
+
9
+ if (!checklist.meta) errors.push('Missing "meta" section');
10
+ else {
11
+ if (!checklist.meta.title) errors.push('meta.title is required');
12
+ if (!checklist.meta.public_key) errors.push('meta.public_key is required');
13
+ }
14
+
15
+ if (!checklist.transport) {
16
+ errors.push('Missing "transport" section');
17
+ } else if (!VALID_TRANSPORT_TYPES.includes(checklist.transport.type)) {
18
+ errors.push(`transport.type must be one of: ${VALID_TRANSPORT_TYPES.join(', ')}`);
19
+ }
20
+
21
+ if (!Array.isArray(checklist.sections)) {
22
+ errors.push('Missing or invalid "sections" array');
23
+ return { valid: false, errors };
24
+ }
25
+
26
+ // First pass: collect all keys for cross-section dependency validation
27
+ const allKeys = new Set();
28
+ for (const section of checklist.sections) {
29
+ for (const item of (section.items || [])) {
30
+ if (item.key) allKeys.add(item.key);
31
+ }
32
+ }
33
+
34
+ const seenKeys = new Set();
35
+ for (const section of checklist.sections) {
36
+ if (!section.key) errors.push('Section missing "key"');
37
+ if (!section.title) errors.push(`Section ${section.key || '?'} missing "title"`);
38
+ if (!Array.isArray(section.items)) {
39
+ errors.push(`Section ${section.key || '?'} missing "items" array`);
40
+ continue;
41
+ }
42
+ for (const item of section.items) {
43
+ if (!item.key) { errors.push('Item missing "key"'); continue; }
44
+ if (!item.prompt) errors.push(`Item ${item.key} missing "prompt"`);
45
+ if (!VALID_KINDS.includes(item.kind)) {
46
+ errors.push(`Item ${item.key}: kind must be one of: ${VALID_KINDS.join(', ')}`);
47
+ }
48
+ if (seenKeys.has(item.key)) errors.push(`Duplicate item key: ${item.key}`);
49
+ seenKeys.add(item.key);
50
+
51
+ if (item.visibility) {
52
+ if (!item.visibility.depends_on) errors.push(`Item ${item.key}: visibility.depends_on is required`);
53
+ if (!Array.isArray(item.visibility.value_in)) errors.push(`Item ${item.key}: visibility.value_in must be an array`);
54
+ if (item.visibility.depends_on && !allKeys.has(item.visibility.depends_on)) {
55
+ errors.push(`Item ${item.key}: depends_on "${item.visibility.depends_on}" references non-existent key`);
56
+ }
57
+ }
58
+ }
59
+ }
60
+
61
+ return { valid: errors.length === 0, errors };
62
+ }
63
+
64
+ function getAllItems(checklist) {
65
+ return (checklist.sections || []).flatMap(s => s.items || []);
66
+ }
67
+
68
+ export function detectCycles(checklist) {
69
+ const items = getAllItems(checklist);
70
+ const keySet = new Set(items.map(i => i.key));
71
+ const graph = new Map();
72
+ const inDegree = new Map();
73
+
74
+ for (const item of items) {
75
+ graph.set(item.key, []);
76
+ inDegree.set(item.key, 0);
77
+ }
78
+
79
+ for (const item of items) {
80
+ const parent = item.visibility?.depends_on;
81
+ if (parent && keySet.has(parent)) {
82
+ graph.get(parent).push(item.key);
83
+ inDegree.set(item.key, inDegree.get(item.key) + 1);
84
+ }
85
+ }
86
+
87
+ const queue = [...inDegree.entries()].filter(([, d]) => d === 0).map(([k]) => k);
88
+ let processed = 0;
89
+
90
+ while (queue.length > 0) {
91
+ const key = queue.shift();
92
+ processed++;
93
+ for (const child of graph.get(key) || []) {
94
+ const nd = inDegree.get(child) - 1;
95
+ inDegree.set(child, nd);
96
+ if (nd === 0) queue.push(child);
97
+ }
98
+ }
99
+
100
+ const hasCycle = processed < items.length;
101
+ const cycleKeys = hasCycle
102
+ ? [...inDegree.entries()].filter(([, d]) => d > 0).map(([k]) => k)
103
+ : [];
104
+
105
+ return { hasCycle, cycleKeys };
106
+ }
107
+
108
+ export function computeVisibility(checklist, answers) {
109
+ const items = getAllItems(checklist);
110
+ const visible = new Set();
111
+
112
+ for (const item of items) {
113
+ if (!item.visibility) visible.add(item.key);
114
+ }
115
+
116
+ let changed = true;
117
+ while (changed) {
118
+ changed = false;
119
+ for (const item of items) {
120
+ if (visible.has(item.key) || !item.visibility) continue;
121
+ const parentKey = item.visibility.depends_on;
122
+ if (!visible.has(parentKey)) continue;
123
+ const parentAnswer = answers[parentKey]?.value;
124
+ if (parentAnswer && item.visibility.value_in.includes(parentAnswer)) {
125
+ visible.add(item.key);
126
+ changed = true;
127
+ }
128
+ }
129
+ }
130
+
131
+ return visible;
132
+ }
133
+
134
+ export async function loadState(path) {
135
+ try {
136
+ return JSON.parse(await readFile(path, 'utf-8'));
137
+ } catch (err) {
138
+ if (err.code === 'ENOENT') return null;
139
+ throw err;
140
+ }
141
+ }
142
+
143
+ export function createState(checklistPath) {
144
+ return {
145
+ checklist: checklistPath,
146
+ started_at: new Date().toISOString(),
147
+ updated_at: new Date().toISOString(),
148
+ answers: {},
149
+ };
150
+ }
151
+
152
+ export async function saveState(path, state) {
153
+ state.updated_at = new Date().toISOString();
154
+ const tmp = path + '.tmp';
155
+ await writeFile(tmp, JSON.stringify(state, null, 2));
156
+ await rename(tmp, path);
157
+ }
158
+
159
+ export function recordAnswer(state, key, value) {
160
+ state.answers[key] = { value, answered_at: new Date().toISOString() };
161
+ }
162
+
163
+ export function recordCredentialSent(state, key, envelopeId) {
164
+ state.answers[key] = { status: 'sent', envelope_id: envelopeId, sent_at: new Date().toISOString() };
165
+ }
166
+
167
+ export function getProgress(checklist, state) {
168
+ const answers = state?.answers || {};
169
+ const visible = computeVisibility(checklist, answers);
170
+ const completed = [...visible].filter(key => answers[key]).length;
171
+ return { total: visible.size, completed, remaining: visible.size - completed };
172
+ }