@rubytech/create-maxy-code 0.1.174 → 0.1.175

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 (49) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/neo4j/schema.cypher +49 -10
  3. package/payload/platform/plugins/docs/references/voice-mirror-guide.md +18 -16
  4. package/payload/platform/scripts/setup-account.sh +8 -0
  5. package/payload/platform/scripts/verify-skill-tool-surface.sh +27 -8
  6. package/payload/premium-plugins/writer-craft/PLUGIN.md +11 -7
  7. package/payload/premium-plugins/writer-craft/hooks/__tests__/voice-session-end-text.test.sh +284 -0
  8. package/payload/premium-plugins/writer-craft/hooks/voice-session-end-text.sh +75 -0
  9. package/payload/premium-plugins/writer-craft/mcp/dist/cli/ingest-session-text.d.ts +3 -0
  10. package/payload/premium-plugins/writer-craft/mcp/dist/cli/ingest-session-text.d.ts.map +1 -0
  11. package/payload/premium-plugins/writer-craft/mcp/dist/cli/ingest-session-text.js +58 -0
  12. package/payload/premium-plugins/writer-craft/mcp/dist/cli/ingest-session-text.js.map +1 -0
  13. package/payload/premium-plugins/writer-craft/mcp/dist/index.d.ts.map +1 -1
  14. package/payload/premium-plugins/writer-craft/mcp/dist/index.js +95 -18
  15. package/payload/premium-plugins/writer-craft/mcp/dist/index.js.map +1 -1
  16. package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.d.ts +47 -6
  17. package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.d.ts.map +1 -1
  18. package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.js +69 -6
  19. package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.js.map +1 -1
  20. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.d.ts +8 -2
  21. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.d.ts.map +1 -1
  22. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.js +82 -53
  23. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.js.map +1 -1
  24. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.d.ts +23 -0
  25. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.d.ts.map +1 -0
  26. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.js +191 -0
  27. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.js.map +1 -0
  28. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.d.ts +6 -0
  29. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.d.ts.map +1 -1
  30. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.js +14 -6
  31. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.js.map +1 -1
  32. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.d.ts +12 -1
  33. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.d.ts.map +1 -1
  34. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.js +23 -12
  35. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.js.map +1 -1
  36. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.d.ts +3 -0
  37. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.d.ts.map +1 -1
  38. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.js +33 -5
  39. package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.js.map +1 -1
  40. package/payload/premium-plugins/writer-craft/mcp/scripts/smoke.mjs +289 -28
  41. package/payload/premium-plugins/writer-craft/mcp/src/cli/ingest-session-text.ts +65 -0
  42. package/payload/premium-plugins/writer-craft/mcp/src/index.ts +108 -18
  43. package/payload/premium-plugins/writer-craft/mcp/src/lib/voice-corpus.ts +78 -6
  44. package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-distil-profile.ts +115 -56
  45. package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-ingest-session-text.ts +229 -0
  46. package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-record-feedback.ts +20 -6
  47. package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-retrieve-conditioning.ts +38 -13
  48. package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-tag-content.ts +42 -5
  49. package/payload/premium-plugins/writer-craft/skills/voice-mirror/SKILL.md +65 -31
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.174",
3
+ "version": "0.1.175",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -1279,13 +1279,17 @@ CREATE INDEX postal_address_postcode IF NOT EXISTS
1279
1279
  FOR (a:PostalAddress) ON (a.postalCode);
1280
1280
 
1281
1281
  // ----------------------------------------------------------
1282
- // Voice Mirror (Task 356) — authorial-voice capture + application
1282
+ // Voice Mirror (Task 356 + Task 462) — authorial-voice capture + application
1283
1283
  // ----------------------------------------------------------
1284
- // :VoiceProfile holds the distilled style card for one :AdminUser.
1285
- // One profile per AdminUser; the (accountId, adminUserId) pair is unique.
1284
+ // :VoiceProfile holds the distilled style card for one (AdminUser, format).
1285
+ // Task 462: one profile per (accountId, userId, format); format
1286
+ // {text, email, social-post, article, marketing-copy, note}.
1287
+ // The old (accountId, adminUserId) unique constraint is dropped and replaced.
1288
+ //
1286
1289
  // :VoiceEdit captures operator edits on agent drafts as feedback,
1287
1290
  // stamped with a Haiku-summarised intent; many edits feed back into
1288
- // the next distillation.
1291
+ // the next distillation. VoiceEdit now carries `format` to route
1292
+ // FEEDBACK_FOR edges to the matching per-format :VoiceProfile.
1289
1293
  //
1290
1294
  // `authorshipMode` is an enum property on content-bearing labels
1291
1295
  // (`:KnowledgeDocument`, `:ConversationArchive` (Task 397 — every chat
@@ -1293,30 +1297,62 @@ FOR (a:PostalAddress) ON (a.postalCode);
1293
1297
  // `:Message`, `:SocialPost`) with
1294
1298
  // values `human-only | human-led-agent-assisted | agent-led-human-reviewed
1295
1299
  // | agent-only | unknown`. Legacy nodes default to `unknown` until
