Skip to content

Commit f8c7ab4

Browse files
authored
feat(first-hour): do_first_hour_of_work tool + 7 persona plans + Slack DM renderer (Slice 15a) (#122)
The agent's first action on a fresh tenant is now do_first_hour_of_work, a single-Sonnet-turn scaffold-driven runner that fires from main() after the intro DM lands. By the time the user finishes setup, their named persona has already pulled their morning, drafted their replies, and DMed them with one click to approve. Architect: phantom-cloud-deploy/local/2026-05-03-slice-15-first-hour- of-work-architect.md. What ships in this PR: - src/persona/work-plans.ts: code-vendored TypeScript mirror of the canonical persona-work-plans.json fixture (slice 15d-fixture, PR #22 on phantom-cloud-deploy). Seven personas locked: sdr-lilian, eng-cos-marcus, am-sloane, bdr-theo, sales-vp-priya, gtm-eng-ryan, founder-asst-adrian. Byte-equal with the canonical for persona_id literals and required_integrations arrays. - src/persona/types.ts: FirstHourOfWorkInput/Output, DraftKind, ReasonCode (11-code taxonomy locked at architect §7.2). - src/persona/scaffold-prompt.ts: the verbatim 4-step prompt from architect §1.4 with persona-driven substitutions. - src/persona/runner.ts: end-to-end orchestrator. Resolves grants via the env reader, filters required-integrations against the resolved set, builds the prompt scaffold, races the LLM call against a 60s AbortController, parses the JSON envelope, persists drafts, sends the Block Kit DM, emits the five audit events. - src/persona/llm-caller.ts: production LlmTurnCaller wrapping runtime.handleMessage with a first-hour: session id prefix and the JSON envelope parser. - src/persona/failure-modes.ts: per-persona DM bodies for the six failure modes (no_integrations_granted, all_pulls_zero_data, llm_error_mid_flow, zero_drafts_from_nonzero_data, integration_ auth_expired, sixty_second_cap_hit). Verbatim from architect §6. - src/persona/draft-store.ts: SQLite helpers for the firstboot ledger and phantom_drafts table. Status transitions pending -> approved -> sent. - src/persona/firstboot-hook.ts: the index.ts entry point. NO-OP when PHANTOM_PERSONA_ID is unset; idempotent on the firstboot ledger. - src/integrations/grants.ts: env-based reader of PHANTOM_GRANTED_ INTEGRATIONS with slack always granted (the wizard's slot 10 install is firstboot-baked). - src/channels/slack/render-first-hour-dm.ts: Block Kit renderer for the standard, fallback, and partial DM variants. Action_id pattern phantom:draft:{draft_id}:{action} per architect §5.4. - src/channels/slack/edit-modal.ts: views.open payload for the draft edit modal. - src/db/schema.ts: additive migrations (52-58) for firstboot_state ledger columns and the new phantom_drafts table. Idempotent per the migration-safety contract. - src/channels/slack-actions.ts: Bolt regex handler for the phantom:draft:{id}:{action} action_id pattern; per-draft buttons flip to context blocks on click. - src/channels/slack.ts: extends sendDm to accept optional Block Kit blocks (the HTTP transport already accepted them; Socket Mode catches up). - src/index.ts: fires the firstboot hook after the intro DM lands. - .github/workflows/ci.yml: persona-work-plans drift verifier step. Runs the cross-repo guard from phantom-cloud-deploy in strict mode once the canonical fixture lands on main. - CLAUDE.md: First hour of work invariant section covering the contract, idempotency, the action_id wire pattern, and the 60s hard cap. Coverage: - 111 new tests across work-plans, scaffold-prompt, runner, failure- modes, llm-caller, render-first-hour-dm, edit-modal, draft-store, firstboot-hook, grants. Snapshot pins on the Block Kit JSON for Lilian's full DM and the per-persona header/footer text shape. - Happy path: drafts persist, DM lands, all five audit events fire in order (start, pulls, drafts, dm, finish). - Each of the six failure modes covered by its own runner test: no_integrations_granted, all_pulls_zero_data, llm_error_mid_flow, zero_drafts_from_nonzero_data, integration_auth_expired, sixty_ second_cap_hit. Plus slack_post_failed and persona_unknown. - Defense-in-depth: a happy-path cycle across all seven personas pinning that none degrades to a fallback DM. Pre-push gate: bun typecheck clean, bun test 2537 pass / 0 fail (+111 from this slice), bun lint clean.
1 parent f26fd0c commit f8c7ab4

30 files changed

Lines changed: 4338 additions & 5 deletions

