@rubytech/create-realagent 1.0.824 → 1.0.826

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 (63) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/task-secrets/dist/index.d.ts +40 -0
  3. package/payload/platform/lib/task-secrets/dist/index.d.ts.map +1 -0
  4. package/payload/platform/lib/task-secrets/dist/index.js +44 -0
  5. package/payload/platform/lib/task-secrets/dist/index.js.map +1 -0
  6. package/payload/platform/lib/task-secrets/src/__tests__/redact-secrets.test.ts +127 -0
  7. package/payload/platform/lib/task-secrets/src/index.ts +77 -0
  8. package/payload/platform/lib/task-secrets/tsconfig.json +9 -0
  9. package/payload/platform/lib/task-secrets/vitest.config.ts +9 -0
  10. package/payload/platform/neo4j/schema.cypher +11 -0
  11. package/payload/platform/package.json +2 -2
  12. package/payload/platform/plugins/admin/skills/business-profile/SKILL.md +2 -2
  13. package/payload/platform/plugins/admin/skills/onboarding/SKILL.md +12 -9
  14. package/payload/platform/plugins/admin/skills/plugin-management/SKILL.md +4 -4
  15. package/payload/platform/plugins/admin/skills/public-agent-manager/SKILL.md +2 -2
  16. package/payload/platform/plugins/admin/skills/stream-log-review/SKILL.md +6 -6
  17. package/payload/platform/plugins/admin/skills/unzip-attachment/references/safety.md +1 -1
  18. package/payload/platform/plugins/cloudflare/references/manual-setup.md +3 -3
  19. package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +4 -4
  20. package/payload/platform/plugins/docs/references/cloudflare.md +1 -1
  21. package/payload/platform/plugins/docs/references/internals.md +2 -2
  22. package/payload/platform/plugins/docs/references/memory-guide.md +1 -1
  23. package/payload/platform/plugins/docs/references/troubleshooting.md +1 -1
  24. package/payload/platform/plugins/linkedin-import/skills/linkedin-import/SKILL.md +2 -2
  25. package/payload/platform/plugins/linkedin-import/skills/linkedin-import/references/connections.md +1 -1
  26. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-not-applicable.test.d.ts +2 -0
  27. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-not-applicable.test.d.ts.map +1 -0
  28. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-not-applicable.test.js +87 -0
  29. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-not-applicable.test.js.map +1 -0
  30. package/payload/platform/plugins/memory/mcp/dist/tools/profile-read.d.ts +2 -0
  31. package/payload/platform/plugins/memory/mcp/dist/tools/profile-read.d.ts.map +1 -1
  32. package/payload/platform/plugins/memory/mcp/dist/tools/profile-read.js +100 -8
  33. package/payload/platform/plugins/memory/mcp/dist/tools/profile-read.js.map +1 -1
  34. package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.d.ts +19 -0
  35. package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.d.ts.map +1 -1
  36. package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.js +60 -27
  37. package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.js.map +1 -1
  38. package/payload/platform/plugins/memory/references/graph-primitives.md +5 -5
  39. package/payload/platform/plugins/memory/references/schema-base.md +1 -1
  40. package/payload/platform/plugins/memory/skills/document-ingest/SKILL.md +6 -6
  41. package/payload/platform/plugins/tasks/PLUGIN.md +1 -1
  42. package/payload/platform/plugins/tasks/mcp/dist/index.js +11 -2
  43. package/payload/platform/plugins/tasks/mcp/dist/index.js.map +1 -1
  44. package/payload/platform/plugins/tasks/mcp/dist/tools/task-create.d.ts +19 -2
  45. package/payload/platform/plugins/tasks/mcp/dist/tools/task-create.d.ts.map +1 -1
  46. package/payload/platform/plugins/tasks/mcp/dist/tools/task-create.js +17 -1
  47. package/payload/platform/plugins/tasks/mcp/dist/tools/task-create.js.map +1 -1
  48. package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import/SKILL.md +9 -9
  49. package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import/references/export-parse.md +2 -2
  50. package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import-enrich/SKILL.md +8 -8
  51. package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
  52. package/payload/platform/templates/specialists/agents/database-operator.md +10 -10
  53. package/payload/server/chunk-AEHTLEC3.js +2302 -0
  54. package/payload/server/chunk-F5QBVHLS.js +1116 -0
  55. package/payload/server/chunk-HAXOJNAM.js +10079 -0
  56. package/payload/server/chunk-TDTQEKNP.js +593 -0
  57. package/payload/server/chunk-ZTBTX3IO.js +642 -0
  58. package/payload/server/client-pool-FXCFSUXR.js +32 -0
  59. package/payload/server/cloudflare-task-tracker-3WV7DZKQ.js +17 -0
  60. package/payload/server/cloudflare-task-tracker-BAMJY4MH.js +17 -0
  61. package/payload/server/maxy-edge.js +3 -3
  62. package/payload/server/neo4j-migrations-5FVPIWDW.js +428 -0
  63. package/payload/server/server.js +20 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-realagent",
3
- "version": "1.0.824",
3
+ "version": "1.0.826",
4
4
  "description": "Install Real Agent — Built for agents. By agents.",
