@rubytech/create-maxy 1.0.793 → 1.0.795

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/lib/graph-search/src/__tests__/fulltext-coverage.test.ts +8 -0
  3. package/payload/platform/neo4j/edge-annotations.json +20 -0
  4. package/payload/platform/neo4j/migrations/002-project-public-agents.ts +191 -0
  5. package/payload/platform/neo4j/schema.cypher +69 -2
  6. package/payload/platform/plugins/admin/hooks/__tests__/archive-ingest-gate.test.sh +166 -0
  7. package/payload/platform/plugins/admin/hooks/archive-ingest-gate.sh +147 -0
  8. package/payload/platform/plugins/admin/skills/public-agent-manager/SKILL.md +5 -2
  9. package/payload/platform/plugins/docs/references/platform.md +1 -1
  10. package/payload/platform/plugins/linkedin-import/skills/linkedin-import/SKILL.md +2 -0
  11. package/payload/platform/plugins/memory/mcp/dist/index.js +2 -2
  12. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  13. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-parse.d.ts +2 -1
  14. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-parse.d.ts.map +1 -1
  15. package/payload/platform/plugins/memory/mcp/dist/tools/whatsapp-export-parse.js.map +1 -1
  16. package/payload/platform/plugins/whatsapp-import/PLUGIN.md +4 -0
  17. package/payload/platform/plugins/whatsapp-import/lib/dist/parse-export.d.ts +8 -2
  18. package/payload/platform/plugins/whatsapp-import/lib/dist/parse-export.d.ts.map +1 -1
  19. package/payload/platform/plugins/whatsapp-import/lib/dist/parse-export.js +66 -15
  20. package/payload/platform/plugins/whatsapp-import/lib/dist/parse-export.js.map +1 -1
  21. package/payload/platform/plugins/whatsapp-import/lib/src/__tests__/parse-export.test.ts +175 -0
  22. package/payload/platform/plugins/whatsapp-import/lib/src/parse-export.ts +78 -17
  23. package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import/SKILL.md +2 -0
  24. package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import/references/export-parse.md +8 -6
  25. package/payload/platform/scripts/seed-neo4j.sh +43 -20
  26. package/payload/platform/templates/specialists/agents/database-operator.md +2 -0
  27. package/payload/server/chunk-2N7XJW6Q.js +3428 -0
  28. package/payload/server/chunk-3SQJW5Y5.js +9892 -0
  29. package/payload/server/client-pool-CTMWNDMO.js +28 -0
  30. package/payload/server/maxy-edge.js +2 -2
  31. package/payload/server/public/assets/{Checkbox-DHsoNPeM.js → Checkbox-BruL6MSR.js} +1 -1
  32. package/payload/server/public/assets/{admin-CBDpia8P.js → admin-D8wbpnrW.js} +7 -7
  33. package/payload/server/public/assets/data-BhrQjgR5.js +1 -0
  34. package/payload/server/public/assets/graph-Jj7seS-w.js +1 -0
  35. package/payload/server/public/assets/{jsx-runtime-lOmSwjvd.css → jsx-runtime-foO6ZMix.css} +1 -1
  36. package/payload/server/public/assets/{page-DU8F3OGU.js → page-DIG7s5Jp.js} +1 -1
  37. package/payload/server/public/assets/page-sZb3wcOM.js +50 -0
  38. package/payload/server/public/assets/{public-Bn-gEWOv.js → public-CfjzDdUe.js} +1 -1
  39. package/payload/server/public/assets/{share-2-0IDKUUq9.js → share-2-BndjMKeG.js} +1 -1
  40. package/payload/server/public/assets/{useVoiceRecorder-B1S_t3Hq.js → useVoiceRecorder-D_8P7xJU.js} +1 -1
  41. package/payload/server/public/data.html +5 -5
  42. package/payload/server/public/graph.html +6 -6
  43. package/payload/server/public/index.html +8 -8
  44. package/payload/server/public/public.html +5 -5
  45. package/payload/server/server.js +123 -125
  46. package/payload/server/public/assets/data-bIkywng-.js +0 -1
  47. package/payload/server/public/assets/graph-CT4W30GR.js +0 -1
  48. package/payload/server/public/assets/page-Cs2i--Z2.js +0 -50
  49. /package/payload/server/public/assets/{jsx-runtime-Br2bU3EJ.js → jsx-runtime-DJER3a7U.js} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy",
3
- "version": "1.0.793",
3
+ "version": "1.0.795",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy": "./dist/index.js"
@@ -85,6 +85,14 @@ const CANONICAL_TEXT_PROPERTIES = [
85
85
  "contactValue",
86
86
  // ToolCall
87
87
  "toolName",
88
+ // Agent (Task 837) — public-agent projection. `displayName` mirrors
89
+ // config.json; `slug` is the directory-name identifier; `role` is the
90
+ // discriminator carried on the four owned :KnowledgeDocument projections
91
+ // ('identity' | 'soul' | 'knowledge' | 'knowledge-summary'). Adding any
92
+ // new agent-side text property to the projector requires extending both
93
+ // the schema's ON EACH list and this canon, otherwise BM25 silently
94
+ // misses operator queries that match the new field.
95
+ "displayName", "slug", "role",
88
96
  ];
