@foldspace_npm/harness 0.1.16 → 0.1.18

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 (56) hide show
  1. package/CLAUDE.md +55 -21
  2. package/README.md +1 -1
  3. package/bin/attach.mjs +73 -20
  4. package/bin/badge.mjs +50 -0
  5. package/bin/inject.mjs +2 -2
  6. package/bin/observe.mjs +4 -3
  7. package/package.json +1 -1
  8. package/recipes/INDEX.md +7 -2
  9. package/recipes/account-overview/README.md +26 -0
  10. package/recipes/account-overview/agent/accounts.ts +159 -0
  11. package/recipes/account-overview/agent/actions/show_account_overview.ts +61 -0
  12. package/recipes/account-overview/agent/api/accounts.ts +59 -0
  13. package/recipes/account-overview/agent/views/brand.ts +22 -0
  14. package/recipes/account-overview/agent/views/overview.ts +303 -0
  15. package/recipes/account-overview/fixtures/overview.empty.json +12 -0
  16. package/recipes/account-overview/fixtures/overview.ok.json +30 -0
  17. package/recipes/account-overview/fixtures/overview.unsigned.json +9 -0
  18. package/recipes/account-overview/recipe.json +10 -0
  19. package/recipes/bottom-bar/README.md +43 -0
  20. package/recipes/bottom-bar/agent/bottomBar.ts +94 -0
  21. package/recipes/bottom-bar/fixtures/configuration.sent.json +17 -0
  22. package/recipes/bottom-bar/recipe.json +9 -0
  23. package/recipes/opportunities-at-risk/README.md +26 -0
  24. package/recipes/opportunities-at-risk/agent/actions/show_opportunities_at_risk.ts +49 -0
  25. package/recipes/opportunities-at-risk/agent/api/opportunities.ts +30 -0
  26. package/recipes/opportunities-at-risk/agent/opportunities.ts +75 -0
  27. package/recipes/opportunities-at-risk/agent/views/at-risk.ts +115 -0
  28. package/recipes/opportunities-at-risk/agent/views/brand.ts +20 -0
  29. package/recipes/opportunities-at-risk/fixtures/at-risk.empty.json +4 -0
  30. package/recipes/opportunities-at-risk/fixtures/at-risk.ok.json +25 -0
  31. package/recipes/opportunities-at-risk/fixtures/at-risk.unsigned.json +3 -0
  32. package/recipes/opportunities-at-risk/recipe.json +10 -0
  33. package/recipes/prepare-for-a-meeting/README.md +27 -0
  34. package/recipes/prepare-for-a-meeting/agent/actions/prepare_for_meeting.ts +70 -0
  35. package/recipes/prepare-for-a-meeting/agent/api/meetings.ts +24 -0
  36. package/recipes/prepare-for-a-meeting/agent/meetings.ts +66 -0
  37. package/recipes/prepare-for-a-meeting/fixtures/prep.ok.json +24 -0
  38. package/recipes/prepare-for-a-meeting/fixtures/prep.unsigned.json +9 -0
  39. package/recipes/prepare-for-a-meeting/recipe.json +10 -0
  40. package/recipes/update-meeting-notes/README.md +22 -0
  41. package/recipes/update-meeting-notes/agent/actions/update_meeting_notes.ts +36 -0
  42. package/recipes/update-meeting-notes/agent/api/meetings.ts +22 -0
  43. package/recipes/update-meeting-notes/fixtures/note.ok.json +3 -0
  44. package/recipes/update-meeting-notes/recipe.json +10 -0
  45. package/recipes/upload-contacts/README.md +25 -0
  46. package/recipes/upload-contacts/agent/actions/upload_contacts.ts +77 -0
  47. package/recipes/upload-contacts/agent/api/contacts.ts +29 -0
  48. package/recipes/upload-contacts/agent/contacts.ts +133 -0
  49. package/recipes/upload-contacts/agent/views/brand.ts +21 -0
  50. package/recipes/upload-contacts/agent/views/uploader.ts +394 -0
  51. package/recipes/upload-contacts/fixtures/import.ok.json +9 -0
  52. package/recipes/upload-contacts/recipe.json +10 -0
  53. package/src/attach-preflight.mjs +9 -0
  54. package/src/badge-core.mjs +22 -0
  55. package/src/cli-registry.mjs +32 -3
  56. package/src/keep-focus.mjs +49 -0