5
5
  "bin": {
6
6
  "create-realagent": "./dist/index.js"
@@ -0,0 +1,40 @@
1
+ export type JsonValue = string | number | boolean | null | JsonValue[] | {
2
+ [key: string]: JsonValue;
3
+ };
4
+ export interface FormSchema {
5
+ /**
6
+ * Field names whose values are secret and must never land on a Task node.
7
+ * Declared at the form definition (e.g. `cloudflare-setup-form`'s schema in
8
+ * `cloudflare-setup-types.ts`), not at the writer. Writers pass the form
9
+ * schema through unmodified — the contract is "secrets are declared once
10
+ * where the form is defined, redaction is enforced once at write time."
11
+ */
12
+ secretFields?: string[];
13
+ }
14
+ export interface RedactResult {
15
+ /**
16
+ * Payload with every `secretFields` entry removed. Non-secret keys keep
17
+ * their values intact (including null, false, 0, empty string). Keys not
18
+ * mentioned in `secretFields` always pass through — secrets are an
19
+ * allow-list of names to drop, not the entire universe.
20
+ */
21
+ redacted: Record<string, JsonValue>;
22
+ /** Number of fields that were stripped. Callers log this when > 0. */
23
+ droppedFields: number;
24
+ /** Names of fields that were stripped, sorted for stable log lines. */
25
+ droppedFieldNames: string[];
26
+ }
27
+ /**
28
+ * Strip every secret-tagged field from `payload` per `schema`. Pure: no IO,
29
+ * no logging, no thrown errors. Caller is responsible for the
30
+ * `[task] redacted ...` log line when `droppedFields > 0`.
31
+ *
32
+ * Edge-case behaviour:
33
+ * - `payload` undefined / null → empty result, droppedFields=0.
34
+ * - `schema` undefined / no `secretFields` → every key passes through.
35
+ * - A secret field whose key is absent from `payload` → no-op (not counted).
36
+ * - Non-string values on a secret field → key still dropped, value never
37
+ * inspected. Redaction is by KEY, not by value-shape heuristic.
38
+ */
39
+ export declare function redactSecrets(payload: Record<string, JsonValue> | null | undefined, schema: FormSchema | null | undefined): RedactResult;
40
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAYA,MAAM,MAAM,SAAS,GACjB,MAAM,GACN,MAAM,GACN,OAAO,GACP,IAAI,GACJ,SAAS,EAAE,GACX;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,CAAC;AAEjC,MAAM,WAAW,UAAU;IACzB;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;CACzB;AAED,MAAM,WAAW,YAAY;IAC3B;;;;;OAKG;IACH,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IACpC,sEAAsE;IACtE,aAAa,EAAE,MAAM,CAAC;IACtB,uEAAuE;IACvE,iBAAiB,EAAE,MAAM,EAAE,CAAC;CAC7B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,GAAG,IAAI,GAAG,SAAS,EACrD,MAAM,EAAE,UAAU,GAAG,IAAI,GAAG,SAAS,GACpC,YAAY,CAgBd"}
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ // Task 890 — outcome contract for audit Task input persistence.
3
+ //
4
+ // Doctrine: every audit Task records enough operator-meaningful information to
5
+ // explain what happened, AND no audit Task contains secret material. Mechanism
6
+ // is centralised here, not at the per-kind writer: form/action input
7
+ // definitions tag secret fields at the source of truth, and writers strip
8
+ // those fields via `redactSecrets` before persisting `inputs.<field>` props
9
+ // onto the Task node.
10
+ //
11
+ // Single file by design — no per-kind branching, no factory. A future
12
+ // schema shape change extends `FormSchema` here; the contract stays one place.
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.redactSecrets = redactSecrets;
15
+ /**
16
+ * Strip every secret-tagged field from `payload` per `schema`. Pure: no IO,
17
+ * no logging, no thrown errors. Caller is responsible for the
18
+ * `[task] redacted ...` log line when `droppedFields > 0`.
19
+ *
20
+ * Edge-case behaviour:
21
+ * - `payload` undefined / null → empty result, droppedFields=0.
22
+ * - `schema` undefined / no `secretFields` → every key passes through.
23
+ * - A secret field whose key is absent from `payload` → no-op (not counted).
24
+ * - Non-string values on a secret field → key still dropped, value never
25
+ * inspected. Redaction is by KEY, not by value-shape heuristic.
26
+ */
27
+ function redactSecrets(payload, schema) {
28
+ if (!payload) {
29
+ return { redacted: {}, droppedFields: 0, droppedFieldNames: [] };
30
+ }
31
+ const secrets = new Set(schema?.secretFields ?? []);
32
+ const redacted = {};
33
+ const dropped = [];
34
+ for (const [key, value] of Object.entries(payload)) {
35
+ if (secrets.has(key)) {
36
+ dropped.push(key);
37
+ continue;
38
+ }
39
+ redacted[key] = value;
40
+ }
41
+ dropped.sort();
42
+ return { redacted, droppedFields: dropped.length, droppedFieldNames: dropped };
43
+ }
44
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA,gEAAgE;AAChE,EAAE;AACF,+EAA+E;AAC/E,+EAA+E;AAC/E,qEAAqE;AACrE,0EAA0E;AAC1E,4EAA4E;AAC5E,sBAAsB;AACtB,EAAE;AACF,sEAAsE;AACtE,+EAA+E;;AA+C/E,sCAmBC;AA/BD;;;;;;;;;;;GAWG;AACH,SAAgB,aAAa,CAC3B,OAAqD,EACrD,MAAqC;IAErC,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,aAAa,EAAE,CAAC,EAAE,iBAAiB,EAAE,EAAE,EAAE,CAAC;IACnE,CAAC;IACD,MAAM,OAAO,GAAG,IAAI,GAAG,CAAC,MAAM,EAAE,YAAY,IAAI,EAAE,CAAC,CAAC;IACpD,MAAM,QAAQ,GAA8B,EAAE,CAAC;IAC/C,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QACnD,IAAI,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;YACrB,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YAClB,SAAS;QACX,CAAC;QACD,QAAQ,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;IACxB,CAAC;IACD,OAAO,CAAC,IAAI,EAAE,CAAC;IACf,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,OAAO,CAAC,MAAM,EAAE,iBAAiB,EAAE,OAAO,EAAE,CAAC;AACjF,CAAC"}
@@ -0,0 +1,127 @@
1
+ // Task 890 — no-secret-leak contract test.
2
+ //
3
+ // Verifies that `redactSecrets` strips every key declared as a secret in the
4
+ // form schema, and is meaningful (not a no-op) — removing the `secret` tag
5
+ // from a field MUST cause a deliberately-secret value to leak into the result,
6
+ // proving the test would catch a regression.
7
+
8
+ import { describe, expect, it } from 'vitest';
9
+ import { redactSecrets, type FormSchema } from '../index.js';
10
+
11
+ const SECRET_VALUE = 'hunter2';
12
+ const SECRET_REGEX = /password|token|api[_-]?key|secret|credential|bearer/i;
13
+
14
+ describe('redactSecrets', () => {
15
+ it('drops every key listed in secretFields', () => {
16
+ const schema: FormSchema = { secretFields: ['password', 'apiKey'] };
17
+ const payload = {
18
+ adminLabel: 'maxy-test',
19
+ adminDomain: 'maxy.bot',
20
+ password: SECRET_VALUE,
21
+ apiKey: 'sk-abc',
22
+ };
23
+ const { redacted, droppedFields, droppedFieldNames } = redactSecrets(payload, schema);
24
+
25
+ expect(redacted).toEqual({ adminLabel: 'maxy-test', adminDomain: 'maxy.bot' });
26
+ expect(droppedFields).toBe(2);
27
+ expect(droppedFieldNames).toEqual(['apiKey', 'password']);
28
+ });
29
+
30
+ it('passes every key through when schema has no secretFields', () => {
31
+ const payload = { mode: 'personal', emailProvided: true };
32
+ const { redacted, droppedFields } = redactSecrets(payload, {});
33
+ expect(redacted).toEqual(payload);
34
+ expect(droppedFields).toBe(0);
35
+ });
36
+
37
+ it('passes every key through when schema is undefined', () => {
38
+ const payload = { mode: 'personal' };
39
+ const { redacted, droppedFields } = redactSecrets(payload, undefined);
40
+ expect(redacted).toEqual(payload);
41
+ expect(droppedFields).toBe(0);
42
+ });
43
+
44
+ it('returns empty result for null/undefined payload', () => {
45
+ expect(redactSecrets(null, { secretFields: ['x'] })).toEqual({
46
+ redacted: {},
47
+ droppedFields: 0,
48
+ droppedFieldNames: [],
49
+ });
50
+ expect(redactSecrets(undefined, undefined)).toEqual({
51
+ redacted: {},
52
+ droppedFields: 0,
53
+ droppedFieldNames: [],
54
+ });
55
+ });
56
+
57
+ it('preserves falsy non-secret values (false, 0, empty string, null)', () => {
58
+ const payload = { a: false, b: 0, c: '', d: null, secret: 's' };
59
+ const { redacted } = redactSecrets(payload, { secretFields: ['secret'] });
60
+ expect(redacted).toEqual({ a: false, b: 0, c: '', d: null });
61
+ });
62
+
63
+ it('does not count secret keys absent from payload', () => {
64
+ const schema: FormSchema = { secretFields: ['password', 'missingField'] };
65
+ const { droppedFields, droppedFieldNames } = redactSecrets({ password: SECRET_VALUE }, schema);
66
+ expect(droppedFields).toBe(1);
67
+ expect(droppedFieldNames).toEqual(['password']);
68
+ });
69
+
70
+ it('drops a secret key regardless of its value shape', () => {
71
+ const schema: FormSchema = { secretFields: ['password'] };
72
+ const cases: Array<{ password: unknown }> = [
73
+ { password: 'string-value' },
74
+ { password: 42 },
75
+ { password: null },
76
+ { password: { nested: 'object' } },
77
+ { password: ['array', 'of', 'strings'] },
78
+ ];
79
+ for (const c of cases) {
80
+ const { redacted } = redactSecrets(c as Record<string, never>, schema);
81
+ expect(redacted).not.toHaveProperty('password');
82
+ }
83
+ });
84
+
85
+ it('CONTRACT: no recorded property value contains a secret regex match (cloudflare-setup-form)', () => {
86
+ // Synthetic cloudflare submission shape — mirrors CloudflareSetupRequest
87
+ // including both the operator-supplied secret (password) and the
88
+ // wire-protocol envelope (session_key, messageId) the schema also drops.
89
+ const cloudflareSchema: FormSchema = { secretFields: ['password', 'session_key', 'messageId'] };
90
+ const payload = {
91
+ adminLabel: 'maxy-test',
92
+ adminDomain: 'maxy.bot',
93
+ publicLabel: 'public',
94
+ publicDomain: 'maxy.bot',
95
+ apex: 'maxy.bot',
96
+ password: SECRET_VALUE,
97
+ tunnelName: 'maxy-test',
98
+ session_key: 'sk-conversation-correlation-12345678',
99
+ messageId: '11111111-2222-3333-4444-555555555555',
100
+ };
101
+ const { redacted } = redactSecrets(payload, cloudflareSchema);
102
+
103
+ // Scan every recorded value for any known-secret regex match.
104
+ for (const [key, value] of Object.entries(redacted)) {
105
+ const stringified = JSON.stringify(value);
106
+ expect(stringified, `field ${key} carried a secret-shaped value`).not.toMatch(SECRET_REGEX);
107
+ expect(stringified, `field ${key} carried the secret literal`).not.toContain(SECRET_VALUE);
108
+ }
109
+ // Wire-envelope fields must also be absent from recorded props.
110
+ expect(redacted).not.toHaveProperty('session_key');
111
+ expect(redacted).not.toHaveProperty('messageId');
112
+ });
113
+
114
+ it('CONTRACT (falsifiability): removing the secret tag causes the password to leak — proves the test is meaningful', () => {
115
+ // This test is the canary that proves the no-secret-leak guarantee comes
116
+ // from the schema, not from coincidence. With `secretFields` empty (the
117
+ // schema-author forgot to tag `password`), the value MUST leak into the
118
+ // recorded payload — otherwise the contract test above would still pass
119
+ // even with a broken schema, and would catch nothing.
120
+ const brokenSchema: FormSchema = { secretFields: [] };
121
+ const payload = { adminLabel: 'maxy-test', password: SECRET_VALUE };
122
+ const { redacted } = redactSecrets(payload, brokenSchema);
123
+
124
+ expect(redacted.password).toBe(SECRET_VALUE);
125
+ expect(JSON.stringify(redacted)).toContain(SECRET_VALUE);
126
+ });
127
+ });
@@ -0,0 +1,77 @@
1
+ // Task 890 — outcome contract for audit Task input persistence.
2
+ //
3
+ // Doctrine: every audit Task records enough operator-meaningful information to
4
+ // explain what happened, AND no audit Task contains secret material. Mechanism
5
+ // is centralised here, not at the per-kind writer: form/action input
6
+ // definitions tag secret fields at the source of truth, and writers strip
7
+ // those fields via `redactSecrets` before persisting `inputs.<field>` props
8
+ // onto the Task node.
9
+ //
10
+ // Single file by design — no per-kind branching, no factory. A future
11
+ // schema shape change extends `FormSchema` here; the contract stays one place.
12
+
13
+ export type JsonValue =
14
+ | string
15
+ | number
16
+ | boolean
17
+ | null
18
+ | JsonValue[]
19
+ | { [key: string]: JsonValue };
20
+
21
+ export interface FormSchema {
22
+ /**
23
+ * Field names whose values are secret and must never land on a Task node.
24
+ * Declared at the form definition (e.g. `cloudflare-setup-form`'s schema in
25
+ * `cloudflare-setup-types.ts`), not at the writer. Writers pass the form
26
+ * schema through unmodified — the contract is "secrets are declared once
27
+ * where the form is defined, redaction is enforced once at write time."
28
+ */
29
+ secretFields?: string[];
30
+ }
31
+
32
+ export interface RedactResult {
33
+ /**
34
+ * Payload with every `secretFields` entry removed. Non-secret keys keep
35
+ * their values intact (including null, false, 0, empty string). Keys not
36
+ * mentioned in `secretFields` always pass through — secrets are an
37
+ * allow-list of names to drop, not the entire universe.
38
+ */
39
+ redacted: Record<string, JsonValue>;
40
+ /** Number of fields that were stripped. Callers log this when > 0. */
41
+ droppedFields: number;
42
+ /** Names of fields that were stripped, sorted for stable log lines. */
43
+ droppedFieldNames: string[];
44
+ }
45
+
46
+ /**
47
+ * Strip every secret-tagged field from `payload` per `schema`. Pure: no IO,
48
+ * no logging, no thrown errors. Caller is responsible for the
49
+ * `[task] redacted ...` log line when `droppedFields > 0`.
50
+ *
51
+ * Edge-case behaviour:
52
+ * - `payload` undefined / null → empty result, droppedFields=0.
53
+ * - `schema` undefined / no `secretFields` → every key passes through.
54
+ * - A secret field whose key is absent from `payload` → no-op (not counted).
55
+ * - Non-string values on a secret field → key still dropped, value never
56
+ * inspected. Redaction is by KEY, not by value-shape heuristic.
57
+ */
58
+ export function redactSecrets(
59
+ payload: Record<string, JsonValue> | null | undefined,
60
+ schema: FormSchema | null | undefined,
61
+ ): RedactResult {
62
+ if (!payload) {
63
+ return { redacted: {}, droppedFields: 0, droppedFieldNames: [] };
64
+ }
65
+ const secrets = new Set(schema?.secretFields ?? []);
66
+ const redacted: Record<string, JsonValue> = {};
67
+ const dropped: string[] = [];
68
+ for (const [key, value] of Object.entries(payload)) {
69
+ if (secrets.has(key)) {
70
+ dropped.push(key);
71
+ continue;
72
+ }
73
+ redacted[key] = value;
74
+ }
75
+ dropped.sort();
76
+ return { redacted, droppedFields: dropped.length, droppedFieldNames: dropped };
77
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"],
8
+ "exclude": ["src/__tests__"]
9
+ }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'node',
6
+ globals: false,
7
+ include: ['src/__tests__/**/*.test.ts'],
8
+ },
9
+ });
@@ -601,6 +601,17 @@ FOR (up:UserProfile) REQUIRE (up.accountId, up.userId) IS UNIQUE;
601
601
  //