1296
- // reviewed via the voice-mirror backfill flow. The index lets the
1297
- // distillation walk filter on `authorshipMode = 'human-only'` cheaply.
1300
+ // reviewed via the voice-mirror backfill flow. The per-label authorship
1301
+ // index lets the distillation walk filter cheaply.
1302
+ //
1303
+ // `format` is an enum property on corpus nodes (same label set) and on
1304
+ // :VoiceProfile nodes. The per-label format index lets per-format corpus
1305
+ // walks and profile reads avoid full-label scans.
1306
+
1307
+ // Task 462 — drop the old (accountId, adminUserId) constraint before
1308
+ // creating the new (accountId, userId, format) three-key constraint.
1309
+ // `IF EXISTS` makes the DROP idempotent on fresh installs.
1310
+ DROP CONSTRAINT voice_profile_admin_unique IF EXISTS;
1298
1311
 
1299
- CREATE CONSTRAINT voice_profile_admin_unique IF NOT EXISTS
1300
- FOR (v:VoiceProfile) REQUIRE (v.accountId, v.adminUserId) IS UNIQUE;
1312
+ CREATE CONSTRAINT voice_profile_format_unique IF NOT EXISTS
1313
+ FOR (v:VoiceProfile) REQUIRE (v.accountId, v.userId, v.format) IS UNIQUE;
1301
1314
 
1302
1315
  CREATE INDEX voice_profile_account IF NOT EXISTS
1303
1316
  FOR (v:VoiceProfile) ON (v.accountId);
1304
1317
 
1305
- CREATE INDEX voice_edit_admin IF NOT EXISTS
1306
- FOR (e:VoiceEdit) ON (e.adminUserId);
1318
+ CREATE INDEX voice_profile_format IF NOT EXISTS
1319
+ FOR (v:VoiceProfile) ON (v.format);
1320
+
1321
+ CREATE INDEX voice_edit_user IF NOT EXISTS
1322
+ FOR (e:VoiceEdit) ON (e.userId);
1307
1323
 
1308
1324
  CREATE INDEX knowledge_document_authorship IF NOT EXISTS
1309
1325
  FOR (k:KnowledgeDocument) ON (k.authorshipMode);
1310
1326
 
1327
+ CREATE INDEX knowledge_document_format IF NOT EXISTS
1328
+ FOR (k:KnowledgeDocument) ON (k.format);
1329
+
1311
1330
  CREATE INDEX conversation_archive_authorship IF NOT EXISTS
1312
1331
  FOR (c:ConversationArchive) ON (c.authorshipMode);
1313
1332
 
1333
+ CREATE INDEX conversation_archive_format IF NOT EXISTS
1334
+ FOR (c:ConversationArchive) ON (c.format);
1335
+
1314
1336
  CREATE INDEX message_authorship IF NOT EXISTS
1315
1337
  FOR (m:Message) ON (m.authorshipMode);
1316
1338
 
1339
+ CREATE INDEX message_format IF NOT EXISTS
1340
+ FOR (m:Message) ON (m.format);
1341
+
1342
+ // contentHash index — deduplication MERGE in voice-ingest-session-text uses
1343
+ // MERGE (n:Message {contentHash: $hash, accountId: $accountId}). Without this
1344
+ // index, each MERGE scans all :Message nodes for the account. The index on
1345
+ // contentHash alone lets neo4j narrow the lookup before applying the
1346
+ // accountId predicate.
1347
+ CREATE INDEX message_content_hash IF NOT EXISTS
1348
+ FOR (m:Message) ON (m.contentHash);
1349
+
1317
1350
  CREATE INDEX social_post_authorship IF NOT EXISTS
1318
1351
  FOR (s:SocialPost) ON (s.authorshipMode);
1319
1352
 
1353
+ CREATE INDEX social_post_format IF NOT EXISTS
1354
+ FOR (s:SocialPost) ON (s.format);
1355
+
1320
1356
  // :Section authorshipMode — the corpus walk includes conversation-archive
1321
1357
  // :Section chunks (parent :ConversationArchive) alongside ordinary
1322
1358
  // document sections under :KnowledgeDocument. Index on the :Section label
@@ -1325,6 +1361,9 @@ FOR (s:SocialPost) ON (s.authorshipMode);
1325
1361
  CREATE INDEX section_authorship IF NOT EXISTS
1326
1362
  FOR (s:Section) ON (s.authorshipMode);
1327
1363
 
1364
+ CREATE INDEX section_format IF NOT EXISTS
1365
+ FOR (s:Section) ON (s.format);
1366
+
1328
1367
  CREATE INDEX social_post_account IF NOT EXISTS
1329
1368
  FOR (s:SocialPost) ON (s.accountId);
1330
1369
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## What It Does
4
4
 
5
- {{productName}} reads emails, posts, and documents that you wrote yourself, and uses them to make sure agent-drafted copy reads like you wrote it — not like generic AI prose.
5
+ {{productName}} reads emails, posts, documents, and your own chat messages, and uses them to make sure agent-drafted copy reads like you wrote it — not like generic AI prose.
6
6
 
7
7
  Anthropic models cannot be fine-tuned. Voice mirror works by feeding the model two things alongside every drafting task:
8
8
 
@@ -11,32 +11,34 @@ Anthropic models cannot be fine-tuned. Voice mirror works by feeding the model t
11
11
 