89
97
 
90
98
  interface IndexDeclaration {
@@ -111,6 +111,26 @@
111
111
  "OBSERVED_IN": {
112
112
  "direction": "(*)-[:OBSERVED_IN]->(Conversation)",
113
113
  "note": "Observation provenance."
114
+ },
115
+ "HAS_IDENTITY": {
116
+ "direction": "(Agent)-[:HAS_IDENTITY]->(KnowledgeDocument)",
117
+ "note": "Task 837 — public-agent IDENTITY.md projection (KnowledgeDocument with role='identity', namespaced attachmentId='agent:<slug>:identity')."
118
+ },
119
+ "HAS_SOUL": {
120
+ "direction": "(Agent)-[:HAS_SOUL]->(KnowledgeDocument)",
121
+ "note": "Task 837 — public-agent SOUL.md projection (role='soul')."
122
+ },
123
+ "HAS_KNOWLEDGE": {
124
+ "direction": "(Agent)-[:HAS_KNOWLEDGE]->(KnowledgeDocument)",
125
+ "note": "Task 837 — public-agent KNOWLEDGE.md projection (role='knowledge'); KNOWLEDGE-SUMMARY.md uses the same edge with role='knowledge-summary'."
126
+ },
127
+ "USES_KNOWLEDGE": {
128
+ "direction": "(Agent)-[:USES_KNOWLEDGE]->(KnowledgeDocument)",
129
+ "note": "Task 837 — operator-tagged docs (slug ∈ k.agents). Materialised at projection time only; runtime memory-search reads k.agents directly."
130
+ },
131
+ "HANDLED_BY": {
132
+ "direction": "(Conversation)-[:HANDLED_BY]->(Agent)",
133
+ "note": "Task 837 — public conversations only. Written by ensureConversation via OPTIONAL MATCH so orphan slugs do not block conversation creation."
114
134
  }
115
135
  },