602
602
  // Categories: communication, scheduling, decision, workflow,
603
603
  // content, interaction
604
+ //
605
+ // Recognised optional properties:
606
+ // notApplicable (boolean) — Task 888. When `true`, the Preference
607
+ // records that the user explicitly declared the (category, key)
608
+ // does not apply to them. Counted as covered for the field-level
609
+ // Coverage signal in `formatProfileSummary`; filtered from the
610
+ // user-facing body (no value to render). Immutable on the tool
611
+ // surface (`profile-update` rejects mode merge|contradict). Always
612
+ // written with confidence: 1.0. No constraint, no index — the
613
+ // read query in loadUserProfile / profile-read widens the WHERE
614
+ // to include `OR pref.notApplicable = true`.
604
615
  // ----------------------------------------------------------
605
616
 
606
617
  CREATE CONSTRAINT preference_id_unique IF NOT EXISTS
@@ -6,8 +6,8 @@
6
6
  "plugins/*/mcp"
7
7
  ],
8
8
  "scripts": {
9
- "build": "tsc -p lib/models/tsconfig.json && tsc -p lib/anthropic-key/tsconfig.json && tsc -p lib/oauth-llm/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/mcp-spawn-tee/tsconfig.json && tsc -p lib/graph-write/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/screening-patterns/tsconfig.json && tsc -p lib/device-url/tsconfig.json && tsc -p lib/brand-templating/tsconfig.json && tsc -p lib/entitlement/tsconfig.json && tsc -p plugins/whatsapp-import/lib/tsconfig.json && NODE_OPTIONS='--max-old-space-size=8192' tsc -b plugins/*/mcp/tsconfig.json",
10
- "build:lib": "tsc -p lib/models/tsconfig.json && tsc -p lib/anthropic-key/tsconfig.json && tsc -p lib/oauth-llm/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/mcp-spawn-tee/tsconfig.json && tsc -p lib/graph-write/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/screening-patterns/tsconfig.json && tsc -p lib/device-url/tsconfig.json && tsc -p lib/brand-templating/tsconfig.json && tsc -p lib/entitlement/tsconfig.json && tsc -p plugins/whatsapp-import/lib/tsconfig.json",
9
+ "build": "tsc -p lib/models/tsconfig.json && tsc -p lib/anthropic-key/tsconfig.json && tsc -p lib/oauth-llm/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/mcp-spawn-tee/tsconfig.json && tsc -p lib/graph-write/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/screening-patterns/tsconfig.json && tsc -p lib/device-url/tsconfig.json && tsc -p lib/brand-templating/tsconfig.json && tsc -p lib/entitlement/tsconfig.json && tsc -p lib/task-secrets/tsconfig.json && tsc -p plugins/whatsapp-import/lib/tsconfig.json && NODE_OPTIONS='--max-old-space-size=8192' tsc -b plugins/*/mcp/tsconfig.json",
10
+ "build:lib": "tsc -p lib/models/tsconfig.json && tsc -p lib/anthropic-key/tsconfig.json && tsc -p lib/oauth-llm/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/mcp-spawn-tee/tsconfig.json && tsc -p lib/graph-write/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/screening-patterns/tsconfig.json && tsc -p lib/device-url/tsconfig.json && tsc -p lib/brand-templating/tsconfig.json && tsc -p lib/entitlement/tsconfig.json && tsc -p lib/task-secrets/tsconfig.json && tsc -p plugins/whatsapp-import/lib/tsconfig.json",
11
11
  "build:memory": "tsc -p plugins/memory/mcp/tsconfig.json",
12
12
  "build:contacts": "tsc -p plugins/contacts/mcp/tsconfig.json",
13
13
  "build:telegram": "tsc -p plugins/telegram/mcp/tsconfig.json",
@@ -10,14 +10,14 @@ Applies when the business owner wants to set up, complete, or update their busin
10
10
  ## When to Activate
11
11
 
12
12
  - Admin asks to set up, edit, or complete their business profile
13
- - Onboarding step 9 delegates here on first run **only when the operator picked `business-owner` mode** (Task 704). Personal mode bootstraps a `Person` with `role: "admin-personal"` directly inside step 9 and does NOT invoke this skill.
13
+ - Onboarding step 9 delegates here on first run **only when the operator picked `business-owner` mode**. Personal mode bootstraps a `Person` with `role: "admin-personal"` directly inside step 9 and does NOT invoke this skill.
14
14
  - Session-start graph check shows a `LocalBusiness` node with missing data (no hours, no services, empty description)
15
15
  - The graph-write gate rejects a `memory-write` or `memory-update` with `no-admin-user` or `no-local-business` — the error message directs the agent here. (For personal-mode accounts, the gate is satisfied by the personal-profile `Person` node, so this rejection should not fire — if it does, the personal-profile node is missing or its `role` is wrong.)
16
16
  - Admin asks questions like "update my address", "add our opening hours", "change the phone number"
17
17
 
18
18
  ## First-run path (no LocalBusiness yet)
19
19
 
20
- The `AdminUser` and the operator's personal-profile `Person` were written deterministically at PIN setup time (Task 830 — `writeAdminUserAndPerson`), so this skill no longer creates the AdminUser. When onboarding step 9 invokes this skill in `business-owner` mode, or when the graph-write gate reports `no-local-business`, create the LocalBusiness:
20
+ The `AdminUser` and the operator's personal-profile `Person` are written deterministically at PIN setup time, so this skill no longer creates the AdminUser. When onboarding step 9 invokes this skill in `business-owner` mode, or when the graph-write gate reports `no-local-business`, create the LocalBusiness:
21
21
 
22
22
  1. **LocalBusiness.** Ask the user for the business name first — this is the only mandatory property. Call `memory-write` with `labels: ["LocalBusiness"]`, minimal `properties: { name }`, `scope: "shared"`, `relationships: [{ type: "OWNS", direction: "incoming", targetNodeId: "<AdminUser-elementId>" }]`. The `LocalBusiness` label is on the exempt set, so the gate passes for this write. Find the AdminUser elementId via `memory-search` for the operator's name; the AdminUser already exists from PIN setup.
23
23
  2. **Capture the remaining domains** (address, hours, services, FAQs, brand assets) via `memory-update` on the `LocalBusiness` node and `memory-write` for related nodes (`OpeningHoursSpecification`, `Service`, `FAQPage`, `ImageObject`). With LocalBusiness present, the gate passes for all subsequent writes.
@@ -203,19 +203,20 @@ Pin the operator's persona and bootstrap the graph nodes that satisfy the graph-
203
203
 
204
204
  **Call `onboarding-step9-mode` with the chosen mode before any graph write or skill invocation.** The tool emits the diagnostic log line and returns the deterministic next-action prose.
205
205
 
206
- **Open the action record with `task-create` (Task 885 process-provenance doctrine).** Before any graph write or skill invocation, call:
206
+ **Open the action record with `task-create` (process-provenance + audit input contract).** Before any graph write or skill invocation, call `task-create` with:
207
207
 
208
- ```
209
- task-create
210
- name: "Establish operator owner — onboarding step 9"
211
- description: "<one-line summary of the chosen mode and what entities will be produced>"
212
- status: "running"
213
- kind: "onboarding-establish-owner"
214
- inputsProvided: ["mode"]
215
- ```
208
+ - `name`: "Establish operator owner — onboarding step 9"
209
+ - `description`: a one-line summary describing the chosen mode and what entities will be produced
210
+ - `status`: `"running"`
211
+ - `kind`: `"onboarding-establish-owner"`
212
+ - `inputsProvided`: the keys you actually pass on `inputs` (e.g. `["mode"]`); records the call shape
213
+ - `inputs`: the form payload — at minimum `{ mode: "personal" | "business-owner" }`. Add other operator-supplied non-secret fields you want the audit panel to render (e.g. when the user later provides email/phone in step 9 personal mode, you can append a follow-up `task-update` rather than reopening the Task)
214
+ - `inputSchema`: `{ secretFields: [] }` — the step-9 mode select carries no secrets; declare the empty list explicitly so the contract is visible at the call site
216
215
 
217
216
  The returned `taskId` is the action-provenance handle for this step — every subsequent `memory-write` for an action-provenance-gated label (`Person`, `UserProfile`, `AdminUser`, `Organization`, `LocalBusiness`) MUST pass it as `producedByTaskId` so the inbound `:PRODUCED` edge from the Task is composed into the write. The Task is auto-linked to the current `AdminConversation` via `RAISED_DURING` (this is what makes `MATCH (c:AdminConversation)<-[:RAISED_DURING]-(t:Task)-[:PRODUCED]->(entity)` traversable from the conversation that initiated onboarding).
218
217
 
218
+ The `inputs` you pass land on the Task as `inputs.<field>` props after `redactSecrets` strips any secret-tagged keys. The contract is enforced centrally — never re-classify what is or isn't a secret at the call site; declare the form's secret fields once in `inputSchema.secretFields`.
219
+
219
220
  Then branch on the mode.
220
221
 
221
222
  ### `business-owner`
@@ -235,4 +236,6 @@ Personal mode does not register a `LocalBusiness`. The `AdminUser` and personal-
235
236
 
236
237
  After step 9 completes in personal mode, tell the user that {{productName}} is configured for personal use — their employer (if any) is not registered here. If they later become the operator for a business of their own, they can ask {{productName}} to set up a business profile, which invokes the `business-profile` skill directly.
237
238
 
239
+ **Post-onboarding work-preference elicitation (Task 888) — no step-9 change.** After step 9 completes, the per-turn `## About the Owner` block surfaces the field-level Coverage signal as the canonical post-onboarding elicitation source: `### Coverage / Missing: communication.preferredChannel, scheduling.workdayStartTime, …`. Onboarding deliberately does NOT pre-elicit these in a questionnaire — the agent drips them organically, one per turn, via the IDENTITY.md § Conversational Memory contract. Step 9's role is to bootstrap identity + persona; work-preferences are accumulated through normal conversation thereafter, with `notApplicable: true` covering the "doesn't apply to me" case (declined fields stop re-prompting).
240
+
238
241
  If the user declines to bootstrap during step 9 in any mode, leave step 9 incomplete AND call `task-update(taskId, status:"failed", errorMessage:"<one-line reason>")` so the action record reflects the abandonment instead of dangling in `running` forever. The next session will resume here with a fresh `task-create` (the prior failed Task stays in the graph as the audit record). Any attempt to write user-domain data will surface `Write blocked (no-admin-user)` or `Write blocked (no-local-business)` via the gate, pulling the agent back into this step.
@@ -22,7 +22,7 @@ Built-in plugins under `$PLATFORM_ROOT/plugins/`:
22
22
 
23
23
  - Enable: call `plugin-toggle-enabled` with `pluginName` and `action: "enable"`. Activates the plugin's behaviour embed and MCP server from the next session. Plugins with scheduled behaviour (declared via `lifecycle` in PLUGIN.md frontmatter) are activated automatically by the platform heartbeat within one minute — no separate setup command needed.
24
24
  - Disable: call `plugin-toggle-enabled` with `pluginName` and `action: "disable"`. Deactivates from the next session. Any scheduled Events owned by the plugin (`sourcePlugin` field) are cancelled automatically by the platform heartbeat.
25
- - The tool refuses core plugins (admin, memory, docs, cloudflare, anthropic) and refuses plugins not installed under `$PLATFORM_ROOT/plugins/`. Direct `Edit` of `account.json` is denied by the pre-tool-use hook (Task 831) — `plugin-toggle-enabled` is the only legitimate path.
25
+ - The tool refuses core plugins (admin, memory, docs, cloudflare, anthropic) and refuses plugins not installed under `$PLATFORM_ROOT/plugins/`. Direct `Edit` of `account.json` is denied by the pre-tool-use hook — `plugin-toggle-enabled` is the only legitimate path.
26
26
 
27
27
  ## Premium Plugins
28
28
 
@@ -38,11 +38,11 @@ When the user asks about a specific premium plugin (e.g., "tell me about the tea
38
38
 
39
39
  ### Recording a purchase
40
40
 
41
- `purchasedPlugins` is an entitlement-bearing field (Task 831): the effective list derives from the Rubytech-signed entitlement payload, not from raw `account.json`. The pre-tool-use hook denies any agent write to `account.json`. Direct purchase recording is therefore unavailable to the agent.
41
+ `purchasedPlugins` is an entitlement-bearing field: the effective list derives from the Rubytech-signed entitlement payload, not from raw `account.json`. The pre-tool-use hook denies any agent write to `account.json`. Direct purchase recording is therefore unavailable to the agent.
42
42
 
43
- - **Production path:** Task 832 — Rubytech-side issuance endpoint signs an updated entitlement payload after a Stripe purchase event and delivers it to `~/<configDir>/entitlement.json`. The verifier picks up the new payload at the next session start.
43
+ - **Production path:** the Rubytech-side issuance endpoint signs an updated entitlement payload after a Stripe purchase event and delivers it to `~/<configDir>/entitlement.json`. The verifier picks up the new payload at the next session start.
44
44
  - **Pre-launch / dev path:** the founder runs `node platform/scripts/generate-entitlement-fixture.mjs sign --account-id <id> --email <e> --tier <t> --plugins a,b,c --out ~/<configDir>/entitlement.json` to produce a signed test payload. The agent does not invoke this script.
45
- - **Personal-mode installs (`brand.json.commercialMode: false`, the default today):** purchasedPlugins is read from raw `account.json` via implicit-trust mode. Pre-launch the agent has no way to add purchases without operator intervention; this is intentional during the gap before Task 832 ships.
45
+ - **Personal-mode installs (`brand.json.commercialMode: false`, the default today):** purchasedPlugins is read from raw `account.json` via implicit-trust mode. Pre-launch the agent has no way to add purchases without operator intervention; this is intentional during the gap before signed-issuance ships.
46
46
 
47
47
  If the user asks the agent to record a purchase, explain that their purchase is processed by Rubytech billing and the entitlement payload arrives on their device automatically — no agent action is required.
48
48
 
@@ -195,7 +195,7 @@ After creation, no template metadata persists in the agent's files. The resultin
195
195
  9. **KNOWLEDGE.md generation** — populate from the now-tagged set plus keyword matches using the `update-knowledge` skill workflow
196
196
  10. Write `config.json` with selected model, plugins, status "active", `liveMemory`, and `knowledgeKeywords`. This is the last gated write — placed after IDENTITY.md, SOUL.md, and KNOWLEDGE.md to prevent cascade failure if one gate stalls.
197
197
  11. Check context budget — auto-summarise if over threshold
198
- 12. **Project the agent into the graph** — delegate to the `database-operator` specialist with the instruction: "Project public agent `{slug}` into the graph by POSTing to `/api/admin/agents/{slug}/project` (Task 837 surface)." The route reads the on-disk files and idempotently MERGEs the `:Agent` node, the four owned `:KnowledgeDocument` projections (IDENTITY/SOUL/KNOWLEDGE/KNOWLEDGE-SUMMARY when present, namespaced `attachmentId="agent:<slug>:<role>"`), the `HAS_*` edges, and the `USES_KNOWLEDGE` edges to every operator-tagged doc. Loud-fail: if the route returns non-2xx, surface the error to the user verbatim — the agent's files exist on disk but its graph projection is incomplete, which means the operator's /graph view will not show this agent. Re-running the projection is safe; it is the same idempotent MERGE.
198
+ 12. **Project the agent into the graph** — delegate to the `database-operator` specialist with the instruction: "Project public agent `{slug}` into the graph by POSTing to `/api/admin/agents/{slug}/project`." The route reads the on-disk files and idempotently MERGEs the `:Agent` node, the four owned `:KnowledgeDocument` projections (IDENTITY/SOUL/KNOWLEDGE/KNOWLEDGE-SUMMARY when present, namespaced `attachmentId="agent:<slug>:<role>"`), the `HAS_*` edges, and the `USES_KNOWLEDGE` edges to every operator-tagged doc. Loud-fail: if the route returns non-2xx, surface the error to the user verbatim — the agent's files exist on disk but its graph projection is incomplete, which means the operator's /graph view will not show this agent. Re-running the projection is safe; it is the same idempotent MERGE.
199
199
  13. Confirm creation: "Agent created. Visitors can reach it at `/{slug}`"
200
200
 
201
201
  ### List
@@ -245,7 +245,7 @@ Deletion removes the entire agent directory (`agents/{slug}/`) — not individua
245
245
  - If no other agents remain (or the user declines to set a new default), clear the `defaultAgent` field by calling `account-update` with `field: "defaultAgent"`, `value: ""`. Tell the user that visitors to the root URL will see a "no agents" message until a new agent is created.
246
246
 
247
247
  **Cleanup sequence:**
248
- - Issue `DELETE /api/admin/agents/{slug}` — the route runs `deleteAgentProjection` (Task 837) FIRST to remove the `:Agent` node and its four owned `:KnowledgeDocument` projections, then `rmSync` the directory. Loud-fail: if graph cleanup throws, files are preserved and a 500 is returned with the error text. Re-issuing the DELETE is safe (idempotent on both layers). Operator-tagged docs survive the projection delete — only the `USES_KNOWLEDGE` edges from this Agent are removed.
248
+ - Issue `DELETE /api/admin/agents/{slug}` — the route removes the `:Agent` node and its four owned `:KnowledgeDocument` projections FIRST, then deletes the directory. Loud-fail: if graph cleanup throws, files are preserved and a 500 is returned with the error text. Re-issuing the DELETE is safe (idempotent on both layers). Operator-tagged docs survive the projection delete — only the `USES_KNOWLEDGE` edges from this Agent are removed.
249
249
  - Verify the directory no longer exists
250
250
  - Report what was removed (list the files that were in the directory)
251
251
 
@@ -10,7 +10,7 @@ description: >
10
10
 
11
11
  # Stream Log Review
12
12
 
13
- Analyse Claude agent stream logs — the `claude-agent-stream-{conversationId}.log` files generated by the platform (Task 532). Each file is scoped to exactly one conversation; a resumed conversation accumulates in one file across multiple process lifetimes, delimited by `[spawn]` / `[process-exit]`. Pre-conversation events (CDP auth-poll, module-init warnings) live in `preconversation-claude-agent-stream-{YYYY-MM-DD}.log` — a separate file per UTC day, read only when investigating boot-time failures.
13
+ Analyse Claude agent stream logs — the `claude-agent-stream-{conversationId}.log` files generated by the platform. Each file is scoped to exactly one conversation; a resumed conversation accumulates in one file across multiple process lifetimes, delimited by `[spawn]` / `[process-exit]`. Pre-conversation events (CDP auth-poll, module-init warnings) live in `preconversation-claude-agent-stream-{YYYY-MM-DD}.log` — a separate file per UTC day, read only when investigating boot-time failures.
14
14
 
15
15
  Parse the log, identify problems, attempt diagnosis, and produce a structured report.
16
16
 
@@ -35,7 +35,7 @@ Parse the log, identify problems, attempt diagnosis, and produce a structured re
35
35
  On the Pi, use the `logs-read` MCP tool with `conversationId` to retrieve a single conversation's log. From the dev machine (SSH), use the shell counterpart:
36
36
 
37
37
  ```bash
38
- # Read a single conversation's stream log (primary mode — Task 532)
38
+ # Read a single conversation's stream log (primary mode)
39
39
  sshpass -p 'password' ssh admin@<hostname>.local "~/<installDir>/platform/scripts/logs-read.sh <conversationId> system"
40
40
 
41
41
  # Tail the most-recently-active stream log (any conversation)
@@ -53,11 +53,11 @@ The `conversationId` is visible in the admin UI and appears on every `[spawn]` /
53
53
  - `[tool-wait-diag]` at 15 / 30 / 45 / 60 s — confirms DNS/TCP/HTTP health across a stall window (not just at the timeout moment).
54
54
  - `[tool-wait-proc]` at 30 s — subprocess open FDs, sockets by TCP state, RSS.
55
55
  - `[subproc-stderr]` — line from the main Claude Code subprocess's stderr. Today the CLI is a bundled Bun binary that ignores Node's `NODE_DEBUG`, so this channel is normally silent — see `[subproc-debug-unavailable]` below. MCP server stderr lines arrive separately as `[mcp:<server>]`.
56
- - `[subproc-stderr-tee-attached]` / `[subproc-stderr-tee-detached]` (Task 535) — lifecycle of the main-subprocess stderr tee. `bytes=0 lines=0` on detach means the tee worked but the subprocess emitted nothing. Absence of both markers next to a `[spawn]` means the tee infrastructure is broken — escalate.
57
- - `[subproc-debug-unavailable]` (Task 535) — one line per spawn, `reason=bundled-bun-binary-ignores-node-debug`. This is the documented reason `[subproc-stderr]` lines are normally absent for the main subprocess. Treat its absence as a regression, not its presence as a problem.
58
- - `[tool-failure-diag]` — one-shot probe on the failure path (Task 530); complements the mid-flight `[tool-wait-diag]` with end-of-wait network state.
56
+ - `[subproc-stderr-tee-attached]` / `[subproc-stderr-tee-detached]` — lifecycle of the main-subprocess stderr tee. `bytes=0 lines=0` on detach means the tee worked but the subprocess emitted nothing. Absence of both markers next to a `[spawn]` means the tee infrastructure is broken — escalate.
57
+ - `[subproc-debug-unavailable]` — one line per spawn, `reason=bundled-bun-binary-ignores-node-debug`. This is the documented reason `[subproc-stderr]` lines are normally absent for the main subprocess. Treat its absence as a regression, not its presence as a problem.
58
+ - `[tool-failure-diag]` — one-shot probe on the failure path; complements the mid-flight `[tool-wait-diag]` with end-of-wait network state.
59
59
  - `[mcp-tee-attach]` / `[mcp-tee-skip]` / `[mcp:<server>]` — MCP server stderr routed into the stream log; a missing attach marker explains missing server diagnostics.
60
- - `[tool-result] error=true output="WEBFETCH_CANNOT_READ_JS_SPA: …"` — a WebFetch dispatch was short-circuited by the SPA preflight hook (Task 536). The URL is a JS-SPA shell that WebFetch cannot extract content from. The agent should have responded to the user naming the failure and asking for a paste or screenshot; if instead an `[agent-dispatch]` (Playwright, research-assistant) follows immediately, the loud-failure guidance in IDENTITY.md did not land. The hook's per-invocation decision trail lives in `{accountDir}/logs/webfetch-preflight.log` (one `[webfetch-preflight] verdict=…` line per probe).
60
+ - `[tool-result] error=true output="WEBFETCH_CANNOT_READ_JS_SPA: …"` — a WebFetch dispatch was short-circuited by the SPA preflight hook. The URL is a JS-SPA shell that WebFetch cannot extract content from. The agent should have responded to the user naming the failure and asking for a paste or screenshot; if instead an `[agent-dispatch]` (Playwright, research-assistant) follows immediately, the loud-failure guidance in IDENTITY.md did not land. The hook's per-invocation decision trail lives in `{accountDir}/logs/webfetch-preflight.log` (one `[webfetch-preflight] verdict=…` line per probe).
61
61
 
62
62
  ## Boundaries
63
63
 
@@ -78,4 +78,4 @@ All of these are from `unzip` (InfoZIP) + `coreutils` — installed on every Pi
78
78
 
79
79
  ## Why this is a skill, not a tool
80
80
 
81
- Doctrine (Task 664 precedent): the admin agent has `Bash`; the security-critical code path is a sequence of shell commands whose determinism is enforced by the shell primitives themselves, not by LLM reasoning. Wrapping this in an MCP tool would add a translation layer without adding enforcement — and per [feedback_deterministic_means_remove_llm.md](../../../../../.claude/projects/-Users-neo-getmaxy/memory/feedback_deterministic_means_remove_llm.md), a tool wrapper is still LLM-mediated at the decision boundary. The shell script is the deterministic primitive; the skill tells the agent which primitive to invoke, in which order, against which argument.
81
+ Doctrine: the admin agent has `Bash`; the security-critical code path is a sequence of shell commands whose determinism is enforced by the shell primitives themselves, not by LLM reasoning. Wrapping this in an MCP tool would add a translation layer without adding enforcement — a tool wrapper is still LLM-mediated at the decision boundary. The shell script is the deterministic primitive; the skill tells the agent which primitive to invoke, in which order, against which argument.
@@ -217,7 +217,7 @@ cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel route dns --overwrite-dns
217
217
  2. Overwritten: `Added CNAME <hostname> which will route to this tunnel` (same shape as new; `--overwrite-dns` makes overwrite transparent)
218
218
  3. Idempotent no-op: `<timestamp> INF <hostname> is already configured to route to your tunnel tunnelID=<UUID>`
219
219
 
220
- Shape 3 is what a second clean run of setup-tunnel.sh against an already-configured hostname emits. Historically the shell script's stdout parser rejected this shape and exited 1 on the idempotent case (session `25674fe3`, Task 559 fix); the script now relies on cloudflared's exit code exclusively.
220
+ Shape 3 is what a second clean run of setup-tunnel.sh against an already-configured hostname emits. Historically the shell script's stdout parser rejected this shape and exited 1 on the idempotent case; the script now relies on cloudflared's exit code exclusively.
221
221
 
222
222
  **If it fails with `zone not found`:** the hostname's parent domain isn't on this brand's Cloudflare account. Either add it in the dashboard (Websites → Add a site) and re-run, or sign into the account that already owns the domain.
223
223
 
@@ -325,14 +325,14 @@ You do **not** run `cloudflared` manually. The brand's existing user-space syste
325
325
  systemctl --user restart "${BRAND}.service"
326
326
  ```
327
327
 
328
- **Why the script dispatches the restart via `systemd-run` instead of a direct `systemctl restart` (Task 558):** when the admin agent invokes `setup-tunnel.sh` via the Bash tool, the script runs *inside* `${BRAND}.service`'s cgroup. A direct `systemctl --user restart ${BRAND}.service` from that cgroup tells systemd to SIGTERM the entire cgroup — the node server, the claude subprocess, the Bash child, and the script itself all die simultaneously. cgroup membership is inherited: `setsid`, `nohup`, `disown`, and `&` all stay in the caller's cgroup, and `systemd-run --scope` runs in the caller's scope. Only `systemd-run --user --unit=<name> --on-active=<N>s` creates a genuinely new transient unit with its own cgroup. The script uses that primitive to arm the restart a few seconds after its own exit:
328
+ **Why the script dispatches the restart via `systemd-run` instead of a direct `systemctl restart`:** when the admin agent invokes `setup-tunnel.sh` via the Bash tool, the script runs *inside* `${BRAND}.service`'s cgroup. A direct `systemctl --user restart ${BRAND}.service` from that cgroup tells systemd to SIGTERM the entire cgroup — the node server, the claude subprocess, the Bash child, and the script itself all die simultaneously. cgroup membership is inherited: `setsid`, `nohup`, `disown`, and `&` all stay in the caller's cgroup, and `systemd-run --scope` runs in the caller's scope. Only `systemd-run --user --unit=<name> --on-active=<N>s` creates a genuinely new transient unit with its own cgroup. The script uses that primitive to arm the restart a few seconds after its own exit:
329
329
 
330
330
  ```
331
331
  systemd-run --user --unit=maxy-tunnel-restart-<nonce>.service --on-active=3s --collect \
332
332
  /bin/systemctl --user restart "${BRAND}.service"
333
333
  ```
334
334
 
335
- The script then emits `[script:setup-tunnel] step=service-restart-dispatched` and `step=service-restart-armed exit=0` in the per-conversation stream log so operators see exactly when the restart was scheduled, exits 0, and the transient timer fires from outside the service's cgroup — semantically identical to this manual runbook's `systemctl --user restart`. (The `script:` prefix is Task 605's chat-surface namespace — see `_stream-log.sh` header.)
335
+ The script then emits `[script:setup-tunnel] step=service-restart-dispatched` and `step=service-restart-armed exit=0` in the per-conversation stream log so operators see exactly when the restart was scheduled, exits 0, and the transient timer fires from outside the service's cgroup — semantically identical to this manual runbook's `systemctl --user restart`. (The `script:` prefix is the chat-surface namespace — see `_stream-log.sh` header.)
336
336
 
337
337
  When walking through manually you do **not** need `systemd-run` — your SSH shell already lives in a separate user-scope cgroup (`user@<uid>.service`), so the direct `systemctl restart` does not kill the caller. The script's extra indirection only matters when the caller *is* the service being restarted.
338
338
 
@@ -20,9 +20,9 @@ Any Cloudflare action outside these four surfaces is a discipline violation —
20
20
 
21
21
  ## 1. Autonomous path — `setup-tunnel.sh`
22
22
 
23
- Use this when the operator wants Cloudflare set up (or re-set up) end-to-end on the device. The script handles OAuth login, tunnel creation, DNS routing for each subdomain, config.yml + tunnel.state, and dispatches the `${BRAND}.service` restart to a transient `systemd-run` unit (Task 558) — all in one invocation. The restart fires a few seconds after the script exits so the script does not kill its own cgroup when invoked via the Bash tool; the chat UI receives a `server_shutdown` SSE frame and reconnects automatically. Post-restart hostname verification is out of scope for the script (connector is not up when the script exits) — verify via the next admin turn or manually with `curl -I https://<hostname>`. Apex hostnames cannot be routed by the CLI; when one is passed, the script prints an `ACTION REQUIRED` block naming the exact dashboard record to edit.
23
+ Use this when the operator wants Cloudflare set up (or re-set up) end-to-end on the device. The script handles OAuth login, tunnel creation, DNS routing for each subdomain, config.yml + tunnel.state, and dispatches the `${BRAND}.service` restart to a transient `systemd-run` unit — all in one invocation. The restart fires a few seconds after the script exits so the script does not kill its own cgroup when invoked via the Bash tool; the chat UI receives a `server_shutdown` SSE frame and reconnects automatically. Post-restart hostname verification is out of scope for the script (connector is not up when the script exits) — verify via the next admin turn or manually with `curl -I https://<hostname>`. Apex hostnames cannot be routed by the CLI; when one is passed, the script prints an `ACTION REQUIRED` block naming the exact dashboard record to edit.
24
24
 
25
- Step 1's OAuth flow is a state machine over two observable variables: the brand-scoped cert path (`${CFG_DIR}/cert.pem`) and the OAuth-default cert path (`~/.cloudflared/cert.pem`). When the brand-scoped cert is missing but the default-path cert is present from any prior partial run, the wrapper promotes it (`mv`) and emits `step=oauth-login result=ok reason=cert-promoted-from-default-path` without re-spawning cloudflared. When both are missing, the wrapper spawns `cloudflared tunnel login`, extracts the argotunnel URL from its stdout, and the instant the URL surfaces, mechanically opens it on the Pi's VNC chromium (`DISPLAY=${DISPLAY:-:99} /usr/bin/chromium <url> &`) — emitting `step=browser-spawn result=ok` and `step=browser-drive mode=operator-click`. The operator clicks the zone row + Authorize on the VNC; cloudflared's callback writes `~/.cloudflared/cert.pem`; the wrapper's cert-poll (180 s budget) picks it up and `mv`s it to the brand-scoped path. There is no CDP auto-click, no DOM matcher, no consent-page driver — the wrapper's job is to faithfully relay `cloudflared tunnel login`, never to layer automation on top (Task 858, doctrine `feedback_wrappers_faithfully_relay_third_party_cli`).
25
+ Step 1's OAuth flow is a state machine over two observable variables: the brand-scoped cert path (`${CFG_DIR}/cert.pem`) and the OAuth-default cert path (`~/.cloudflared/cert.pem`). When the brand-scoped cert is missing but the default-path cert is present from any prior partial run, the wrapper promotes it (`mv`) and emits `step=oauth-login result=ok reason=cert-promoted-from-default-path` without re-spawning cloudflared. When both are missing, the wrapper spawns `cloudflared tunnel login`, extracts the argotunnel URL from its stdout, and the instant the URL surfaces, mechanically opens it on the Pi's VNC chromium (`DISPLAY=${DISPLAY:-:99} /usr/bin/chromium <url> &`) — emitting `step=browser-spawn result=ok` and `step=browser-drive mode=operator-click`. The operator clicks the zone row + Authorize on the VNC; cloudflared's callback writes `~/.cloudflared/cert.pem`; the wrapper's cert-poll (180 s budget) picks it up and `mv`s it to the brand-scoped path. There is no CDP auto-click, no DOM matcher, no consent-page driver — the wrapper's job is to faithfully relay `cloudflared tunnel login`, never to layer automation on top.
26
26
 
27
27
  ### How inputs reach the script
28
28
 
@@ -52,7 +52,7 @@ The agent does not invoke the script directly during onboarding — the endpoint
52
52
 
53
53
  The endpoint returns `{ ok: false, field: "script", message, output }` and the form surfaces the error inline. Relay the output to the user, name the exit code, and cite `references/reset-guide.md` for the next action. Offer to re-render the form after any manual steps the script's error output named. Do not attempt a second invocation outside the form, a Playwright-driven dashboard inspection, or an alternative `cloudflared` command sequence. The discipline rule below applies.
54
54
 
55
- When the failure reason is `timeout-waiting-cert` (Task 858 — operator did not click Authorize within the 180 s budget), the form surfaces the timeout and the operator can re-submit. The page is still on the Pi VNC; the operator can click Authorize there and the next form submit will complete via the cert-promotion pre-flight (the cert lands in `~/.cloudflared/cert.pem` after consent, and the wrapper's `mv` runs on the next invocation). Do not suggest `~/reset-tunnel.sh` — the cert path is intact and a fresh attempt is the only remediation needed.
55
+ When the failure reason is `timeout-waiting-cert` (operator did not click Authorize within the 180 s budget), the form surfaces the timeout and the operator can re-submit. The page is still on the Pi VNC; the operator can click Authorize there and the next form submit will complete via the cert-promotion pre-flight (the cert lands in `~/.cloudflared/cert.pem` after consent, and the wrapper's `mv` runs on the next invocation). Do not suggest `~/reset-tunnel.sh` — the cert path is intact and a fresh attempt is the only remediation needed.
56
56
 
57
57
  ---
58
58
 
@@ -90,7 +90,7 @@ Example:
90
90
 
91
91
  Use this when the operator needs to do something only the Cloudflare dashboard can do: sign in, switch accounts, add a site, edit an apex CNAME, verify zone nameservers, delete a tunnel after stopping its replicas. The guide has one numbered click-path per operation. Quote the relevant click-path verbatim — the operator follows it in the browser. The agent does not drive dashboard mutations via Playwright or Chrome DevTools.
92
92
 
93
- The single exception is `list-cf-domains.sh` (Task 589), which reads the domains attached to the logged-in account to populate the `cloudflare-setup-form` dropdowns. That script is deterministic (bash + raw CDP, no LLM in the decision path), invoked only by the `/api/admin/cloudflare/domains` route, and produces only a JSON `string[]` on stdout; no dashboard state is changed. Any dashboard scrape that is not this exact script is forbidden — the agent does not extend this carve-out to new scripts it writes, hypothesises, or finds. Adding a new sanctioned scrape surface requires a code change reviewed as a doctrine change, not an inline agent decision.
93
+ The single exception is `list-cf-domains.sh`, which reads the domains attached to the logged-in account to populate the `cloudflare-setup-form` dropdowns. That script is deterministic (bash + raw CDP, no LLM in the decision path), invoked only by the `/api/admin/cloudflare/domains` route, and produces only a JSON `string[]` on stdout; no dashboard state is changed. Any dashboard scrape that is not this exact script is forbidden — the agent does not extend this carve-out to new scripts it writes, hypothesises, or finds. Adding a new sanctioned scrape surface requires a code change reviewed as a doctrine change, not an inline agent decision.
94
94
 
95
95
  ---
96
96
 
@@ -30,7 +30,7 @@ When you submit, the `/api/admin/cloudflare/setup` endpoint runs — in strict o
30
30
  - `cloudflared tunnel route dns` for each subdomain hostname. Apex hostnames cannot be routed this way — the script prints an **ACTION REQUIRED** block naming the exact dashboard record to add or edit. Stream log emits `step=route-dns hostname=… tunnel_id=…` before the call and `step=route-dns hostname=… result=ok|apex-skip|error` after; on error the bounded cloudflared stderr (≤400 chars) rides in the same phase line. **The script does not parse cloudflared's stdout** — exit code is the sole decision signal, so all three legitimate cloudflared output shapes (new record, overwrite, idempotent "already configured") are treated as success.
31
31
  - `config.yml` and `tunnel.state` written under `${CFG_DIR}`.
32
32
  - **Step-7 onboarding completion persisted** — the script writes `${ACCOUNT_DIR}/onboarding/step7-complete` (a JSON marker with the completion timestamp and tunnel ID) before arming the restart. Stream log: `step=onboarding-persist result=ok|error reason=<r>`. The marker is consumed by the next admin session's first state read and advances `OnboardingState.currentStep` to 7. Without this, the service restart below would SIGTERM the admin agent before it could persist step-7 completion, and the next session would re-ask the Cloudflare question you just finished. Both invocation surfaces (the form-driven action and the agent-via-Bash path) declare `ACCOUNT_DIR` explicitly because `systemd-run --user` does not inherit parent env — when ACCOUNT_DIR isn't reaching the script you'll see `result=skipped reason=no-account-dir` in the stream log instead of `result=ok`.
33
- - **Post-restart resume contract** — when the form's ActionLogPanel reports `code=0`, the form dispatches the `admin-chat:post-restart-resume` window CustomEvent with `{actionId, message}`. The chat hook ([useAdminChat.ts](../../../../platform/ui/app/useAdminChat.ts)) waits for the brand-service down-then-up cycle on `/api/health`, calls `POST /api/admin/sessions/<cid>/resume?session_key=<sk>` (cookie-bridge-rehydrates the wiped sessionStore via the surviving `__remote_session` cookie and binds the conversation), then sends the captured "Cloudflare setup completed (actionId: <id>)." marker as a normal hidden chat POST that re-invokes the agent. No relay queue, no boot-drain, no banner, no rebind endpoint. Diagnostic: `grep '\[admin-resume\] reason=post-restart' ~/{configDir}/logs/server.log` (expect one line per restart cycle), `grep '\[client-event\] kind=post-restart-resume' ~/{configDir}/logs/server.log` for the operator-visible client trace. See `.docs/web-chat.md` "Post-restart resume contract" for the full client/server contract.
33
+ - **Post-restart resume contract** — when the script exits cleanly, the form fires a client-side resume event. The chat hook ([useAdminChat.ts](../../../../platform/ui/app/useAdminChat.ts)) waits for the brand-service to come back via `/api/health` (down-then-up), re-binds the conversation to the new server process, and sends the "Cloudflare setup completed" marker as a normal hidden chat POST that re-invokes the agent in a fresh session. No relay queue, no boot-drain, no banner. Diagnostic: `grep '\[admin-resume\] reason=post-restart' ~/{configDir}/logs/server.log` (expect one line per restart cycle), `grep '\[client-event\] kind=post-restart-resume' ~/{configDir}/logs/server.log` for the operator-visible client trace. See `.docs/web-chat.md` "Post-restart resume contract" for the full client/server contract.
34
34
  - `systemctl --user restart ${BRAND}.service` — restarts the platform service so the new tunnel spawns via the service's `ExecStartPre=resume-tunnel.sh`.
35
35
  - Post-restart verification — `ps -ef | grep '[c]loudflared'` confirms the connector is alive, then `curl -I https://<hostname>` against each subdomain (up to 60 s per host) confirms a non-530 response.
36
36