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.
- package/lib/cli.js +34 -1
- package/package.json +1 -1
- package/templates/README.md +4 -2
- package/templates/cabinet/checkpoint-protocol.md +113 -0
- package/templates/handoff/capture-and-encrypt.mjs +67 -0
- package/templates/handoff/decrypt-credential.mjs +72 -0
- package/templates/handoff/example-checklist.yaml +81 -0
- package/templates/handoff/generate-keys.mjs +35 -0
- package/templates/handoff/handoff-checklist.mjs +172 -0
- package/templates/handoff/handoff-crypto.mjs +102 -0
- package/templates/handoff/handoff-transport.mjs +105 -0
- package/templates/handoff/schema.md +92 -0
- package/templates/handoff/secure-input.mjs +99 -0
- package/templates/hooks/action-completion-gate.sh +70 -0
- package/templates/skills/cabinet-mantine-quality/SKILL.md +21 -0
- package/templates/skills/cc-upgrade/SKILL.md +14 -0
- package/templates/skills/cc-upgrade/phases/execute-plans-rename-detect.md +77 -0
- package/templates/skills/execute/SKILL.md +30 -46
- package/templates/skills/execute-group/SKILL.md +183 -0
- package/templates/skills/{execute-plans → generate-plan-groups}/SKILL.md +72 -89
- package/templates/skills/handoff/SKILL.md +122 -0
- package/templates/skills/handoff-add/SKILL.md +44 -0
- package/templates/skills/handoff-ask/SKILL.md +45 -0
- package/templates/skills/handoff-create/SKILL.md +161 -0
- package/templates/skills/handoff-progress/SKILL.md +55 -0
- package/templates/skills/handoff-status/SKILL.md +86 -0
- package/templates/skills/plan/SKILL.md +2 -1
- package/templates/skills/validate/phases/validators.md +37 -0
- package/templates/workflows/execute-group.js +495 -0
- /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-
|
|
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
package/templates/README.md
CHANGED
|
@@ -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 (
|
|
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/
|
|
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
|
+
}
|