116
136
  "sublabels": {
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Migration 002 — Project file-based public agents into the graph (Task 837).
3
+ *
4
+ * Two passes:
5
+ *
6
+ * 1. Walk every account directory under data/accounts/<accountId>/agents/
7
+ * and call projectAgent(accountId, accountDir, slug) for each non-admin
8
+ * agent that has a config.json. The projector is idempotent: re-running
9
+ * this migration produces no duplicate nodes or edges.
10
+ *
11
+ * 2. For every existing public Conversation that carries an agentSlug
12
+ * property, MATCH the corresponding :Agent and MERGE the
13
+ * (:Conversation)-[:HANDLED_BY]->(:Agent) edge. Conversations whose
14
+ * slug doesn't resolve to an Agent (orphan slugs from deleted agents,
15
+ * or DM-channel public flows that haven't been extended to register a
16
+ * slug) are left edge-less; they will gain the edge automatically once
17
+ * the slug is registered or a new agent at that slug is projected.
18
+ *
19
+ * Run via the platform/ui standalone runtime so it picks up the same
20
+ * NEO4J_URI / accounts-directory resolution as the server:
21
+ *
22
+ * cd platform/ui && \
23
+ * NEO4J_URI=bolt://… NEO4J_PASSWORD=… \
24
+ * npx tsx ../neo4j/migrations/002-project-public-agents.ts
25
+ *
26
+ * Output: structured `[agent-graph-backfill]` lines per account + a final
27
+ * totals line. A non-zero exit code on any per-account failure surfaces to
28
+ * the operator; subsequent accounts are still attempted (the migration is
29
+ * isolating by account, not all-or-nothing).
30
+ */
31
+
32
+ import { existsSync, readdirSync } from "node:fs";
33
+ import { resolve } from "node:path";
34
+ import {
35
+ projectAgent,
36
+ getSession,
37
+ } from "../../ui/app/lib/neo4j-store";
38
+ import { ACCOUNTS_DIR } from "../../ui/app/lib/claude-agent/account";
39
+
40
+ interface PerAccountStats {
41
+ accountId: string;
42
+ agents: number;
43
+ agentFailures: number;
44
+ convEdges: number;
45
+ convOrphans: number;
46
+ convCandidates: number;
47
+ }
48
+
49
+ async function projectAccountAgents(
50
+ accountId: string,
51
+ accountDir: string,
52
+ ): Promise<{ agents: number; agentFailures: number }> {
53
+ const agentsDir = resolve(accountDir, "agents");
54
+ if (!existsSync(agentsDir)) return { agents: 0, agentFailures: 0 };
55
+
56
+ let agents = 0;
57
+ let agentFailures = 0;
58
+ for (const entry of readdirSync(agentsDir, { withFileTypes: true })) {
59
+ if (!entry.isDirectory()) continue;
60
+ if (entry.name === "admin") continue;
61
+
62
+ const configPath = resolve(agentsDir, entry.name, "config.json");
63
+ if (!existsSync(configPath)) continue;
64
+
65
+ try {
66
+ await projectAgent(accountId, accountDir, entry.name);
67
+ agents++;
68
+ } catch (err) {
69
+ agentFailures++;
70
+ const msg = err instanceof Error ? err.message : String(err);
71
+ console.error(
72
+ `[agent-graph-backfill] account=${accountId.slice(0, 8)} agent=${entry.name} project FAILED error="${msg}"`,
73
+ );
74
+ }
75
+ }
76
+ return { agents, agentFailures };
77
+ }
78
+
79
+ interface HandledByBackfillStats {
80
+ candidates: number;
81
+ edges: number;
82
+ orphans: number;
83
+ }
84
+
85
+ /**
86
+ * Two-pass: count candidate Conversations (those carrying agentSlug),
87
+ * then OPTIONAL MATCH to surface orphans separately from successful merges.
88
+ * A hard MATCH-then-MATCH chain would silently filter orphan-slug rows out,
89
+ * making `edges=0` indistinguishable from "no public conversations exist"
90
+ * vs "every slug is orphaned" — different incidents, different fixes.
91
+ */
92
+ async function backfillHandledByEdges(accountId: string): Promise<HandledByBackfillStats> {
93
+ const session = getSession();
94
+ try {
95
+ const result = await session.run(
96
+ `MATCH (c:Conversation {accountId: $accountId, agentType: 'public'})
97
+ WHERE c.agentSlug IS NOT NULL
98
+ OPTIONAL MATCH (a:Agent {accountId: $accountId, slug: c.agentSlug})
99
+ FOREACH (_ IN CASE WHEN a IS NULL THEN [] ELSE [1] END | MERGE (c)-[:HANDLED_BY]->(a))
100
+ RETURN
101
+ count(c) AS candidates,
102
+ sum(CASE WHEN a IS NULL THEN 0 ELSE 1 END) AS edges,
103
+ sum(CASE WHEN a IS NULL THEN 1 ELSE 0 END) AS orphans`,
104
+ { accountId },
105
+ );
106
+ const toNum = (v: unknown): number => {
107
+ if (typeof v === "number") return v;
108
+ if (v && typeof (v as { toNumber: () => number }).toNumber === "function") {
109
+ return (v as { toNumber: () => number }).toNumber();
110
+ }
111
+ return 0;
112
+ };
113
+ return {
114
+ candidates: toNum(result.records[0]?.get("candidates")),
115
+ edges: toNum(result.records[0]?.get("edges")),
116
+ orphans: toNum(result.records[0]?.get("orphans")),
117
+ };
118
+ } finally {
119
+ await session.close();
120
+ }
121
+ }
122
+
123
+ async function main(): Promise<void> {
124
+ const start = Date.now();
125
+
126
+ if (!existsSync(ACCOUNTS_DIR)) {
127
+ console.error(`[agent-graph-backfill] ACCOUNTS_DIR missing at ${ACCOUNTS_DIR} — nothing to do`);
128
+ process.exit(0);
129
+ }
130
+
131
+ const accountEntries = readdirSync(ACCOUNTS_DIR, { withFileTypes: true })
132
+ .filter((e) => e.isDirectory());
133
+
134
+ console.error(`[agent-graph-backfill] start accounts=${accountEntries.length}`);
135
+
136
+ let totalAgents = 0;
137
+ let totalAgentFailures = 0;
138
+ let totalConvEdges = 0;
139
+ let totalConvOrphans = 0;
140
+ let totalConvCandidates = 0;
141
+ const perAccount: PerAccountStats[] = [];
142
+
143
+ for (const entry of accountEntries) {
144
+ const accountDir = resolve(ACCOUNTS_DIR, entry.name);
145
+ const accountId = entry.name;
146
+ const accountStart = Date.now();
147
+
148
+ const { agents, agentFailures } = await projectAccountAgents(accountId, accountDir);
149
+ totalAgents += agents;
150
+ totalAgentFailures += agentFailures;
151
+
152
+ let convStats: HandledByBackfillStats = { candidates: 0, edges: 0, orphans: 0 };
153
+ try {
154
+ convStats = await backfillHandledByEdges(accountId);
155
+ totalConvEdges += convStats.edges;
156
+ totalConvOrphans += convStats.orphans;
157
+ totalConvCandidates += convStats.candidates;
158
+ } catch (err) {
159
+ const msg = err instanceof Error ? err.message : String(err);
160
+ console.error(
161
+ `[agent-graph-backfill] account=${accountId.slice(0, 8)} handled-by-backfill FAILED error="${msg}"`,
162
+ );
163
+ }
164
+
165
+ perAccount.push({
166
+ accountId,
167
+ agents,
168
+ agentFailures,
169
+ convEdges: convStats.edges,
170
+ convOrphans: convStats.orphans,
171
+ convCandidates: convStats.candidates,
172
+ });
173
+ const ms = Date.now() - accountStart;
174
+ console.error(
175
+ `[agent-graph-backfill] account=${accountId.slice(0, 8)} agents=${agents} failures=${agentFailures} conv-candidates=${convStats.candidates} conv-edges=${convStats.edges} conv-orphans=${convStats.orphans} ms=${ms}`,
176
+ );
177
+ }
178
+
179
+ const ms = Date.now() - start;
180
+ console.error(
181
+ `[agent-graph-backfill] done totals: agents=${totalAgents} agent-failures=${totalAgentFailures} conv-candidates=${totalConvCandidates} conv-edges=${totalConvEdges} conv-orphans=${totalConvOrphans} ms=${ms}`,
182
+ );
183
+
184
+ process.exit(totalAgentFailures > 0 ? 1 : 0);
185
+ }
186
+
187
+ main().catch((err) => {
188
+ const msg = err instanceof Error ? err.message : String(err);
189
+ console.error(`[agent-graph-backfill] fatal error="${msg}"`);
190
+ process.exit(2);
191
+ });
@@ -286,6 +286,7 @@ OPTIONS {
286
286
  // - Email: Email, EmailAccount
287
287
  // - Review signals: ReviewAlert
288
288
  // - CV/career sublabels: Position, Credential
289
+ // - Public agents: Agent (Task 837 — projection of file-based public agents)
289
290
  //
290
291
  // Property union — every textual property the schema's writers assign:
291
292
  // - Generic: name, title, summary, body, content, text, description, headline, abstract,
@@ -301,6 +302,11 @@ OPTIONS {
301
302
  // - Credential: authority
302
303
  // - AccessGrant: contactValue
303
304
  // - ToolCall: toolName
305
+ // - Agent (Task 837): displayName, slug, role
306
+ // `displayName` = operator-facing name; `slug` = directory-name identifier;
307
+ // `role` = discriminator on KnowledgeDocument projections
308
+ // ('identity'|'soul'|'knowledge'|'knowledge-summary') so BM25 hits surface
309
+ // which file backed the result. Distinct from Person.role (no shadow).
304
310
  CREATE FULLTEXT INDEX entity_search IF NOT EXISTS
305
311
  FOR (n:LocalBusiness|Service|PriceSpecification|OpeningHoursSpecification|Organization
306
312
  |Person|UserProfile|Preference|AdminUser|AccessGrant
@@ -309,12 +315,13 @@ FOR (n:LocalBusiness|Service|PriceSpecification|OpeningHoursSpecification|Organi
309
315
  |Task|Project|Event
310
316
  |Workflow|WorkflowStep|WorkflowRun|StepResult
311
317
  |OnboardingState|Email|EmailAccount|ReviewAlert
312
- |Position|Credential)
318
+ |Position|Credential|Agent)
313
319
  ON EACH [n.name, n.firstName, n.lastName, n.givenName, n.familyName,
314
320
  n.title, n.currentTitle, n.summary, n.body, n.content, n.text, n.description, n.headline, n.abstract,
315
321
  n.email, n.note, n.label, n.value, n.message, n.preview, n.tagline,
316
322
  n.subject, n.bodyPreview, n.fromName, n.fromAddress, n.agentAddress, n.screeningReason,
317
- n.authority, n.contactValue, n.toolName];
323
+ n.authority, n.contactValue, n.toolName,
324
+ n.displayName, n.slug, n.role];
318
325
 
319
326
  // Project node (Task 740) — a standalone creative-output node distinct from
320
327
  // :Section. Anchored via (:UserProfile)-[:CREATED]->(:Project), with optional
@@ -724,6 +731,66 @@ FOR (ag:AccessGrant) ON (ag.magicToken);
724
731
  CREATE INDEX access_grant_status IF NOT EXISTS
725
732
  FOR (ag:AccessGrant) ON (ag.status);
726
733
 
734
+ // ----------------------------------------------------------
735
+ // Agent node — projection of file-based public agents (Task 837)
736
+ //
737
+ // Source of truth remains the filesystem: accountDir/agents/<slug>/
738
+ // {config.json, IDENTITY.md, SOUL.md, KNOWLEDGE.md, KNOWLEDGE-SUMMARY.md}.
739
+ // The Agent node is a graph projection so operators can see, on /graph,
740
+ // which public agents exist, what knowledge they have access to, and
741
+ // which conversations they have handled. Re-running the projector
742
+ // is idempotent and never mutates the on-disk files.
743
+ //
744
+ // Properties (mirrored from config.json + filesystem mtime):
745
+ // slug — directory name; immutable identifier within an account
746
+ // displayName — operator-facing name from config.json
747
+ // status — 'active' | 'inactive' | <other> per config.json
748
+ // model — Anthropic model id used by the public agent
749
+ // liveMemory — bool; whether memory-search runs at message time
750
+ // knowledgeKeywords — string[] (live-search keyword subscriptions)
751
+ // role — always 'agent' (mirrors KnowledgeDocument.role
752
+ // discriminator; surfaces in BM25 hits)
753
+ // createdAt — ISO 8601, set on first projection
754
+ // updatedAt — ISO 8601, set on every projection
755
+ //
756
+ // Owned KnowledgeDocument projections (deleted on agent delete):
757
+ // (Agent)-[:HAS_IDENTITY]->(:KnowledgeDocument {role:'identity'})
758
+ // (Agent)-[:HAS_SOUL ]->(:KnowledgeDocument {role:'soul'})
759
+ // (Agent)-[:HAS_KNOWLEDGE]->(:KnowledgeDocument {role:'knowledge'})
760
+ // (Agent)-[:HAS_KNOWLEDGE]->(:KnowledgeDocument {role:'knowledge-summary'})
761
+ // attachmentId is namespaced as "agent:<accountId>:<slug>:<role>" so the
762
+ // projection reuses the existing knowledge_doc_id_unique constraint without
763
+ // a parallel uniqueness scheme. The accountId segment is load-bearing —
764
+ // without it, two accounts with the same agent slug would collide on the
765
+ // global unique constraint and the second projection would silently
766
+ // re-parent the first account's doc via the MERGE+SET path. Account
767
+ // isolation is doctrine.
768
+ //
769
+ // Operator-tagged docs (NOT deleted on agent delete — only edges removed):
770
+ // (Agent)-[:USES_KNOWLEDGE]->(:KnowledgeDocument)
771
+ // where slug ∈ KnowledgeDocument.agents. Materialised at projection time
772
+ // only; the runtime memory-search path continues to read k.agents directly,
773
+ // so a doc tagged after the last projection becomes graph-visible at the
774
+ // next re-projection but is reachable at runtime immediately. The runtime
775
+ // path is unchanged — see [public-agent.ts:130-160].
776
+ //
777
+ // Conversation→Agent edge (written by ensureConversation when
778
+ // agentType='public' and agentSlug is set):
779
+ // (Conversation)-[:HANDLED_BY]->(Agent)
780
+ // OPTIONAL MATCH on the Agent so a public conversation with an orphan
781
+ // slug still writes (single `[agent-graph] handled-by-skip` log line).
782
+ //
783
+ // Composite uniqueness: one Agent per (accountId, slug) — the directory
784
+ // name is the natural key within an account, slugs collide across
785
+ // accounts and that's allowed.
786
+ // ----------------------------------------------------------
787
+
788
+ CREATE CONSTRAINT agent_account_slug_unique IF NOT EXISTS
789
+ FOR (a:Agent) REQUIRE (a.accountId, a.slug) IS UNIQUE;
790
+
791
+ CREATE INDEX agent_account IF NOT EXISTS
792
+ FOR (a:Agent) ON (a.accountId);
793
+
727
794
  // ----------------------------------------------------------
728
795
  // AdminUser node — device-level admin identity
729
796
  // Platform-native. Represents a human who administers one or
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env bash
2
+ # Regression test for archive-ingest-gate.sh (Task 846).
3
+ #
4
+ # Six cases cover the contract:
5
+ # 1. Edit on /platform/plugins/<x>/lib/* is BLOCKED (exit 2).
6
+ # 2. Edit on a benign path is ALLOWED (exit 0).
7
+ # 3. Bash with `npx vitest` is BLOCKED.
8
+ # 4. PostToolUse on whatsapp-export-parse with isError:true sets the flag.
9
+ # 5. Subsequent PreToolUse on ANY tool is BLOCKED (post-parse-error gate).
10
+ # 6. UserPromptSubmit clears the flag, restoring normal allow behavior.
11
+ #
12
+ # Tests use ARCHIVE_INGEST_GATE_STATE_DIR to point at a tmp dir so they run
13
+ # without a real account layout.
14
+
15
+ set -u
16
+
17
+ HOOK="$(cd "$(dirname "$0")/.." && pwd)/archive-ingest-gate.sh"
18
+ if [[ ! -x "$HOOK" ]]; then
19
+ echo "FAIL: $HOOK not executable" >&2
20
+ exit 1
21
+ fi
22
+
23
+ # Per-run isolated state dir
24
+ STATE_DIR=$(mktemp -d)
25
+ export ARCHIVE_INGEST_GATE_STATE_DIR="$STATE_DIR"
26
+ FLAG_FILE="$STATE_DIR/archive-ingest-parse-error.flag"
27
+
28
+ cleanup() { rm -rf "$STATE_DIR"; }
29
+ trap cleanup EXIT
30
+
31
+ PASS=0
32
+ FAIL=0
33
+
34
+ run_case() {
35
+ local name="$1" stdin="$2" expected_exit="$3"
36
+ local actual_exit
37
+ printf '%s' "$stdin" | bash "$HOOK" >/dev/null 2>/dev/null
38
+ actual_exit=$?
39
+ if [[ "$actual_exit" -eq "$expected_exit" ]]; then
40
+ echo "PASS: $name (exit=$actual_exit)"
41
+ PASS=$((PASS + 1))
42
+ else
43
+ echo "FAIL: $name (expected exit=$expected_exit, got=$actual_exit)" >&2
44
+ FAIL=$((FAIL + 1))
45
+ fi
46
+ }
47
+
48
+ # Case 1 — Edit on plugin lib path: BLOCKED
49
+ run_case "Edit on platform/plugins/whatsapp-import/lib/src/parse-export.ts → BLOCKED" \
50
+ '{"hook_event_name":"PreToolUse","tool_name":"Edit","tool_input":{"file_path":"/Users/x/repo/platform/plugins/whatsapp-import/lib/src/parse-export.ts","old_string":"a","new_string":"b"}}' \
51
+ 2
52
+
53
+ # Case 2 — Edit on a benign path: ALLOWED
54
+ run_case "Edit on README.md → ALLOWED" \
55
+ '{"hook_event_name":"PreToolUse","tool_name":"Edit","tool_input":{"file_path":"/Users/x/repo/README.md","old_string":"a","new_string":"b"}}' \
56
+ 0
57
+
58
+ # Case 3 — Bash with `npx vitest`: BLOCKED
59
+ run_case "Bash 'npx vitest run parse-export.test.ts' → BLOCKED" \
60
+ '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"npx vitest run parse-export.test.ts"}}' \
61
+ 2
62
+
63
+ # Case 3b — Bash with benign command: ALLOWED
64
+ run_case "Bash 'ls -la' → ALLOWED" \
65
+ '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"ls -la"}}' \
66
+ 0
67
+
68
+ # Case 3c — Bash with `bun test`: BLOCKED
69
+ run_case "Bash 'bun test' → BLOCKED" \
70
+ '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"bun test"}}' \
71
+ 2
72
+
73
+ # Case 3d — Bash with `npm test`: BLOCKED
74
+ run_case "Bash 'npm test' → BLOCKED" \
75
+ '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"npm test"}}' \
76
+ 2
77
+
78
+ # Make sure flag is absent before parse-error simulation
79
+ rm -f "$FLAG_FILE"
80
+
81
+ # Case 4 — PostToolUse on whatsapp-export-parse with isError:true sets flag
82
+ run_case "PostToolUse parse-error sets flag (exit 0, flag side-effect)" \
83
+ '{"hook_event_name":"PostToolUse","tool_name":"mcp__memory__whatsapp-export-parse","tool_input":{"filePath":"_chat.txt"},"tool_response":{"isError":true,"content":[{"type":"text","text":"parse-error file=_chat.txt line=1 reason=not-a-_chat.txt"}]}}' \
84
+ 0
85
+
86
+ if [[ -f "$FLAG_FILE" ]]; then
87
+ echo "PASS: parse-error flag created at $FLAG_FILE"
88
+ PASS=$((PASS + 1))
89
+ else
90
+ echo "FAIL: parse-error flag NOT created at $FLAG_FILE" >&2
91
+ FAIL=$((FAIL + 1))
92
+ fi
93
+
94
+ # Case 5 — Subsequent PreToolUse on ANY tool BLOCKED while flag is fresh
95
+ run_case "PreToolUse Read after parse-error → BLOCKED" \
96
+ '{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"/tmp/foo"}}' \
97
+ 2
98
+
99
+ run_case "PreToolUse Bash after parse-error → BLOCKED" \
100
+ '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo hi"}}' \
101
+ 2
102
+
103
+ # Case 6 — UserPromptSubmit clears flag
104
+ run_case "UserPromptSubmit clears flag (exit 0)" \
105
+ '{"hook_event_name":"UserPromptSubmit","prompt":"retry"}' \
106
+ 0
107
+
108
+ if [[ ! -f "$FLAG_FILE" ]]; then
109
+ echo "PASS: UserPromptSubmit cleared flag"
110
+ PASS=$((PASS + 1))
111
+ else
112
+ echo "FAIL: UserPromptSubmit did NOT clear flag" >&2
113
+ FAIL=$((FAIL + 1))
114
+ fi
115
+
116
+ # Case 7 — After clearance, normal allow resumes
117
+ run_case "PreToolUse Read after clearance → ALLOWED" \
118
+ '{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"/tmp/foo"}}' \
119
+ 0
120
+
121
+ # Case 8 — PostToolUse with isError:false does NOT set flag
122
+ rm -f "$FLAG_FILE"
123
+ run_case "PostToolUse parse-success (isError:false) does NOT set flag" \
124
+ '{"hook_event_name":"PostToolUse","tool_name":"mcp__memory__whatsapp-export-parse","tool_input":{"filePath":"_chat.txt"},"tool_response":{"isError":false,"content":[{"type":"text","text":"{\"parsedLines\":[]}"}]}}' \
125
+ 0
126
+
127
+ if [[ ! -f "$FLAG_FILE" ]]; then
128
+ echo "PASS: parse-success leaves flag absent"
129
+ PASS=$((PASS + 1))
130
+ else
131
+ echo "FAIL: parse-success incorrectly created flag" >&2
132
+ FAIL=$((FAIL + 1))
133
+ fi
134
+
135
+ # Case 9 — Stale flag (>600s) auto-clears + allows
136
+ PAST=$(( $(date -u +%s) - 700 ))
137
+ echo "$PAST" > "$FLAG_FILE"
138
+ run_case "Stale flag auto-clears, PreToolUse Read → ALLOWED" \
139
+ '{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"/tmp/foo"}}' \
140
+ 0
141
+
142
+ # Case 10 — No stdin (terminal) fails closed
143
+ echo "Probing fail-closed behaviour (no stdin)..."
144
+ bash "$HOOK" </dev/null >/dev/null 2>/dev/null
145
+ ACTUAL=$?
146
+ # /dev/null IS a stdin — the `[ -t 0 ]` check tests for terminal, not file.
147
+ # A file/pipe stdin reads as empty, which produces empty hook_event_name and
148
+ # falls through to default `exit 0` (allow). The terminal-only fail-closed
149
+ # branch can't be tested non-interactively; verify the script reads `[ -t 0 ]`.
150
+ if grep -q '\[ -t 0 \]' "$HOOK"; then
151
+ echo "PASS: fail-closed terminal check is present"
152
+ PASS=$((PASS + 1))
153
+ else
154
+ echo "FAIL: fail-closed terminal check missing" >&2
155
+ FAIL=$((FAIL + 1))
156
+ fi
157
+
158
+ echo
159
+ echo "──────── archive-ingest-gate test summary ────────"
160
+ echo "PASS: $PASS"
161
+ echo "FAIL: $FAIL"
162
+
163
+ if [[ "$FAIL" -gt 0 ]]; then
164
+ exit 1
165
+ fi
166
+ exit 0
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env bash
2
+ # Archive-ingest gate (Task 846).
3
+ #
4
+ # Three enforcements, one script — phase decided by `hook_event_name` on stdin:
5
+ #
6
+ # 1. PreToolUse Edit/Write/NotebookEdit: deny writes under
7
+ # `*platform/plugins/*/lib/*` (parser/CSV-shape source for any *-import or
8
+ # *-export plugin). The database-operator subagent has Read+Bash but not
9
+ # Edit/Write per its `tools:` frontmatter — yet it can still mutate files
10
+ # via Bash heredoc. The path block applies to every tool that takes a
11
+ # `file_path` and has been observed as the improvisation surface.
12
+ #
13
+ # 2. PreToolUse Bash: deny commands invoking JavaScript test runners
14
+ # (vitest|bun test|npm test|npx jest|node .*vitest). The reproducer
15
+ # incident (conv 47c6a590) saw the operator run all four variants in
16
+ # sequence after parse-export returned isError.
17
+ #
18
+ # 3. Parse-error gate: PostToolUse on any `mcp__*__*-export-parse` /
19
+ # `mcp__*__*-import-parse` tool whose `tool_response.isError == true`
20
+ # writes a flag file. Subsequent PreToolUse on ANY tool blocks until
21
+ # UserPromptSubmit clears the flag (semantics: "subagent's next action
22
+ # must be a user-facing message; further tool calls blocked"). A 600s
23
+ # TTL is the cross-session safety net.
24
+ #
25
+ # Exit codes follow Claude Code hook protocol: 0 = allow, 2 = block (stderr
26
+ # message shown to the agent). Fail-closed on stdin read failure to match
27
+ # pre-tool-use.sh.
28
+
29
+ set -uo pipefail
30
+
31
+ # Read stdin — fail closed if unavailable
32
+ if [ -t 0 ]; then
33
+ echo "Blocked: archive-ingest-gate received no stdin (cannot inspect tool call). Failing closed." >&2
34
+ exit 2
35
+ fi
36
+ INPUT=$(cat)
37
+
38
+ # ----- Resolve account dir for state file ----------------------------------
39
+ # Mirrors pre-tool-use.sh lines 100-107: walk from this hook's location to
40
+ # platform/, then `../data/accounts/<single-account>/`. Phase 0 has exactly
41
+ # one account directory.
42
+ HOOK_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
43
+ PLATFORM_ROOT_RESOLVED="${HOOK_DIR}/../../.."
44
+ ACCOUNTS_DIR="${PLATFORM_ROOT_RESOLVED}/../data/accounts"
45
+ ACCOUNT_DIR=""
46
+ if [ -d "$ACCOUNTS_DIR" ]; then
47
+ ACCOUNT_DIR=$(find "$ACCOUNTS_DIR" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | head -1)
48
+ fi
49
+ # Test harness override — tests pass `ARCHIVE_INGEST_GATE_STATE_DIR` to point
50
+ # at a tmp dir without needing a real account layout.
51
+ STATE_DIR="${ARCHIVE_INGEST_GATE_STATE_DIR:-${ACCOUNT_DIR}/state}"
52
+ FLAG_FILE="${STATE_DIR}/archive-ingest-parse-error.flag"
53
+ TTL_SECONDS=600
54
+
55
+ # ----- Parse fields from stdin ---------------------------------------------
56
+ # Hook protocol: every event includes `hook_event_name`. PreToolUse +
57
+ # PostToolUse include `tool_name`. PostToolUse adds `tool_response`.
58
+ # UserPromptSubmit has neither. Use grep/sed against the JSON envelope —
59
+ # no jq dependency to match the rest of the hook fleet.
60
+ HOOK_EVENT=$(printf '%s' "$INPUT" | grep -o '"hook_event_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
61
+ TOOL_NAME=$(printf '%s' "$INPUT" | grep -o '"tool_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
62
+
63
+ # ============================================================================
64
+ # UserPromptSubmit — clear the parse-error flag.
65
+ # ============================================================================
66
+ if [ "$HOOK_EVENT" = "UserPromptSubmit" ]; then
67
+ if [ -f "$FLAG_FILE" ]; then
68
+ rm -f "$FLAG_FILE" 2>/dev/null
69
+ echo "[archive-ingest-gate] cleared reason=user-prompt-submit" >&2
70
+ fi
71
+ exit 0
72
+ fi
73
+
74
+ # ============================================================================
75
+ # PostToolUse — record parse-error from any *-export-parse / *-import-parse.
76
+ # ============================================================================
77
+ if [ "$HOOK_EVENT" = "PostToolUse" ]; then
78
+ case "$TOOL_NAME" in
79
+ mcp__*__*-export-parse|mcp__*__*-import-parse)
80
+ # Inspect tool_response for isError true. Body is a JSON object; match
81
+ # `"isError":true` with optional whitespace.
82
+ if printf '%s' "$INPUT" | grep -Eq '"isError"[[:space:]]*:[[:space:]]*true'; then
83
+ mkdir -p "$STATE_DIR" 2>/dev/null
84
+ date -u +%s > "$FLAG_FILE" 2>/dev/null
85
+ echo "[archive-ingest-gate] surfaced reason=parse-error tool=${TOOL_NAME}" >&2
86
+ fi
87
+ ;;
88
+ esac
89
+ # PostToolUse must always allow the tool result through.
90
+ exit 0
91
+ fi
92
+
93
+ # ============================================================================
94
+ # PreToolUse — three independent blocks.
95
+ # ============================================================================
96
+ if [ "$HOOK_EVENT" != "PreToolUse" ]; then
97
+ # Unknown event, or hook fired in a context without an event name. Allow.
98
+ exit 0
99
+ fi
100
+
101
+ # --- Block 1: post-parse-error gate (applies to ALL tools) -----------------
102
+ if [ -f "$FLAG_FILE" ]; then
103
+ FLAG_TS=$(cat "$FLAG_FILE" 2>/dev/null | head -1)
104
+ NOW=$(date -u +%s)
105
+ if [ -n "$FLAG_TS" ] && [ "$FLAG_TS" -gt 0 ] 2>/dev/null; then
106
+ AGE=$(( NOW - FLAG_TS ))
107
+ if [ "$AGE" -lt "$TTL_SECONDS" ]; then
108
+ echo "[archive-ingest-gate] block tool=${TOOL_NAME} reason=post-parse-error age_s=${AGE}" >&2
109
+ echo "Blocked: an archive-parser MCP tool returned isError=true earlier in this turn. The subagent's next action must be a user-facing message naming the parse-error and yielding back to the operator. Further tool calls are blocked until the operator submits a new prompt." >&2
110
+ exit 2
111
+ fi
112
+ # Stale flag — clean up so we don't keep blocking on a corpse.
113
+ rm -f "$FLAG_FILE" 2>/dev/null
114
+ else
115
+ # Unparseable flag — remove rather than block on garbage.
116
+ rm -f "$FLAG_FILE" 2>/dev/null
117
+ fi
118
+ fi
119
+
120
+ # --- Block 2: plugin-source path block (Edit/Write/NotebookEdit) -----------
121
+ case "$TOOL_NAME" in
122
+ Edit|Write|NotebookEdit)
123
+ FILE_PATH=$(printf '%s' "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
124
+ case "$FILE_PATH" in
125
+ */platform/plugins/*/lib/*|platform/plugins/*/lib/*)
126
+ echo "[archive-ingest-gate] block tool=${TOOL_NAME} reason=plugin-source-edit path=${FILE_PATH}" >&2
127
+ echo "Blocked: ${TOOL_NAME} on ${FILE_PATH} is a platform plugin lib/ path. The database-operator subagent does not own plugin source; if a parser is broken, surface the parse-error to the operator and let them dispatch a code-edit task instead." >&2
128
+ exit 2
129
+ ;;
130
+ esac
131
+ ;;
132
+ esac
133
+
134
+ # --- Block 3: shell test-runner block (Bash) -------------------------------
135
+ if [ "$TOOL_NAME" = "Bash" ]; then
136
+ COMMAND=$(printf '%s' "$INPUT" | grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
137
+ # Match any of: `vitest`, `bun test`, `npm test`, `npx jest`, `node ... vitest`.
138
+ # Word-boundary checks via grep -E with explicit token boundaries — POSIX
139
+ # `[[:space:]]` covers leading-token edge cases, end-of-string covers tail.
140
+ if printf '%s' "$COMMAND" | grep -Eq '(^|[[:space:]/])vitest($|[[:space:]])|(^|[[:space:]])bun[[:space:]]+test($|[[:space:]])|(^|[[:space:]])npm[[:space:]]+test($|[[:space:]])|(^|[[:space:]])npx[[:space:]]+jest($|[[:space:]])|node[[:space:]].*vitest'; then
141
+ echo "[archive-ingest-gate] block tool=Bash reason=test-runner command=${COMMAND}" >&2
142
+ echo "Blocked: Bash command invokes a JavaScript test runner (vitest/bun test/npm test/npx jest). The database-operator subagent does not run plugin tests; surface the parse-error to the operator." >&2
143
+ exit 2
144
+ fi
145
+ fi
146
+
147
+ exit 0