12
12
  The model conditions on both and produces copy that reads as yours.
13
13
 
14
- ## Setup
14
+ Voice mirror maintains a separate profile for each type of content you write: plain text, email, social posts, articles, notes, and marketing copy. The right profile is applied automatically based on what you're drafting.
15
15
 
16
- ### 1. Tell {{productName}} what you wrote
16
+ ## How Your Voice Is Captured
17
17
 
18
- Voice mirror only learns from content you genuinely authored. Everything else stays unknown until you say otherwise.
18
+ ### Automatically from chat
19
+
20
+ Every admin chat session feeds your writing into the `text` profile automatically. When the session ends, {{productName}} reads the transcript, filters out slash commands, system messages, and large paste blocks, and adds each genuine turn as a corpus entry. This happens in the background with no action needed from you.
21
+
22
+ ### Via backfill — from historical content
23
+
24
+ Use the backfill flow to teach {{productName}} your writing from emails, documents, and social posts you've already written.
19
25
 
20
26
  In the admin chat, ask: **"Start the voice-mirror backfill."**
21
27
 
22
28
  {{productName}} asks which stream to backfill first — discrete documents (emails, posts, PDFs) or chat archives (WhatsApp, Telegram, etc.). **Pick chat archives first if you have any imported.** Chat is where your unguarded voice lives; email is the same voice in dress clothes.
23
29
 
24
30
  - **Chat archives** — tag a whole conversation in one click. {{productName}} doesn't ask about individual messages because chunks within a conversation almost always share the same voice. Options per conversation: yours, mixed (multiple authors on your side — rare), or not yours (e.g. a Slack channel where you only forwarded other people's messages).
25
- - **Documents and posts** — paginated 10 at a time. Tag the whole batch, a subset by number, or per-item if you want a precise label like `human-led-agent-assisted` (you wrote the content, {{productName}} polished).
31
+ - **Documents and posts** — paginated 10 at a time. {{productName}} shows each item with its detected format (email, article, note, social-post). Tag the whole batch, a subset by number, or per-item if you want a precise label like `human-led-agent-assisted` (you wrote the content, {{productName}} polished). Override the detected format if one is wrong.
26
32
 
27
33
  Skip a batch if none qualify, or stop at any time — the next session resumes where you left off.
28
34
 
29
- ### 2. Distil the profile
30
-
31
- Once you have at least ~20 `human-only` items tagged, ask: **"Build my voice profile."**
32
-
33
- {{productName}} reads the corpus, summarises your style as a YAML card, and saves it to the graph. It picks up your sentence rhythms, the constructions you reach for, the words you avoid.
35
+ ## Distilling Your Profile
34
36
 
35
- The profile re-runs automatically when your corpus grows by ≥20% or every 30 days, whichever comes first.
37
+ Once you have corpus entries tagged (or after automatic PTY ingestion fills the `text` profile), ask: **"Build my voice profile."**
36
38
 
37
- ### 3. Done
39
+ {{productName}} reads the corpus for each format, summarises your style as a YAML card, and saves it to the graph. It picks up your sentence rhythms, the constructions you reach for, the words you avoid — separately for email, articles, social posts, and so on.
38
40
 
39
- That's it. Every agent-drafted email, social post, brochure paragraph, and prospectus section from this point conditions on your voice. You don't need to ask for it.
41
+ The profile re-runs automatically when your corpus grows by ≥20% for any format or every 30 days, whichever comes first.
40
42
 
41
43
  ## Feedback Loop
42
44
 
@@ -50,11 +52,11 @@ Voice mirror is on by default for every drafting skill. To opt out for one, set
50
52
 
51
53
  ## What It Won't Do
52
54
 
53
- - **Blend voices** — one profile per person, no "Joel + Neo combined".
55
+ - **Blend voices** — one profile set per person, no "Joel + Neo combined".
54
56
  - **Copy public figures** — voice mirror only learns from your own writing.
55
57
  - **Clone audio** — text only, no speech synthesis.
56
- - **Guess** — content stays `unknown` until you mark it. {{productName}} never auto-classifies your historical writing.
58
+ - **Guess** — historical content stays `unknown` until you mark it. {{productName}} never auto-classifies your writing. (Automatic ingestion applies only to your live PTY sessions where authorship is certain.)
57
59
 
58
60
  ## Status
59
61
 
60
- Voice mirror shipped end-to-end. The schema, the four tools (`voice-tag-content`, `voice-distil-profile`, `voice-retrieve-conditioning`, `voice-record-feedback`), and wiring into the three live drafting surfaces (email composition, property brochures, investor data room) are all live. When no voice profile exists on the account, every drafting skill degrades gracefully — the output matches what it was before voice mirror was installed.
62
+ Voice mirror is live end-to-end. Six corpus formats (text, email, social-post, article, note, marketing-copy), five tools (`voice-tag-content`, `voice-distil-profile`, `voice-retrieve-conditioning`, `voice-record-feedback`, `voice-ingest-session-text`), automatic PTY-turn ingestion via SessionEnd hook, and wiring into the three live drafting surfaces (email composition, property brochures, investor data room) are all live. When no voice profile exists on the account for a given format, every drafting skill degrades gracefully — the output matches what it was before voice mirror was installed.
@@ -58,6 +58,7 @@ mkdir -p "$ACCOUNT_DIR/agents/admin" "$ACCOUNT_DIR/agents/public" "$ACCOUNT_DIR/
58
58
  # to locate scripts relative to the platform installation, not the account dir.