.github/workflows/ci.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,24 @@ jobs:
3434
- name: Test
3535
run: bun test
3636

37+
# Slice 15a: persona-work-plans drift guard. The in-VM phantom
38+
# mirror at src/persona/work-plans.ts must stay byte-equal with
39+
# the canonical fixture at scripts/shared/persona-work-plans.json
40+
# in phantom-cloud-deploy. The verifier parses persona_id and
41+
# required_integrations literals out of the TS file and diffs
42+
# them against the canonical JSON. Strict mode flips on once the
43+
# phantom-cloud-deploy fixture lands on main; until then this
44+
# step skips on a missing fixture (the slice 15d-fixture PR is
45+
# already open at #22).
46+
- name: verify persona-work-plans drift
47+
run: |
48+
git clone --depth 1 https://github.com/ghostwright/phantom-cloud-deploy.git /tmp/pcd || true
49+
if [ -f /tmp/pcd/scripts/shared/verify-persona-work-plans.sh ]; then
50+
PERSONA_WORK_PLANS_VERIFY_STRICT=1 /tmp/pcd/scripts/shared/verify-persona-work-plans.sh
51+
else
52+
echo "phantom-cloud-deploy fixture not yet on main; skipping drift verification"
53+
fi
54+
3755
- name: Install Chat UI Dependencies
3856
working-directory: chat-ui
3957
run: bun install --frozen-lockfile

CLAUDE.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,18 @@ The file is read at request-time and never cached in process memory, so an in-pl
291291

292292
**Migration-safety CI gate.** Phantom tenants survive a snapshot-replace upgrade by rsyncing `/app/data/` (which contains `phantom.sqlite`) from the old ZFS clone to the new clone. Migrations therefore must be **additive** (no `DROP TABLE`, `DROP COLUMN`, `DROP CONSTRAINT`, `DROP INDEX`, no `RENAME`) and **idempotent** (`CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`). `ALTER TABLE ... ADD COLUMN` is allowed because the runner's `_migrations` index gates re-execution. The gate runs in CI on every PR via `bun run src/db/check-migrations.ts`, fails closed on any violation, and rejects the PR. The full architectural argument lives in the Phase 18 architect doc §5.2.
293293

294+
## First hour of work (Slice 15a)
295+
296+
The agent's first action on every fresh tenant is `do_first_hour_of_work`, a single-Sonnet-turn scaffold-driven runner that fires from `src/index.ts::main()` after the intro DM lands. The architect doc is `phantom-cloud-deploy/local/2026-05-03-slice-15-first-hour-of-work-architect.md`.
297+
298+
**The contract.** ONE turn, ONE prompt-pinned 4-step structure (PULL, IDENTIFY, DRAFT, DM), ONE 60 second hard cap enforced by `AbortController` in `src/persona/runner.ts`. The runner reads `PHANTOM_PERSONA_ID`, `PHANTOM_TENANT_SLUG`, `PHANTOM_OWNER_NAME`, `PHANTOM_OWNER_EMAIL`, `PHANTOM_GRANTED_INTEGRATIONS` from the env, builds the system-prompt scaffold from `src/persona/scaffold-prompt.ts`, runs the LLM via `src/persona/llm-caller.ts` (which wraps `runtime.handleMessage` with a `first-hour:` session id prefix), parses the JSON envelope from the model's final text, persists drafts to the local SQLite `phantom_drafts` table, sends the Block Kit DM via `src/channels/slack/render-first-hour-dm.ts`, and emits five audit events (start, pulls, drafts, dm, finish) per the architect's §7 reason-code taxonomy (11 codes locked).
299+
300+
**The wire.** Action_id pattern for the DM buttons is `phantom:draft:{draft_id}:{action}` where action is one of `send | edit | skip | skip_all | save_only`. The Bolt action dispatcher at `src/channels/slack-actions.ts` parses the action_id and forwards to the registered draft action recorder; phantom-control's `agent_drafts` table (slice 15b) is the cross-host record. Idempotency is layered: in-VM SQLite `firstboot_state.first_hour_of_work_completed_at` is layer 1; phantom-control's `agents.first_hour_of_work_completed_at` is layer 2 (slice 15b); the per-fire ULID is layer 3.
301+
302+
**Persona work-plans cross-repo invariant.** `src/persona/work-plans.ts` is the in-VM mirror of `phantom-cloud-deploy/scripts/shared/persona-work-plans.json`. Both files MUST stay byte-equal for the seven `persona_id` literals AND the `required_integrations` arrays per persona. The cross-repo verifier at `phantom-cloud-deploy/scripts/shared/verify-persona-work-plans.sh` runs in this repo's CI on every PR; strict mode flips on once the canonical fixture lands on `phantom-cloud-deploy:main`. The seven locked ids are `sdr-lilian`, `eng-cos-marcus`, `am-sloane`, `bdr-theo`, `sales-vp-priya`, `gtm-eng-ryan`, `founder-asst-adrian`. The architect doc §2.5 carried `sales-leader-priya` as a typo; the canonical wins.
303+
304+
**The 60 second hard cap.** Architect §6.6 names this as the partial-DM affordance, NOT a bonus. The runner's `Promise.race` between the LLM call and a `setTimeout(hard_cap_ms)` enforces wall-clock cutoff; on timeout the partial DM ships with the `Reply MORE for the rest` affordance and reason_code `sixty_second_cap_hit`. The `phantom/src/persona/__tests__/runner.test.ts::"sixty_second_cap_hit"` test exercises the AbortController path with a 50ms cap stub.
305+
294306
## Key Design Decisions
295307