@@ -0,0 +1,36 @@
1
+ // A write, with no card. The result is what was saved: the note id and the
2
+ // meeting id. A missing field is named. A missing meeting is not_found.
3
+
4
+ import { saveMeetingNote } from "../api/meetings";
5
+
6
+ export const update_meeting_notes = {
7
+ execute: async (params: { meetingId?: string; notes?: string; author?: string }) => {
8
+ const meetingId = typeof params?.meetingId === "string" ? params.meetingId.trim() : "";
9
+ const notes = typeof params?.notes === "string" ? params.notes.trim() : "";
10
+ const author = typeof params?.author === "string" ? params.author.trim() : "";
11
+
12
+ if (!meetingId) {
13
+ return { success: false, error: "meetingId was not provided.", reason: "config" };
14
+ }
15
+ if (!notes) {
16
+ return { success: false, error: "notes were not provided.", reason: "config" };
17
+ }
18
+
19
+ const res = await saveMeetingNote(meetingId, notes, author || undefined);
20
+ if (!res.ok) {
21
+ console.warn("[update_meeting_notes]", res.status, res.reason, res.detail);
22
+ return { success: false, error: res.error, reason: res.reason };
23
+ }
24
+
25
+ const noteId = res.data.note?.id?.trim() || "";
26
+ if (!noteId) {
27
+ return { success: false, error: "The note was not saved.", reason: "server" };
28
+ }
29
+
30
+ return {
31
+ success: true,
32
+ message: "A note was saved.",
33
+ data: { noteId, meetingId },
34
+ };
35
+ },
36
+ };
@@ -0,0 +1,22 @@
1
+ // Mock API. This path is not a real product. Find an API that saves a note on a
2
+ // meeting and returns the new note id, then replace the path and the envelope
3
+ // with what you observed. `__observe_me` cannot succeed until you do.
4
+
5
+ import { apiFetch, type ApiResult } from "../utils";
6
+
7
+ export type NotePayload = {
8
+ note?: { id?: string | null } | null;
9
+ };
10
+
11
+ export async function saveMeetingNote(
12
+ meetingId: string,
13
+ content: string,
14
+ author?: string,
15
+ ): Promise<ApiResult<NotePayload>> {
16
+ const body: { content: string; author?: string } = { content };
17
+ if (author) body.author = author;
18
+ return apiFetch<NotePayload>(`/__observe_me/meetings/${encodeURIComponent(meetingId)}/notes`, {
19
+ method: "POST",
20
+ body: JSON.stringify(body),
21
+ });
22
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "note": { "id": "note_9" }
3
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "title": "Update meeting notes",
3
+ "level": "L4",
4
+ "family": "write",
5
+ "kind": "action",
6
+ "action": "update_meeting_notes",
7
+ "entry": "agent/actions/update_meeting_notes.ts",
8
+ "outcome": "A note is saved on a meeting and the new note id comes back",
9
+ "provenBy": 1
10
+ }
@@ -0,0 +1,25 @@
1
+ # Upload contacts — L4
2
+
3
+ **The user says** "upload these contacts". **The user sees** the import card: a drop zone, a review table, then the result. Nothing is written until they choose Import. Done finishes the card.
4
+
5
+ **Proven by 1 production build.**
6
+
7
+ `awaitUserInput` is set, so the agent is paused until `callback` runs. Cancel, an empty file, a failed import, and a finished import each call `callback`. The callback states what happened: created, skipped, and a reason on each skipped row.
8
+
9
+ In Agent Studio: an action with key `upload_contacts` and no parameters. The file comes from the card, not from the model.
10
+
11
+ ## Adapt it
12
+
13
+ | File | Change |
14
+ |---|---|
15
+ | `agent/api/contacts.ts` | Mock import path. Find a real API that accepts a list of contacts and returns created and skipped rows |
16
+ | `agent/contacts.ts` | The columns the file actually has |
17
+ | `agent/views/uploader.ts` | The card. `brand.ts` is the live import card; re-sample it when the host brand differs |
18
+
19
+ ## What that build learned the hard way
20
+
21
+ - **Preview before the write.** Choosing a file is not a request to create records. The write runs when they choose Import.
22
+ - **Invalid rows stay out of the request.** They are reported as skipped, with the reason, and the valid rows are what get posted.
23
+ - **Every path calls `callback`.** Cancel and an empty file included. Leaving the card up without calling it holds the conversation.
24
+ - **A failed import is not "Created 0".** Draw the failure and return its `reason`.
25
+ - **"View Contacts" only works where the app listens.** The build's single-page app routed on a message the card posts; other apps do not. ⚠️ Keep the button only if you wire that message, or open the list with a navigation route.
@@ -0,0 +1,77 @@
1
+ // The agent waits while the user picks a file, reviews the rows, and imports.
2
+ // Calling callback is mandatory on every finished path. The write happens only
3
+ // after Import. Done is what finishes the card.
4
+
5
+ import type { ImportOutcome } from "../contacts";
6
+ import { renderFailure, type ApiFailure, type FailureReason } from "../utils";
7
+ import { mountContactUpload } from "../views/uploader";
8
+
9
+ type UploadResult = {
10
+ success: boolean;
11
+ message?: string;
12
+ error?: string;
13
+ reason?: FailureReason;
14
+ data?: { cancelled?: boolean; created?: number; skipped?: number; rows?: ImportOutcome["rows"] };
15
+ };
16
+
17
+ function done(outcome: ImportOutcome): UploadResult {
18
+ return {
19
+ success: true,
20
+ message: `Created ${outcome.created}. Skipped ${outcome.skipped}.`,
21
+ data: { cancelled: false, created: outcome.created, skipped: outcome.skipped, rows: outcome.rows },
22
+ };
23
+ }
24
+
25
+ export const upload_contacts = {
26
+ execute: async (): Promise<UploadResult> => {
27
+ return { success: true, message: "A contact upload is on screen.", data: { cancelled: false } };
28
+ },
29
+
30
+ awaitUserInput: true,
31
+
32
+ render: (
33
+ result: UploadResult | undefined,
34
+ host: HTMLElement,
35
+ header: HTMLElement,
36
+ callback: (value: unknown, disableOnSubmit?: boolean) => void,
37
+ ) => {
38
+ if (!result?.success) {
39
+ renderFailure(host, {
40
+ ok: false,
41
+ status: 0,
42
+ error: result?.error ?? "The upload could not be opened.",
43
+ reason: result?.reason ?? "server",
44
+ });
45
+ callback(result, true);
46
+ return;
47
+ }
48
+
49
+ mountContactUpload(host, header, {
50
+ onCancel: () => {
51
+ callback(
52
+ { success: true, message: "The user closed the upload.", data: { cancelled: true } },
53
+ true,
54
+ );
55
+ },
56
+ onEmpty: () => {
57
+ callback(
58
+ {
59
+ success: true,
60
+ message: "The file had no contacts.",
61
+ data: { cancelled: false, created: 0, skipped: 0, rows: [] },
62
+ },
63
+ true,
64
+ );
65
+ },
66
+ onDone: (outcome) => {
67
+ callback(done(outcome), true);
68
+ },
69
+ onFailed: (failure: ApiFailure) => {
70
+ callback(
71
+ { success: false, error: failure.error, reason: failure.reason, data: { cancelled: false } },
72
+ true,
73
+ );
74
+ },
75
+ });
76
+ },
77
+ };
@@ -0,0 +1,29 @@
1
+ // Mock API. This path is not a real product. Find an API that accepts a list of
2
+ // contacts and returns how many were created and which rows were skipped, then
3
+ // replace the path and the envelope with what you observed. `__observe_me`
4
+ // cannot succeed until you do.
5
+
6
+ import { apiFetch, type ApiResult } from "../utils";
7
+
8
+ export type ContactDraft = {
9
+ firstName: string;
10
+ lastName: string;
11
+ email: string;
12
+ phone: string | null;
13
+ title: string | null;
14
+ accountName: string | null;
15
+ };
16
+
17
+ export type ImportPayload = {
18
+ created?: number;
19
+ skipped?: number;
20
+ createdContacts?: { name?: string | null; email?: string | null }[] | null;
21
+ skippedContacts?: { name?: string | null; email?: string | null; reason?: string | null }[] | null;
22
+ };
23
+
24
+ export async function importContacts(contacts: ContactDraft[]): Promise<ApiResult<ImportPayload>> {
25
+ return apiFetch<ImportPayload>("/__observe_me/contacts/import", {
26
+ method: "POST",
27
+ body: JSON.stringify({ contacts }),
28
+ });
29
+ }
@@ -0,0 +1,133 @@
1
+ import type { ContactDraft, ImportPayload } from "./api/contacts";
2
+
3
+ export type ParsedContact = ContactDraft & {
4
+ valid: boolean;
5
+ reason: string | null;
6
+ };
7
+
8
+ export type ContactRow = {
9
+ name: string;
10
+ email: string;
11
+ reason: string | null;
12
+ };
13
+
14
+ export type ImportOutcome = {
15
+ created: number;
16
+ skipped: number;
17
+ rows: ContactRow[];
18
+ };
19
+
20
+ type Column = "firstName" | "lastName" | "email" | "phone" | "title" | "accountName";
21
+
22
+ function splitCsvLine(line: string): string[] {
23
+ const values: string[] = [];
24
+ let current = "";
25
+ let inQuotes = false;
26
+ for (let i = 0; i < line.length; i++) {
27
+ const char = line[i];
28
+ if (char === '"') {
29
+ inQuotes = !inQuotes;
30
+ } else if (char === "," && !inQuotes) {
31
+ values.push(current.trim());
32
+ current = "";
33
+ } else {
34
+ current += char;
35
+ }
36
+ }
37
+ values.push(current.trim());
38
+ return values;
39
+ }
40
+
41
+ function columnOf(header: string): Column | null {
42
+ const normalized = header.replace(/['"]/g, "").trim().toLowerCase();
43
+ if (normalized.includes("first") && normalized.includes("name")) return "firstName";
44
+ if (normalized === "firstname" || normalized === "first_name") return "firstName";
45
+ if (normalized.includes("last") && normalized.includes("name")) return "lastName";
46
+ if (normalized === "lastname" || normalized === "last_name") return "lastName";
47
+ if (normalized.includes("email")) return "email";
48
+ if (normalized.includes("phone") || normalized.includes("mobile")) return "phone";
49
+ if (normalized.includes("title")) return "title";
50
+ if (normalized.includes("account") || normalized.includes("company")) return "accountName";
51
+ return null;
52
+ }
53
+
54
+ export function parseContactsCsv(csv: string): ParsedContact[] {
55
+ const lines = csv.trim().split(/\r?\n/).filter((line) => line.trim() !== "");
56
+ if (lines.length < 2) return [];
57
+
58
+ const columns = splitCsvLine(lines[0]).map(columnOf);
59
+ const contacts: ParsedContact[] = [];
60
+
61
+ for (const line of lines.slice(1)) {
62
+ const values = splitCsvLine(line);
63
+ const read = (column: Column) => {
64
+ const index = columns.indexOf(column);
65
+ return index >= 0 ? (values[index] ?? "").trim() : "";
66
+ };
67
+ const contact: ParsedContact = {
68
+ firstName: read("firstName"),
69
+ lastName: read("lastName"),
70
+ email: read("email"),
71
+ phone: read("phone") || null,
72
+ title: read("title") || null,
73
+ accountName: read("accountName") || null,
74
+ valid: true,
75
+ reason: null,
76
+ };
77
+ if (!contact.firstName || !contact.lastName) {
78
+ contact.valid = false;
79
+ contact.reason = "Missing name";
80
+ } else if (!contact.email) {
81
+ contact.valid = false;
82
+ contact.reason = "Missing email";
83
+ } else if (!contact.email.includes("@")) {
84
+ contact.valid = false;
85
+ contact.reason = "Invalid email";
86
+ }
87
+ contacts.push(contact);
88
+ }
89
+
90
+ return contacts;
91
+ }
92
+
93
+ export function contactName(contact: { firstName?: string; lastName?: string; name?: string | null }): string {
94
+ if (contact.name?.trim()) return contact.name.trim();
95
+ return `${contact.firstName ?? ""} ${contact.lastName ?? ""}`.trim();
96
+ }
97
+
98
+ export function outcomeFrom(parsed: ParsedContact[], server: ImportPayload): ImportOutcome {
99
+ const heldBack = parsed.filter((row) => !row.valid);
100
+ const serverSkipped = server.skippedContacts ?? [];
101
+ const rows: ContactRow[] = [
102
+ ...(server.createdContacts ?? []).map((row) => ({
103
+ name: row.name?.trim() || "Untitled",
104
+ email: row.email?.trim() || "",
105
+ reason: null,
106
+ })),
107
+ ...heldBack.map((row) => ({
108
+ name: contactName(row) || "Untitled",
109
+ email: row.email,
110
+ reason: row.reason,
111
+ })),
112
+ ...serverSkipped.map((row) => ({
113
+ name: row.name?.trim() || "Untitled",
114
+ email: row.email?.trim() || "",
115
+ reason: row.reason?.trim() || null,
116
+ })),
117
+ ];
118
+ const created = typeof server.created === "number" ? server.created : (server.createdContacts ?? []).length;
119
+ const skipped =
120
+ heldBack.length + (typeof server.skipped === "number" ? server.skipped : serverSkipped.length);
121
+ return { created, skipped, rows };
122
+ }
123
+
124
+ export function toDraft(contact: ParsedContact): ContactDraft {
125
+ return {
126
+ firstName: contact.firstName,
127
+ lastName: contact.lastName,
128
+ email: contact.email,
129
+ phone: contact.phone,
130
+ title: contact.title,
131
+ accountName: contact.accountName,
132
+ };
133
+ }
@@ -0,0 +1,21 @@
1
+ // Palette sampled from the production build this recipe came from (a dark CRM).
2
+ // Re-sample these from the host page when that product's brand differs.
3
+ // Record where each value came from in docs/app-profile.md.
4
+
5
+ export const brand = {
6
+ surface: "#0E1729",
7
+ border: "#344256",
8
+ text: "#E0E0E0",
9
+ textStrong: "#FFFFFF",
10
+ muted: "#94A3B8",
11
+ faint: "#64748B",
12
+ accent: "#60A5FA",
13
+ accentDeep: "#3B82F6",
14
+ ok: "#34D399",
15
+ warn: "#FBBF24",
16
+ bad: "#F87171",
17
+ tableHead: "#1E293B",
18
+ disabled: "#475569",
19
+ font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
20
+ radius: "8px",
21
+ } as const;