59
59
  ACCOUNT_SETTINGS="$ACCOUNT_DIR/.claude/settings.json"
60
60
  HOOKS_PATH="\$PLATFORM_ROOT/plugins/admin/hooks"
61
+ WRITER_CRAFT_HOOKS_PATH="\$PLATFORM_ROOT/../premium-plugins/writer-craft/hooks"
61
62
  cat > "$ACCOUNT_SETTINGS" << SETTINGS_EOF
62
63
  {
63
64
  "hooks": {
@@ -146,6 +147,13 @@ cat > "$ACCOUNT_SETTINGS" << SETTINGS_EOF
146
147
  { "type": "command", "command": "bash $HOOKS_PATH/session-end-retrospective.sh" }
147
148
  ]
148
149
  }
150
+ ],
151
+ "SessionEnd": [
152
+ {
153
+ "hooks": [
154
+ { "type": "command", "command": "bash $WRITER_CRAFT_HOOKS_PATH/voice-session-end-text.sh" }
155
+ ]
156
+ }
149
157
  ]
150
158
  }
151
159
  }
@@ -326,26 +326,45 @@ def main() -> int:
326
326
  )
327
327
  pairs_checked += 1
328
328
 
329
- # Built-in-tool rule + WebFetch rule: sweep all specialist templates directly.
330
- # These rules are orthogonal to the skill-pairing loop above — they check the
331
- # specialist's own prompt body for directives that require built-in CC tools.
329
+ # Built-in-tool rule + WebFetch rule: sweep all agent template .md files.
330
+ # Covers platform specialists (SPECIALISTS_DIR) and premium-plugin agent
331
+ # templates (premium-plugins/*/agents/). These rules are orthogonal to the
332
+ # skill-pairing loop above — they check each agent's own prompt body for
333
+ # directives that require built-in CC tools.
332
334
  known_skills = collect_skill_names()
333
335
  builtin_checked = 0
336
+
337
+ agent_template_dirs: list[str] = []
334
338
  if os.path.isdir(SPECIALISTS_DIR):
335
- for fname in sorted(os.listdir(SPECIALISTS_DIR)):
339
+ agent_template_dirs.append(SPECIALISTS_DIR)
340
+ pp_root = os.path.join(REPO_ROOT, "premium-plugins")
341
+ if os.path.isdir(pp_root):
342
+ for plugin in sorted(os.listdir(pp_root)):
343
+ adir = os.path.join(pp_root, plugin, "agents")
344
+ if os.path.isdir(adir):
345
+ agent_template_dirs.append(adir)
346
+
347
+ for adir in agent_template_dirs:
348
+ for fname in sorted(os.listdir(adir)):
336
349
  if not fname.endswith(".md"):
337
350
  continue
338
- specialist = fname[:-3]
339
- spath = os.path.join(SPECIALISTS_DIR, fname)
340
- tools = specialist_tools(specialist)
341
- if tools is None:
351
+ spath = os.path.join(adir, fname)
352
+ fm = parse_frontmatter(spath)
353
+ if fm is None:
342
354
  continue
355
+ specialist = fm.get("name") or fname[:-3]
356
+ raw = fm.get("tools", "")
357
+ if isinstance(raw, list):
358
+ tools = {t for t in raw if t}
359
+ else:
360
+ tools = {t.strip() for t in str(raw).split(",") if t.strip()}
343
361
  body = specialist_body(spath)
344
362
  for err in check_builtin_tool_rule(specialist, body, tools, known_skills):
345
363
  errors.append(err)
346
364
  for err in check_webfetch_rule(specialist, body, tools):
347
365
  errors.append(err)
348
366
  builtin_checked += 1
367
+
349
368
  summary.append(
350
369
  f"[verify] builtin-tool-check specialists_checked={builtin_checked}"
351
370
  )
@@ -16,6 +16,9 @@ tools:
16
16
  - name: voice-record-feedback
17
17
  publicAllowlist: false
18
18
  adminAllowlist: true
19
+ - name: voice-ingest-session-text
20
+ publicAllowlist: false
21
+ adminAllowlist: true
19
22
  always: false
20
23
  embed: false
21
24
  mcp:
@@ -87,7 +90,7 @@ The user is:
87
90
 
88
91
  | Skill | Purpose |
89
92
  |-------|---------|
90
- | `voice-mirror` | Capture the operator's voice from their own writing (`human-only` corpus) and condition agent drafts on it. Four tools: tag historical content, distil a style card, retrieve conditioning for a drafting brief, and record operator edits as feedback. Other drafting skills (email composition, Postiz, property-brochure, prospectus) call it transparently — opt-out via `voiceMirror: false` in the calling skill's frontmatter. |
93
+ | `voice-mirror` | Capture the operator's voice from their own writing (`human-only` corpus) and condition agent drafts on it. Five tools: tag historical content (with auto-derived format), distil a per-format style card, retrieve conditioning for a drafting brief (requires `brief.format`), record operator edits as feedback, and auto-ingest PTY turns at SessionEnd. Other drafting skills (email composition, Postiz, property-brochure, prospectus) call it transparently — opt-out via `voiceMirror: false` in the calling skill's frontmatter. |
91
94
 