296308
**Qdrant over LanceDB:** WAL durability with crash recovery. Native hybrid search (dense + BM25 sparse vectors). Named vectors for separate embedding spaces. Mmap mode for low memory. TypeScript REST client works with Bun (no NAPI addon risk).

src/channels/slack-actions.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,31 @@ export function setMorningBriefPreferenceRecorder(recorder: MorningBriefPreferen
5252
morningBriefRecorder = recorder;
5353
}
5454

55+
/**
56+
* First-hour-of-work draft action handler (architect §5.4). The user
57+
* clicks Send / Edit / Skip / Skip-All / Save-Only on the Block Kit
58+
* DM that the runner posted at firstboot; the Bolt action handler
59+
* parses the action_id (phantom:draft:{draft_id}:{action}), looks up
60+
* the draft via this recorder, and the recorder updates the local
61+
* SQLite row + (slice 15b) POSTs to phantom-control.
62+
*/
63+
export type FirstHourDraftAction = "send" | "edit" | "skip" | "skip_all" | "save_only";
64+
65+
export type FirstHourDraftActionRecorder = (params: {
66+
draftId: string;
67+
action: FirstHourDraftAction;
68+
userId: string;
69+
channelId: string;
70+
messageTs: string;
71+
clickedAt: number;
72+
}) => Promise<void> | void;
73+
74+
let firstHourDraftRecorder: FirstHourDraftActionRecorder | null = null;
75+
76+
export function setFirstHourDraftActionRecorder(recorder: FirstHourDraftActionRecorder): void {
77+
firstHourDraftRecorder = recorder;
78+
}
79+
5580
/** Extract a typed value from the Bolt body object */
5681
function bodyField<T>(body: unknown, ...keys: string[]): T | undefined {
5782
let obj = body as Record<string, unknown> | undefined;
@@ -255,4 +280,89 @@ export function registerSlackActions(app: App): void {
255280
}
256281
});
257282
}
283+
284+
// Slice 15a first-hour-of-work draft buttons. Pattern:
285+
// phantom:draft:{draft_id}:{action} where action is send | edit |
286+
// skip | skip_all | save_only. The handler parses the action_id,
287+
// hands off to the draft recorder (slice 15a wires the local
288+
// SQLite update; slice 15b POSTs to phantom-control).
289+
app.action(/^phantom:draft:[^:]+:(send|edit|skip|skip_all|save_only)$/, async ({ ack, body, client }) => {
290+
await ack();
291+
292+
const b = body as unknown as Record<string, unknown>;
293+
const actions = b.actions as Array<{ action_id: string; value?: string }> | undefined;
294+
if (!actions?.[0]) return;
295+
296+
const actionId = actions[0].action_id;
297+
const parts = actionId.split(":");
298+
if (parts.length !== 4) return;
299+
const draftId = parts[2];
300+
const action = parts[3] as FirstHourDraftAction;
301+
302+
const channelId = bodyField<string>(b, "channel", "id");
303+
const messageTs = bodyField<string>(b, "message", "ts");
304+
const userId = bodyField<string>(b, "user", "id");
305+
if (!channelId || !messageTs || !userId) return;
306+
307+
if (firstHourDraftRecorder) {
308+
try {
309+
await firstHourDraftRecorder({
310+
draftId,
311+
action,
312+
userId,
313+
channelId,
314+
messageTs,
315+
clickedAt: Date.now(),
316+
});
317+
} catch (err) {
318+
const msg = err instanceof Error ? err.message : String(err);
319+
console.warn(`[slack] first-hour draft recorder failed: ${msg}`);
320+
}
321+
} else {
322+
console.log(`[slack] first_hour_draft_action draft=${draftId} action=${action} user=${userId}`);
323+
}
324+
325+
// Update the message: replace the draft's own actions block with a
326+
// context block reflecting the click. The skip-all and save_only
327+
// surfaces follow the same shape; the send/edit/skip variants
328+
// flip just the per-draft actions row, leaving other drafts in the
329+
// same DM untouched.
330+
const existingBlocks = bodyField<Array<{ type: string; block_id?: string }>>(b, "message", "blocks") ?? [];
331+
const replacementText = ackTextForAction(action, draftId);
332+
const targetBlockId = action === "skip_all" ? "phantom_first_hour_skip_all" : `draft_${draftId}`;
333+
const updatedBlocks = existingBlocks.map((block) => {
334+
if (block.block_id !== targetBlockId) return block;
335+
return {
336+
type: "context",
337+
elements: [{ type: "mrkdwn", text: replacementText }],
338+
};
339+
});
340+
const messageText = bodyField<string>(b, "message", "text") ?? "";
341+
try {
342+
await client.chat.update({
343+
channel: channelId,
344+
ts: messageTs,
345+
text: messageText,
346+
blocks: updatedBlocks,
347+
} as unknown as Parameters<typeof client.chat.update>[0]);
348+
} catch (err) {
349+
const msg = err instanceof Error ? err.message : String(err);
350+
console.warn(`[slack] Failed to update first-hour draft buttons: ${msg}`);
351+
}
352+
});
353+
}
354+
355+
function ackTextForAction(action: FirstHourDraftAction, draftId: string): string {
356+
switch (action) {
357+
case "send":
358+
return `_Sent. (draft ${draftId})_`;
359+
case "edit":
360+
return `_Edit modal opened. (draft ${draftId})_`;
361+
case "skip":
362+
return `_Skipped. (draft ${draftId})_`;
363+
case "skip_all":
364+
return "_All drafts skipped. Reply MORE to surface them again._";
365+
case "save_only":
366+
return `_Saved. (draft ${draftId})_`;
367+
}
258368
}

src/channels/slack.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,8 +290,8 @@ export class SlackChannel implements Channel {
290290
return egressPostToChannel(this.egressContext(), channelId, text);
291291
}
292292

293-
async sendDm(userId: string, text: string): Promise<string | null> {
294-
return egressSendDm(this.egressContext(), userId, text);
293+
async sendDm(userId: string, text: string, blocks?: SlackBlock[]): Promise<string | null> {
294+
return egressSendDm(this.egressContext(), userId, text, blocks);
295295
}
296296

297297
async postThinking(channel: string, threadTs: string): Promise<string | null> {
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
2+
3+
exports[`buildEditModalView snapshot of the modal payload 1`] = `
4+
{
5+
"blocks": [
6+
{
7+
"text": {
8+
"text":
9+
"*Draft kind:* Slack reply
10+
*Edit hints from Lilian here:* tighten the ask"
11+
,
12+
"type": "mrkdwn",
13+
},
14+
"type": "section",
15+
},
16+
{
17+
"block_id": "draft_body_block",
18+
"element": {
19+
"action_id": "draft_body_input",
20+
"initial_value": "Hi Sarah, here is the v3 quote.",
21+
"max_length": 4000,
22+
"multiline": true,
23+
"type": "plain_text_input",
24+
},
25+
"label": {
26+
"emoji": false,
27+
"text": "Draft body",
28+
"type": "plain_text",
29+
},
30+
"type": "input",
31+
},
32+
{
33+
"block_id": "edit_save_only",
34+
"elements": [
35+
{
36+
"action_id": "phantom:draft:dft_01J:save_only",
37+
"text": {
38+
"emoji": false,
39+
"text": "Save without sending",
40+
"type": "plain_text",
41+
},
42+
"type": "button",
43+
"value": "dft_01J",
44+
},
45+
],
46+
"type": "actions",
47+
},
48+
],
49+
"callback_id": "phantom_draft_edit_modal",
50+
"close": {
51+
"emoji": false,
52+
"text": "Cancel",
53+
"type": "plain_text",
54+
},
55+
"private_metadata": "dft_01J",
56+
"submit": {
57+
"emoji": false,
58+
"text": "Save and send",
59+
"type": "plain_text",
60+
},
61+
"title": {
62+
"emoji": false,
63+
"text": "Edit draft",
64+
"type": "plain_text",
65+
},
66+
"type": "modal",
67+
}
68+
`;

0 commit comments

Comments
 (0)