92
95
  ## Specialist Agent
93
96
 
@@ -99,14 +102,15 @@ The manuscript-reviewer agent operates in its own context window with file tools
99
102
 
100
103
  ## Tools
101
104
 
102
- Voice-mirror introduces four deterministic plugin tools. They route writes through `memory-write` so the graph-write gate stamps provenance on every node.
105
+ Voice-mirror introduces five deterministic plugin tools. They route writes through `memory-write` so the graph-write gate stamps provenance on every node.
103
106
 
104
107
  | Tool | Purpose |
105
108
  |------|---------|
106
- | `voice-tag-content` | Stamp `authorshipMode ∈ {human-only, human-led-agent-assisted, agent-led-human-reviewed, agent-only, unknown}` on one or many `:KnowledgeDocument | :Message | :SocialPost` nodes (email threads live as `:KnowledgeDocument {source:'email'}` since Task 321). Bulk-mode for backfill batches. |
107
- | `voice-distil-profile` | Walk the operator's `human-only` corpus and persist a `:VoiceProfile {styleCard, generatedAt, corpusSize, feedbackEntries}` linked to the `:AdminUser`. Cadence-guarded: re-runs on ≥20% corpus growth or ≥30 days, otherwise no-op. |
108
- | `voice-retrieve-conditioning` | Return `{styleCard, exemplars[]}` for a drafting brief. K=5 short-form, K=15 long-form. Token-budget bounded. Graceful degradation: empty profile → `{styleCard: null, exemplars: []}`. |
109
- | `voice-record-feedback` | Capture an operator edit on an agent draft as a `:VoiceEdit` with a Haiku-summarised `intent`, linked back to the operator's `:VoiceProfile` via `:FEEDBACK_FOR`. |
109
+ | `voice-tag-content` | Stamp `authorshipMode ∈ {human-only, human-led-agent-assisted, agent-led-human-reviewed, agent-only, unknown}` and derived `format ∈ {text, email, social-post, article, note, marketing-copy}` on one or many `:KnowledgeDocument | :Message | :SocialPost` nodes (email threads live as `:KnowledgeDocument {source:'email'}` since Task 321). Bulk-mode for backfill batches. Pass `format` explicitly to override auto-derivation. |
110
+ | `voice-distil-profile` | Walk the operator's `human-only` corpus for the given `format` and persist a `:VoiceProfile {styleCard, generatedAt, corpusSize, feedbackEntries, format}` linked to the `:AdminUser`. Omit `format` to enumerate all formats in the corpus and distil each. Cadence-guarded per format: re-runs on ≥20% corpus growth or ≥30 days, otherwise no-op. |
111
+ | `voice-retrieve-conditioning` | Return `{styleCard, exemplars[]}` for a drafting brief. Requires `brief.format` (one of the six corpus formats). K=5 short-form (`brief.length:'short'`), K=15 long-form (`brief.length:'long'`). Token-budget bounded. Graceful degradation: empty profile → `{styleCard: null, exemplars: []}`. |
112
+ | `voice-record-feedback` | Capture an operator edit on an agent draft as a `:VoiceEdit {format}` with a Haiku-summarised `intent`, linked back to the operator's per-format `:VoiceProfile` via `:FEEDBACK_FOR`. Requires `format`. |
113
+ | `voice-ingest-session-text` | Ingest operator PTY turns from a JSONL transcript as `:Message {format:'text', authorshipMode:'human-only'}` corpus nodes. Deduplicates via SHA256 `contentHash`. Called by `voice-session-end-text.sh` at SessionEnd; not intended for direct agent invocation. |
110
114
 
111
115
  The non-voice skills continue to operate via existing platform tools (`memory-search` for knowledge retrieval). Structured choices are still presented as numbered lists in plain chat — the platform has no inline-UI component primitive on the admin / native-CC surface.
112
116
 
@@ -127,4 +131,4 @@ Domain knowledge files loaded on demand by each skill. See individual skill file
127
131
  | `review-manuscript` | `review-manuscript-checklist.md` |
128
132
  | `review-scene` | `scene-analysis-framework.md` |
129
133
  | `review-prose` | `prose-review-checklist.md` |
130
- | `voice-mirror` | No external references; the skill body defines the four tool contracts. Developer notes in `maxy-code/.docs/voice-mirror.md`. |
134
+ | `voice-mirror` | No external references; the skill body defines the five tool contracts. Developer notes in `maxy-code/.docs/voice-mirror.md`. |
@@ -0,0 +1,284 @@
1
+ #!/usr/bin/env bash
2
+ # voice-session-end-text.sh hook test suite.
3
+ #
4
+ # Pattern: a local Python HTTP server stands in for /api/admin/log-ingest
5
+ # and records every POST so the assertions can inspect every log line the
6
+ # hook emitted. The CLI is stubbed with a mock node script so tests run
7
+ # without a live Neo4j connection.
8
+ #
9
+ # Contract under test:
10
+ # - Hook skips with reason=role-not-admin when MAXY_SESSION_ROLE != admin.
11
+ # - Hook skips with reason=is-specialist when MAXY_SPECIALIST is set.
12
+ # - Hook skips with reason=empty-stdin when no stdin is piped.
13
+ # - Hook skips with reason=missing-transcript when the JSONL path in the
14
+ # stdin envelope does not exist.
15
+ # - Hook skips with reason=cli-not-found when the compiled CLI is absent.
16
+ # - Happy path: hook emits trigger + result=ok when all guards pass and the
17
+ # stub CLI exits 0.
18
+ # - Dedupe path: stub CLI returns turns=0 skipped=3 → result=ok with those
19
+ # counts (verifying the hook forwards the CLI stdout to the log-ingest).
20
+
21
+ set -u
22
+
23
+ HOOK="$(cd "$(dirname "$0")/.." && pwd)/voice-session-end-text.sh"
24
+ if [[ ! -f "$HOOK" ]]; then
25
+ echo "FAIL: hook not found at $HOOK" >&2
26
+ exit 1
27
+ fi
28
+
29
+ PASS=0
30
+ FAIL=0
31
+ TMPFILES=()
32
+ LISTENER_PIDS=()
33
+
34
+ cleanup() {
35
+ for pid in "${LISTENER_PIDS[@]:-}"; do
36
+ [[ -n "$pid" ]] && { kill "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true; }
37
+ done
38
+ for f in "${TMPFILES[@]:-}"; do
39
+ [[ -n "$f" ]] && rm -f "$f" 2>/dev/null || true
40
+ done
41
+ }
42
+ trap cleanup EXIT
43
+
44
+ # ---- helpers ---------------------------------------------------------------
45
+
46
+ make_tmp_file() {
47
+ local tmp
48
+ tmp=$(mktemp)
49
+ TMPFILES+=("$tmp")
50
+ echo "$tmp"
51
+ }
52
+
53
+ # Start a listener on a random port; capture every POST body.
54
+ # Sets LISTENER_PORT and LISTENER_LOG globals.
55
+ start_listener() {
56
+ LISTENER_LOG=$(make_tmp_file)
57
+ LISTENER_PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("",0)); print(s.getsockname()[1]); s.close()')
58
+ python3 - "$LISTENER_PORT" "$LISTENER_LOG" <<'PY' &
59
+ import sys, http.server, json
60
+
61
+ port = int(sys.argv[1])
62
+ log = sys.argv[2]
63
+
64
+ class H(http.server.BaseHTTPRequestHandler):
65
+ def do_POST(self):
66
+ n = int(self.headers.get("Content-Length", 0))
67
+ body = self.rfile.read(n).decode()
68
+ with open(log, "a") as f:
69
+ f.write(body + "\n")
70
+ self.send_response(200)
71
+ self.end_headers()
72
+ def log_message(self, *a): pass
73
+
74
+ http.server.HTTPServer(("127.0.0.1", port), H).serve_forever()
75
+ PY
76
+ local pid=$!
77
+ LISTENER_PIDS+=("$pid")
78
+ sleep 0.3 # let the server start
79
+ }
80
+
81
+ # Run the hook with given env overrides and optional stdin.
82
+ # Returns: HOOK_EXIT, LISTENER_LINES (array of log-ingest line bodies).
83
+ run_hook() {
84
+ local env_args=("$@")
85
+ local stdin_data="${HOOK_STDIN:-}"
86
+ local hook_exit=0
87
+ if [[ -n "$stdin_data" ]]; then
88
+ printf '%s' "$stdin_data" | env \
89
+ MAXY_UI_INTERNAL_PORT="$LISTENER_PORT" \
90
+ MAXY_SESSION_ROLE="${MAXY_SESSION_ROLE:-admin}" \
91
+ MAXY_SPECIALIST="${MAXY_SPECIALIST:-}" \
92
+ PLATFORM_ROOT="${FAKE_PLATFORM_ROOT:-}" \
93
+ ACCOUNT_ID="${ACCOUNT_ID:-acct-test}" \
94
+ USER_ID="${USER_ID:-user-test}" \
95
+ SESSION_ID="" \
96
+ NEO4J_URI="bolt://localhost:7688" \
97
+ NEO4J_USER="neo4j" \
98
+ NEO4J_PASSWORD="test" \
99
+ ${env_args[@]+"${env_args[@]}"} \
100
+ bash "$HOOK" >/dev/null 2>/dev/null || hook_exit=$?
101
+ else
102
+ env \
103
+ MAXY_UI_INTERNAL_PORT="$LISTENER_PORT" \
104
+ MAXY_SESSION_ROLE="${MAXY_SESSION_ROLE:-admin}" \
105
+ MAXY_SPECIALIST="${MAXY_SPECIALIST:-}" \
106
+ PLATFORM_ROOT="${FAKE_PLATFORM_ROOT:-}" \
107
+ ACCOUNT_ID="${ACCOUNT_ID:-acct-test}" \
108
+ USER_ID="${USER_ID:-user-test}" \
109
+ SESSION_ID="" \
110
+ NEO4J_URI="bolt://localhost:7688" \
111
+ NEO4J_USER="neo4j" \
112
+ NEO4J_PASSWORD="test" \
113
+ ${env_args[@]+"${env_args[@]}"} \
114
+ bash "$HOOK" </dev/null >/dev/null 2>/dev/null || hook_exit=$?
115
+ fi
116
+ HOOK_EXIT=$hook_exit
117
+ # Read all log-ingest POSTs.
118
+ LISTENER_LINES=()
119
+ while IFS= read -r raw; do
120
+ [[ -z "$raw" ]] && continue
121
+ LISTENER_LINES+=("$raw")
122
+ done < "$LISTENER_LOG"
123
+ # Reset log for next run.
124
+ > "$LISTENER_LOG"
125
+ }
126
+
127
+ assert_contains_line() {
128
+ local test_name="$1"
129
+ local fragment="$2"
130
+ for line in "${LISTENER_LINES[@]:-}"; do
131
+ if [[ "$line" == *"$fragment"* ]]; then
132
+ echo " PASS: $test_name"
133
+ PASS=$((PASS + 1))
134
+ return 0
135
+ fi
136
+ done
137
+ echo " FAIL: $test_name (expected fragment: '$fragment' not found in log lines)"
138
+ printf ' lines:\n'
139
+ for line in "${LISTENER_LINES[@]:-}"; do printf ' %s\n' "$line"; done
140
+ FAIL=$((FAIL + 1))
141
+ }
142
+
143
+ assert_exit() {
144
+ local test_name="$1"
145
+ local expected="$2"
146
+ if [[ "$HOOK_EXIT" -eq "$expected" ]]; then
147
+ echo " PASS: $test_name (exit $expected)"
148
+ PASS=$((PASS + 1))
149
+ else
150
+ echo " FAIL: $test_name (expected exit $expected, got $HOOK_EXIT)"
151
+ FAIL=$((FAIL + 1))
152
+ fi
153
+ }
154
+
155
+ # ---- setup -----------------------------------------------------------------
156
+
157
+ start_listener
158
+
159
+ # Create a fake platform root structure so the lib source works.
160
+ FAKE_PLATFORM_ROOT=$(mktemp -d)
161
+ TMPFILES+=("$FAKE_PLATFORM_ROOT")
162
+ mkdir -p "$FAKE_PLATFORM_ROOT/plugins/admin/hooks/lib"
163
+ # Copy the real lib so guards work correctly.
164
+ REAL_LIB="$(cd "$(dirname "$0")/../../../../platform/plugins/admin/hooks/lib" && pwd)/admin-graph-pass-common.sh"
165
+ if [[ ! -f "$REAL_LIB" ]]; then
166
+ echo "FAIL: admin-graph-pass-common.sh not found at $REAL_LIB" >&2
167
+ exit 1
168
+ fi
169
+ cp "$REAL_LIB" "$FAKE_PLATFORM_ROOT/plugins/admin/hooks/lib/admin-graph-pass-common.sh"
170
+
171
+ # Create a minimal fake JSONL transcript.
172
+ FAKE_TRANSCRIPT=$(make_tmp_file)
173
+ printf '{"type":"user","message":{"role":"user","content":"Hello world"}}\n' > "$FAKE_TRANSCRIPT"
174
+
175
+ # Create a stub CLI that exits 0 with a canned result.
176
+ FAKE_CLI_DIR=$(mktemp -d)
177
+ TMPFILES+=("$FAKE_CLI_DIR")
178
+ STUB_CLI="$FAKE_CLI_DIR/ingest-session-text.js"
179
+ cat > "$STUB_CLI" <<'STUB'
180
+ process.stdout.write("turns=3 skipped=0 sessionId=test-session\n");
181
+ process.exit(0);
182
+ STUB
183
+
184
+ # Stub CLI for dedupe scenario (turns=0).
185
+ STUB_CLI_DEDUP="$FAKE_CLI_DIR/ingest-dedup.js"
186
+ cat > "$STUB_CLI_DEDUP" <<'STUB'
187
+ process.stdout.write("turns=0 skipped=3 sessionId=test-session\n");
188
+ process.exit(0);
189
+ STUB
190
+
191
+ # Stub CLI that exits 1 (error).
192
+ STUB_CLI_ERR="$FAKE_CLI_DIR/ingest-error.js"
193
+ cat > "$STUB_CLI_ERR" <<'STUB'
194
+ process.stderr.write("simulated error\n");
195
+ process.exit(1);
196
+ STUB
197
+
198
+ # Valid stdin envelope for the SessionEnd hook.
199
+ valid_envelope() {
200
+ local transcript="${1:-$FAKE_TRANSCRIPT}"
201
+ printf '{"session_id":"test-session","transcript_path":"%s","hook_event_name":"SessionEnd"}' "$transcript"
202
+ }
203
+
204
+ # Override CLI path by symlinking ingest-session-text.js inside a fake platform layout.
205
+ make_platform_with_cli() {
206
+ local stub="$1"
207
+ local proot
208
+ proot=$(mktemp -d)
209
+ TMPFILES+=("$proot")
210
+ cp "$FAKE_PLATFORM_ROOT/plugins/admin/hooks/lib/admin-graph-pass-common.sh" \
211
+ "$proot/plugins/admin/hooks/lib/" 2>/dev/null || \
212
+ { mkdir -p "$proot/plugins/admin/hooks/lib"; cp "$REAL_LIB" "$proot/plugins/admin/hooks/lib/admin-graph-pass-common.sh"; }
213
+ mkdir -p "$proot/../premium-plugins/writer-craft/mcp/dist/cli" 2>/dev/null || \
214
+ mkdir -p "$(dirname "$proot")/../premium-plugins/writer-craft/mcp/dist/cli" 2>/dev/null || true
215
+ # Simpler: build the expected path from $PLATFORM_ROOT/../premium-plugins/...
216
+ # The hook computes: ${PLATFORM_ROOT}/../premium-plugins/writer-craft/mcp/dist/cli/ingest-session-text.js
217
+ # So parent of $proot + /premium-plugins/writer-craft/mcp/dist/cli/
218
+ local parent
219
+ parent=$(dirname "$proot")
220
+ mkdir -p "$parent/premium-plugins/writer-craft/mcp/dist/cli"
221
+ cp "$stub" "$parent/premium-plugins/writer-craft/mcp/dist/cli/ingest-session-text.js"
222
+ echo "$proot"
223
+ }
224
+
225
+ # ---- tests -----------------------------------------------------------------
226
+ echo "[voice-session-end-text.test] starting"
227
+
228
+ # T1: non-admin role → skip
229
+ MAXY_SESSION_ROLE="public" HOOK_STDIN=$(valid_envelope) run_hook
230
+ assert_exit "T1: non-admin exits 0" 0
231
+ assert_contains_line "T1: non-admin emits trigger-skipped" "trigger-skipped"
232
+
233
+ # T2: specialist → skip
234
+ unset MAXY_SESSION_ROLE 2>/dev/null || true
235
+ MAXY_SESSION_ROLE="admin" MAXY_SPECIALIST="database-operator" HOOK_STDIN=$(valid_envelope) run_hook
236
+ assert_exit "T2: specialist exits 0" 0
237
+ assert_contains_line "T2: specialist emits trigger-skipped" "trigger-skipped"
238
+
239
+ # T3: empty stdin → skip
240
+ MAXY_SPECIALIST="" MAXY_SESSION_ROLE="admin"
241
+ HOOK_STDIN="" run_hook # no stdin piped
242
+ assert_exit "T3: empty-stdin exits 0" 0
243
+ assert_contains_line "T3: empty-stdin emits trigger-skipped" "trigger-skipped"
244
+
245
+ # T4: missing transcript → skip
246
+ FAKE_MISSING=$(make_tmp_file)
247
+ rm -f "$FAKE_MISSING"
248
+ HOOK_STDIN=$(valid_envelope "$FAKE_MISSING") run_hook
249
+ assert_exit "T4: missing-transcript exits 0" 0
250
+ assert_contains_line "T4: missing-transcript emits trigger-skipped" "trigger-skipped"
251
+
252
+ # T5: CLI not found → skip
253
+ FAKE_PLATFORM_ROOT="$FAKE_PLATFORM_ROOT" HOOK_STDIN=$(valid_envelope) run_hook
254
+ assert_exit "T5: cli-not-found exits 0" 0
255
+ assert_contains_line "T5: cli-not-found emits trigger-skipped" "cli-not-found"
256
+
257
+ # T6: happy path — stub CLI exits 0
258
+ HAPPY_PROOT=$(make_platform_with_cli "$STUB_CLI")
259
+ FAKE_PLATFORM_ROOT="$HAPPY_PROOT" HOOK_STDIN=$(valid_envelope) run_hook
260
+ assert_exit "T6: happy path exits 0" 0
261
+ assert_contains_line "T6: happy path emits trigger" "trigger"
262
+ assert_contains_line "T6: happy path emits result=ok" "result=ok"
263
+
264
+ # T7: dedupe — stub returns turns=0 skipped=3
265
+ DEDUP_PROOT=$(make_platform_with_cli "$STUB_CLI_DEDUP")
266
+ FAKE_PLATFORM_ROOT="$DEDUP_PROOT" HOOK_STDIN=$(valid_envelope) run_hook
267
+ assert_exit "T7: dedupe exits 0" 0
268
+ assert_contains_line "T7: dedupe emits result=ok" "result=ok"
269
+
270
+ # T8: CLI error — stub exits 1
271
+ ERR_PROOT=$(make_platform_with_cli "$STUB_CLI_ERR")
272
+ FAKE_PLATFORM_ROOT="$ERR_PROOT" HOOK_STDIN=$(valid_envelope) run_hook
273
+ assert_exit "T8: cli-error exits 0" 0
274
+ assert_contains_line "T8: cli-error emits result=err" "result=err"
275
+
276
+ # ---- summary ---------------------------------------------------------------
277
+ echo ""
278
+ if [[ "$FAIL" -eq 0 ]]; then
279
+ echo "[voice-session-end-text.test] all $PASS tests passed"
280
+ exit 0
281
+ else
282
+ echo "[voice-session-end-text.test] FAILED — $FAIL/$((PASS + FAIL)) tests failed"
283
+ exit 1
284
+ fi