@rubytech/create-maxy-code 0.1.216 → 0.1.219

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 (64) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/mcp-eager/dist/index.d.ts +0 -55
  3. package/payload/platform/lib/mcp-eager/dist/index.d.ts.map +1 -1
  4. package/payload/platform/lib/mcp-eager/dist/index.js +0 -76
  5. package/payload/platform/lib/mcp-eager/dist/index.js.map +1 -1
  6. package/payload/platform/lib/mcp-eager/src/index.ts +0 -101
  7. package/payload/platform/plugins/admin/hooks/__tests__/hook-emit-stale-lock-ttl.test.sh +102 -0
  8. package/payload/platform/plugins/admin/hooks/__tests__/pre-tool-use-admin-tool-gate.test.sh +52 -112
  9. package/payload/platform/plugins/admin/hooks/lib/hook-emit.sh +29 -1
  10. package/payload/platform/plugins/admin/hooks/post-tool-use-agent.sh +20 -1
  11. package/payload/platform/plugins/admin/hooks/pre-tool-use.sh +0 -72
  12. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +11 -18
  13. package/payload/platform/plugins/browser/mcp/dist/index.js +19 -19
  14. package/payload/platform/plugins/browser/mcp/dist/index.js.map +1 -1
  15. package/payload/platform/plugins/docs/references/platform.md +10 -17
  16. package/payload/platform/plugins/email/mcp/dist/index.js +12 -12
  17. package/payload/platform/plugins/email/mcp/dist/index.js.map +1 -1
  18. package/payload/platform/plugins/memory/mcp/dist/index.js +8 -8
  19. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  20. package/payload/platform/plugins/url-get/mcp/dist/index.js +2 -2
  21. package/payload/platform/plugins/url-get/mcp/dist/index.js.map +1 -1
  22. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +0 -14
  23. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  24. package/payload/platform/services/claude-session-manager/dist/http-server.js +25 -69
  25. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  26. package/payload/platform/services/claude-session-manager/dist/index.js +12 -31
  27. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  28. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.d.ts +3 -0
  29. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.d.ts.map +1 -1
  30. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.js +55 -1
  31. package/payload/platform/services/claude-session-manager/dist/jsonl-tail.js.map +1 -1
  32. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +1 -1
  33. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +1 -1
  34. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +23 -17
  35. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  36. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +94 -73
  37. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  38. package/payload/platform/services/claude-session-manager/dist/rc-life.d.ts +4 -0
  39. package/payload/platform/services/claude-session-manager/dist/rc-life.d.ts.map +1 -0
  40. package/payload/platform/services/claude-session-manager/dist/rc-life.js +16 -0
  41. package/payload/platform/services/claude-session-manager/dist/rc-life.js.map +1 -0
  42. package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts +12 -46
  43. package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts.map +1 -1
  44. package/payload/platform/services/claude-session-manager/dist/systemd-scope.js +24 -73
  45. package/payload/platform/services/claude-session-manager/dist/systemd-scope.js.map +1 -1
  46. package/payload/platform/templates/agents/admin/IDENTITY.md +3 -3
  47. package/payload/premium-plugins/writer-craft/mcp/dist/index.d.ts.map +1 -1
  48. package/payload/premium-plugins/writer-craft/mcp/dist/index.js +4 -5
  49. package/payload/premium-plugins/writer-craft/mcp/dist/index.js.map +1 -1
  50. package/payload/premium-plugins/writer-craft/mcp/src/index.ts +4 -5
  51. package/payload/server/public/assets/AdminShell-BcxEZnpu.js +1 -0
  52. package/payload/server/public/assets/{admin-DjGGOx2K.js → admin-DMo5DWKH.js} +1 -1
  53. package/payload/server/public/assets/{data-WBEvtRV-.js → data-D6IQdm0R.js} +1 -1
  54. package/payload/server/public/assets/{graph-DsOLsIPf.js → graph-PkZKVkWD.js} +1 -1
  55. package/payload/server/public/data.html +2 -2
  56. package/payload/server/public/graph.html +2 -2
  57. package/payload/server/public/index.html +2 -2
  58. package/payload/server/server.js +0 -6
  59. package/payload/premium-plugins/writer-craft/lib/mcp-eager/dist/index.d.ts +0 -116
  60. package/payload/premium-plugins/writer-craft/lib/mcp-eager/dist/index.d.ts.map +0 -1
  61. package/payload/premium-plugins/writer-craft/lib/mcp-eager/dist/index.js +0 -125
  62. package/payload/premium-plugins/writer-craft/lib/mcp-eager/dist/index.js.map +0 -1
  63. package/payload/premium-plugins/writer-craft/lib/mcp-eager/package.json +0 -7
  64. package/payload/server/public/assets/AdminShell-CPWbJ_I8.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.216",
3
+ "version": "0.1.219",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -20,41 +20,6 @@
20
20
  */
21
21
  /** Wire-format metadata key the Claude Code SDK reads. */
22
22
  export declare const ALWAYS_LOAD_META_KEY: "anthropic/alwaysLoad";
23
- /**
24
- * Admin-seat detection. The rc-daemon exports `MAXY_SESSION_ROLE=admin` and
25
- * `MAXY_SPECIALIST=""` for the orchestrator process and its MCP plugin
26
- * children; specialist spawns set `MAXY_SPECIALIST=<name>`. A plugin process
27
- * inheriting the env can therefore decide at registration AND invocation
28
- * time whether it is loaded inside the admin seat.
29
- *
30
- * Mirrors `isAdminSeat` in platform/plugins/admin/mcp/src/skill-resolution.ts
31
- * — this is a duplicate at the library layer so plugins that cannot import
32
- * from the admin plugin (a sibling, not a dependency) share the same rule.
33
- */
34
- export declare function isAdminSeat(env?: NodeJS.ProcessEnv): boolean;
35
- /**
36
- * MCP refusal envelope returned when the admin seat invokes a specialist-
37
- * owned tool. The text names the dispatch route so the model's next action
38
- * is the `Agent` dispatch rather than a dead end.
39
- */
40
- export declare function adminBlockedResponse(toolName: string, owner: string): {
41
- content: {
42
- type: "text";
43
- text: string;
44
- }[];
45
- isError: true;
46
- };
47
- /**
48
- * Wrap a tool handler so any admin-seat invocation short-circuits with the
49
- * refusal envelope above and emits one `[admin-tool-gate]` line to stderr.
50
- *
51
- * Specialist seats pass through unchanged: this is the primitive Task 529
52
- * defines for closing the in-message authoring path that Task 516 could
53
- * not reach. The handler signature is preserved so existing call sites
54
- * substitute `eagerTool(... handler)` for `eagerTool(... withAdminGate(name,
55
- * owner, handler))` mechanically.
56
- */
57
- export declare function withAdminGate<H extends (...args: any[]) => any>(toolName: string, owner: string, handler: H): H;
58
23
  /**
59
24
  * Loose structural type for any object exposing a `registerTool` method. The
60
25
  * MCP SDK ships dual ESM/CJS packages with conditional exports, so importing
@@ -92,25 +57,5 @@ type LocalZodRawShape = Record<string, unknown>;
92
57
  * the consumer's own zod version) infers the handler args.
93
58
  */
94
59
  export declare function eagerTool<Args extends LocalZodRawShape>(server: EagerCapableServer, name: string, description: string, inputSchema: Args, handler: (...args: any[]) => any): void;
95
- /**
96
- * Loose structural type for the legacy four-arg `server.tool(name, desc,
97
- * schema, handler)` call shape — used when the admin seat must register a
98
- * specialist-owned tool as deferred (not eager) so it does not appear in
99
- * the admin's eager tool list.
100
- */
101
- type DeferredCapableServer = {
102
- tool: (name: string, description: string, inputSchema: any, handler: (...args: any[]) => any) => any;
103
- };
104
- /**
105
- * Register a tool that is eager for specialists but deferred + admin-gated
106
- * for the admin seat. The owning specialist name is named here so the
107
- * refusal envelope (and the `[admin-tool-gate]` log line) carries it.
108
- *
109
- * Use for every tool whose owner is a specialist (database-operator, writer-
110
- * craft, research-assistant, personal-assistant, content-producer). The
111
- * admin seat sees the tool only via ToolSearch — and any invocation from the
112
- * admin seat refuses with the dispatch hint.
113
- */
114
- export declare function eagerToolGated<Args extends LocalZodRawShape>(server: EagerCapableServer & DeferredCapableServer, name: string, owner: string, description: string, inputSchema: Args, handler: (...args: any[]) => any): void;
115
60
  export {};
116
61
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,0DAA0D;AAC1D,eAAO,MAAM,oBAAoB,EAAG,sBAA+B,CAAC;AAEpE;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,OAAO,CAEzE;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG;IACrE,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC1C,OAAO,EAAE,IAAI,CAAC;CACf,CAYA;AAED;;;;;;;;;GASG;AAEH,wBAAgB,aAAa,CAAC,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,EAC7D,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,CAAC,GACT,CAAC,CAYH;AAED;;;;;;;GAOG;AAEH,KAAK,kBAAkB,GAAG;IAAE,YAAY,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,CAAA;CAAE,CAAC;AAEpE;;;;;;;;;;GAUG;AACH,KAAK,gBAAgB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEhD;;;;;;;;;;;;GAYG;AACH,wBAAgB,SAAS,CAAC,IAAI,SAAS,gBAAgB,EACrD,MAAM,EAAE,kBAAkB,EAC1B,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,IAAI,EAEjB,OAAO,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,GAC/B,IAAI,CAUN;AAED;;;;;GAKG;AAEH,KAAK,qBAAqB,GAAG;IAAE,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,EAAE,GAAG,EAAE,OAAO,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,KAAK,GAAG,CAAA;CAAE,CAAC;AAEtI;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAAC,IAAI,SAAS,gBAAgB,EAC1D,MAAM,EAAE,kBAAkB,GAAG,qBAAqB,EAClD,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,IAAI,EAEjB,OAAO,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,GAC/B,IAAI,CAMN"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,0DAA0D;AAC1D,eAAO,MAAM,oBAAoB,EAAG,sBAA+B,CAAC;AAEpE;;;;;;;GAOG;AAEH,KAAK,kBAAkB,GAAG;IAAE,YAAY,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,CAAA;CAAE,CAAC;AAEpE;;;;;;;;;;GAUG;AACH,KAAK,gBAAgB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEhD;;;;;;;;;;;;GAYG;AACH,wBAAgB,SAAS,CAAC,IAAI,SAAS,gBAAgB,EACrD,MAAM,EAAE,kBAAkB,EAC1B,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,IAAI,EAEjB,OAAO,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,GAC/B,IAAI,CAUN"}
@@ -1,11 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ALWAYS_LOAD_META_KEY = void 0;
4
- exports.isAdminSeat = isAdminSeat;
5
- exports.adminBlockedResponse = adminBlockedResponse;
6
- exports.withAdminGate = withAdminGate;
7
4
  exports.eagerTool = eagerTool;
8
- exports.eagerToolGated = eagerToolGated;
9
5
  /**
10
6
  * MCP eager-load helper.
11
7
  *
@@ -28,59 +24,6 @@ exports.eagerToolGated = eagerToolGated;
28
24
  */
29
25
  /** Wire-format metadata key the Claude Code SDK reads. */
30
26
  exports.ALWAYS_LOAD_META_KEY = "anthropic/alwaysLoad";
31
- /**
32
- * Admin-seat detection. The rc-daemon exports `MAXY_SESSION_ROLE=admin` and
33
- * `MAXY_SPECIALIST=""` for the orchestrator process and its MCP plugin
34
- * children; specialist spawns set `MAXY_SPECIALIST=<name>`. A plugin process
35
- * inheriting the env can therefore decide at registration AND invocation
36
- * time whether it is loaded inside the admin seat.
37
- *
38
- * Mirrors `isAdminSeat` in platform/plugins/admin/mcp/src/skill-resolution.ts
39
- * — this is a duplicate at the library layer so plugins that cannot import
40
- * from the admin plugin (a sibling, not a dependency) share the same rule.
41
- */
42
- function isAdminSeat(env = process.env) {
43
- return env.MAXY_SESSION_ROLE === "admin" && !(env.MAXY_SPECIALIST && env.MAXY_SPECIALIST.length > 0);
44
- }
45
- /**
46
- * MCP refusal envelope returned when the admin seat invokes a specialist-
47
- * owned tool. The text names the dispatch route so the model's next action
48
- * is the `Agent` dispatch rather than a dead end.
49
- */
50
- function adminBlockedResponse(toolName, owner) {
51
- return {
52
- content: [
53
- {
54
- type: "text",
55
- text: `Tool \`${toolName}\` is owned by the \`${owner}\` specialist and is not callable from the admin seat. ` +
56
- `Dispatch \`${owner}\` with the Agent tool and pass the work as the brief.`,
57
- },
58
- ],
59
- isError: true,
60
- };
61
- }
62
- /**
63
- * Wrap a tool handler so any admin-seat invocation short-circuits with the
64
- * refusal envelope above and emits one `[admin-tool-gate]` line to stderr.
65
- *
66
- * Specialist seats pass through unchanged: this is the primitive Task 529
67
- * defines for closing the in-message authoring path that Task 516 could
68
- * not reach. The handler signature is preserved so existing call sites
69
- * substitute `eagerTool(... handler)` for `eagerTool(... withAdminGate(name,
70
- * owner, handler))` mechanically.
71
- */
72
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
73
- function withAdminGate(toolName, owner, handler) {
74
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
75
- const wrapped = ((...args) => {
76
- if (isAdminSeat(process.env)) {
77
- process.stderr.write(`[admin-tool-gate] role=admin tool=${toolName} decision=block reason=specialist-owned owner=${owner}\n`);
78
- return adminBlockedResponse(toolName, owner);
79
- }
80
- return handler(...args);
81
- });
82
- return wrapped;
83
- }
84
27
  /**
85
28
  * Register an MCP tool that the Claude Code SDK eager-loads into the
86
29
  * model's prompt at session boot, bypassing the ToolSearch round-trip.
@@ -103,23 +46,4 @@ handler) {
103
46
  _meta: { [exports.ALWAYS_LOAD_META_KEY]: true },
104
47
  }, handler);
105
48
  }
106
- /**
107
- * Register a tool that is eager for specialists but deferred + admin-gated
108
- * for the admin seat. The owning specialist name is named here so the
109
- * refusal envelope (and the `[admin-tool-gate]` log line) carries it.
110
- *
111
- * Use for every tool whose owner is a specialist (database-operator, writer-
112
- * craft, research-assistant, personal-assistant, content-producer). The
113
- * admin seat sees the tool only via ToolSearch — and any invocation from the
114
- * admin seat refuses with the dispatch hint.
115
- */
116
- function eagerToolGated(server, name, owner, description, inputSchema,
117
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
118
- handler) {
119
- if (isAdminSeat(process.env)) {
120
- server.tool(name, description, inputSchema, withAdminGate(name, owner, handler));
121
- return;
122
- }
123
- eagerTool(server, name, description, inputSchema, handler);
124
- }
125
49
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AAkCA,kCAEC;AAOD,oDAeC;AAaD,sCAgBC;AAuCD,8BAiBC;AAqBD,wCAcC;AAlLD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,0DAA0D;AAC7C,QAAA,oBAAoB,GAAG,sBAA+B,CAAC;AAEpE;;;;;;;;;;GAUG;AACH,SAAgB,WAAW,CAAC,MAAyB,OAAO,CAAC,GAAG;IAC9D,OAAO,GAAG,CAAC,iBAAiB,KAAK,OAAO,IAAI,CAAC,CAAC,GAAG,CAAC,eAAe,IAAI,GAAG,CAAC,eAAe,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;AACvG,CAAC;AAED;;;;GAIG;AACH,SAAgB,oBAAoB,CAAC,QAAgB,EAAE,KAAa;IAIlE,OAAO;QACL,OAAO,EAAE;YACP;gBACE,IAAI,EAAE,MAAM;gBACZ,IAAI,EACF,UAAU,QAAQ,wBAAwB,KAAK,yDAAyD;oBACxG,cAAc,KAAK,wDAAwD;aAC9E;SACF;QACD,OAAO,EAAE,IAAI;KACd,CAAC;AACJ,CAAC;AAED;;;;;;;;;GASG;AACH,8DAA8D;AAC9D,SAAgB,aAAa,CAC3B,QAAgB,EAChB,KAAa,EACb,OAAU;IAEV,8DAA8D;IAC9D,MAAM,OAAO,GAAG,CAAC,CAAC,GAAG,IAAW,EAAE,EAAE;QAClC,IAAI,WAAW,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC;YAC7B,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,qCAAqC,QAAQ,iDAAiD,KAAK,IAAI,CACxG,CAAC;YACF,OAAO,oBAAoB,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;QAC/C,CAAC;QACD,OAAO,OAAO,CAAC,GAAG,IAAI,CAAC,CAAC;IAC1B,CAAC,CAAiB,CAAC;IACnB,OAAO,OAAO,CAAC;AACjB,CAAC;AA0BD;;;;;;;;;;;;GAYG;AACH,SAAgB,SAAS,CACvB,MAA0B,EAC1B,IAAY,EACZ,WAAmB,EACnB,WAAiB;AACjB,8DAA8D;AAC9D,OAAgC;IAEhC,MAAM,CAAC,YAAY,CACjB,IAAI,EACJ;QACE,WAAW;QACX,WAAW;QACX,KAAK,EAAE,EAAE,CAAC,4BAAoB,CAAC,EAAE,IAAI,EAAE;KACxC,EACD,OAAO,CACR,CAAC;AACJ,CAAC;AAWD;;;;;;;;;GASG;AACH,SAAgB,cAAc,CAC5B,MAAkD,EAClD,IAAY,EACZ,KAAa,EACb,WAAmB,EACnB,WAAiB;AACjB,8DAA8D;AAC9D,OAAgC;IAEhC,IAAI,WAAW,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC;QAC7B,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,WAAW,EAAE,WAAW,EAAE,aAAa,CAAC,IAAI,EAAE,KAAK,EAAE,OAAO,CAAC,CAAC,CAAC;QACjF,OAAO;IACT,CAAC;IACD,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,WAAW,EAAE,OAAO,CAAC,CAAC;AAC7D,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AA4DA,8BAiBC;AA7ED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,0DAA0D;AAC7C,QAAA,oBAAoB,GAAG,sBAA+B,CAAC;AA0BpE;;;;;;;;;;;;GAYG;AACH,SAAgB,SAAS,CACvB,MAA0B,EAC1B,IAAY,EACZ,WAAmB,EACnB,WAAiB;AACjB,8DAA8D;AAC9D,OAAgC;IAEhC,MAAM,CAAC,YAAY,CACjB,IAAI,EACJ;QACE,WAAW;QACX,WAAW;QACX,KAAK,EAAE,EAAE,CAAC,4BAAoB,CAAC,EAAE,IAAI,EAAE;KACxC,EACD,OAAO,CACR,CAAC;AACJ,CAAC"}
@@ -21,72 +21,6 @@
21
21
  /** Wire-format metadata key the Claude Code SDK reads. */
22
22
  export const ALWAYS_LOAD_META_KEY = "anthropic/alwaysLoad" as const;
23
23
 
24
- /**
25
- * Admin-seat detection. The rc-daemon exports `MAXY_SESSION_ROLE=admin` and
26
- * `MAXY_SPECIALIST=""` for the orchestrator process and its MCP plugin
27
- * children; specialist spawns set `MAXY_SPECIALIST=<name>`. A plugin process
28
- * inheriting the env can therefore decide at registration AND invocation
29
- * time whether it is loaded inside the admin seat.
30
- *
31
- * Mirrors `isAdminSeat` in platform/plugins/admin/mcp/src/skill-resolution.ts
32
- * — this is a duplicate at the library layer so plugins that cannot import
33
- * from the admin plugin (a sibling, not a dependency) share the same rule.
34
- */
35
- export function isAdminSeat(env: NodeJS.ProcessEnv = process.env): boolean {
36
- return env.MAXY_SESSION_ROLE === "admin" && !(env.MAXY_SPECIALIST && env.MAXY_SPECIALIST.length > 0);
37
- }
38
-
39
- /**
40
- * MCP refusal envelope returned when the admin seat invokes a specialist-
41
- * owned tool. The text names the dispatch route so the model's next action
42
- * is the `Agent` dispatch rather than a dead end.
43
- */
44
- export function adminBlockedResponse(toolName: string, owner: string): {
45
- content: { type: "text"; text: string }[];
46
- isError: true;
47
- } {
48
- return {
49
- content: [
50
- {
51
- type: "text",
52
- text:
53
- `Tool \`${toolName}\` is owned by the \`${owner}\` specialist and is not callable from the admin seat. ` +
54
- `Dispatch \`${owner}\` with the Agent tool and pass the work as the brief.`,
55
- },
56
- ],
57
- isError: true,
58
- };
59
- }
60
-
61
- /**
62
- * Wrap a tool handler so any admin-seat invocation short-circuits with the
63
- * refusal envelope above and emits one `[admin-tool-gate]` line to stderr.
64
- *
65
- * Specialist seats pass through unchanged: this is the primitive Task 529
66
- * defines for closing the in-message authoring path that Task 516 could
67
- * not reach. The handler signature is preserved so existing call sites
68
- * substitute `eagerTool(... handler)` for `eagerTool(... withAdminGate(name,
69
- * owner, handler))` mechanically.
70
- */
71
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
72
- export function withAdminGate<H extends (...args: any[]) => any>(
73
- toolName: string,
74
- owner: string,
75
- handler: H,
76
- ): H {
77
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
78
- const wrapped = ((...args: any[]) => {
79
- if (isAdminSeat(process.env)) {
80
- process.stderr.write(
81
- `[admin-tool-gate] role=admin tool=${toolName} decision=block reason=specialist-owned owner=${owner}\n`,
82
- );
83
- return adminBlockedResponse(toolName, owner);
84
- }
85
- return handler(...args);
86
- }) as unknown as H;
87
- return wrapped;
88
- }
89
-
90
24
  /**
91
25
  * Loose structural type for any object exposing a `registerTool` method. The
92
26
  * MCP SDK ships dual ESM/CJS packages with conditional exports, so importing
@@ -142,38 +76,3 @@ export function eagerTool<Args extends LocalZodRawShape>(
142
76
  handler,
143
77
  );
144
78
  }
145
-
146
- /**
147
- * Loose structural type for the legacy four-arg `server.tool(name, desc,
148
- * schema, handler)` call shape — used when the admin seat must register a
149
- * specialist-owned tool as deferred (not eager) so it does not appear in
150
- * the admin's eager tool list.
151
- */
152
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
153
- type DeferredCapableServer = { tool: (name: string, description: string, inputSchema: any, handler: (...args: any[]) => any) => any };
154
-
155
- /**
156
- * Register a tool that is eager for specialists but deferred + admin-gated
157
- * for the admin seat. The owning specialist name is named here so the
158
- * refusal envelope (and the `[admin-tool-gate]` log line) carries it.
159
- *
160
- * Use for every tool whose owner is a specialist (database-operator, writer-
161
- * craft, research-assistant, personal-assistant, content-producer). The
162
- * admin seat sees the tool only via ToolSearch — and any invocation from the
163
- * admin seat refuses with the dispatch hint.
164
- */
165
- export function eagerToolGated<Args extends LocalZodRawShape>(
166
- server: EagerCapableServer & DeferredCapableServer,
167
- name: string,
168
- owner: string,
169
- description: string,
170
- inputSchema: Args,
171
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
172
- handler: (...args: any[]) => any,
173
- ): void {
174
- if (isAdminSeat(process.env)) {
175
- server.tool(name, description, inputSchema, withAdminGate(name, owner, handler));
176
- return;
177
- }
178
- eagerTool(server, name, description, inputSchema, handler);
179
- }
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env bash
2
+ # Task 569 — stale-lockdir TTL reclamation in _hook_acquire_lock and
3
+ # _drain_acquire_lock. A backdated lockdir is force-removed on acquire;
4
+ # a freshly held lockdir is not.
5
+ set -u
6
+
7
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
8
+ LIB="$ROOT/lib/hook-emit.sh"
9
+ DRAIN="$ROOT/post-tool-use-agent.sh"
10
+ [[ -f "$LIB" ]] || { echo "FAIL: $LIB missing"; exit 1; }
11
+ [[ -f "$DRAIN" ]] || { echo "FAIL: $DRAIN missing"; exit 1; }
12
+
13
+ TMP=$(mktemp -d)
14
+ export MAXY_HOOK_BUFFER_DIR="$TMP/buffers"
15
+ export MAXY_HOOK_SERVER_LOG="$TMP/server.log"
16
+ # Tight test budget — 20 × 10ms = 200ms ceiling so the negative case
17
+ # returns quickly.
18
+ export MAXY_HOOK_LOCK_ATTEMPTS=20
19
+ export MAXY_HOOK_LOCK_SLEEP=0.01
20
+ export MAXY_HOOK_LOCK_TTL_SECONDS=2
21
+ mkdir -p "$MAXY_HOOK_BUFFER_DIR"
22
+
23
+ PASS=0; FAIL=0
24
+ pass() { PASS=$((PASS+1)); echo "PASS: $1"; }
25
+ fail() { FAIL=$((FAIL+1)); echo "FAIL: $1" >&2; }
26
+
27
+ # shellcheck disable=SC1090
28
+ source "$LIB" || { fail "source library"; exit 1; }
29
+ pass "source library"
30
+
31
+ # Test 1: a stale lockdir (mtime backdated 10s; TTL=2s) is reclaimed and
32
+ # acquire succeeds.
33
+ LOCK1="$TMP/lock-stale"
34
+ mkdir "$LOCK1"
35
+ # Backdate mtime by 10s. -t needs YYYYMMDDhhmm.SS form; portable form
36
+ # uses -d on GNU and -A/-r on BSD, so we use python to set it.
37
+ python3 -c "import os,time; os.utime('$LOCK1', (time.time()-10, time.time()-10))"
38
+ if _hook_acquire_lock "$LOCK1"; then
39
+ pass "stale lockdir reclaimed (emit-path)"
40
+ else
41
+ fail "stale lockdir not reclaimed within budget (emit-path)"
42
+ fi
43
+ # Verify observability line.
44
+ if grep -q "\[hook-propagate-error\] reason=stale-lock-reclaimed lockdir=$LOCK1" "$MAXY_HOOK_SERVER_LOG"; then
45
+ pass "stale-lock-reclaimed observability line emitted"
46
+ else
47
+ fail "stale-lock-reclaimed line missing from server.log"
48
+ fi
49
+ rmdir "$LOCK1" 2>/dev/null || true
50
+
51
+ # Test 2: a fresh lockdir (mtime = now) is NOT reclaimed and acquire
52
+ # returns 1 within the attempt budget.
53
+ LOCK2="$TMP/lock-fresh"
54
+ mkdir "$LOCK2"
55
+ START=$(date +%s)
56
+ if _hook_acquire_lock "$LOCK2"; then
57
+ fail "fresh lockdir wrongly reclaimed (emit-path)"
58
+ else
59
+ END=$(date +%s)
60
+ pass "fresh lockdir not reclaimed; acquire timed out (emit-path, ${END}-${START}s)"
61
+ fi
62
+ # Verify no stale-lock-reclaimed line was written for LOCK2.
63
+ if grep -q "lockdir=$LOCK2 ageSeconds=" "$MAXY_HOOK_SERVER_LOG"; then
64
+ fail "fresh lockdir wrongly logged as stale (emit-path)"
65
+ else
66
+ pass "no stale-lock log for fresh lockdir (emit-path)"
67
+ fi
68
+ rmdir "$LOCK2" 2>/dev/null || true
69
+
70
+ # Test 3: same property for the drainer-side function. Extract
71
+ # _drain_acquire_lock + its age helper from post-tool-use-agent.sh and
72
+ # source them in isolation.
73
+ DRAIN_LIB="$TMP/drain-lock.sh"
74
+ awk '
75
+ /^_drain_lockdir_age_seconds\(\)/,/^}/ { print; next }
76
+ /^_drain_acquire_lock\(\)/,/^}/ { print; next }
77
+ ' "$DRAIN" > "$DRAIN_LIB"
78
+ [[ -s "$DRAIN_LIB" ]] || { fail "drain lock extract empty"; }
79
+ # shellcheck disable=SC1090
80
+ source "$DRAIN_LIB" || fail "source drain lib"
81
+
82
+ LOCK3="$TMP/lock-drain-stale"
83
+ mkdir "$LOCK3"
84
+ python3 -c "import os,time; os.utime('$LOCK3', (time.time()-10, time.time()-10))"
85
+ if _drain_acquire_lock "$LOCK3"; then
86
+ pass "stale lockdir reclaimed (drain-path)"
87
+ else
88
+ fail "stale lockdir not reclaimed (drain-path)"
89
+ fi
90
+
91
+ LOCK4="$TMP/lock-drain-fresh"
92
+ mkdir "$LOCK4"
93
+ if _drain_acquire_lock "$LOCK4"; then
94
+ fail "fresh lockdir wrongly reclaimed (drain-path)"
95
+ else
96
+ pass "fresh lockdir not reclaimed; acquire timed out (drain-path)"
97
+ fi
98
+
99
+ rm -rf "$TMP"
100
+ echo "----"
101
+ echo "$PASS pass, $FAIL fail"
102
+ [[ "$FAIL" -eq 0 ]]
@@ -1,17 +1,17 @@
1
1
  #!/usr/bin/env bash
2
- # Task 529admin-tool-gate enforces orchestration-only at the PreToolUse
3
- # hook. Admin-direct invocations of Write/Edit/MultiEdit/Bash/WebFetch/
4
- # WebSearch are refused with the dispatch hint; subagents pass through.
2
+ # Task 571the orchestration-only block has been removed. Admin's standard
3
+ # tool surface (Write/Edit/MultiEdit/Bash/WebFetch/WebSearch) is callable
4
+ # directly from the admin seat; delegation is a doctrine bias enforced in
5
+ # IDENTITY.md, not a platform refusal. This test pins the post-571 contract:
5
6
  #
6
- # Task 559 discriminator is `parent_tool_use_id` from the Claude Code
7
- # hook input. Non-empty in-window Task subagent (allow).
7
+ # - Admin-direct Bash/Edit/Write/WebFetch all return exit 0 with no
8
+ # `reason=orchestration-only` or `reason=specialist-owned` line.
9
+ # - The resource-specific hook blocks (code-dir, entitlement, account.json,
10
+ # pending-actions, base64-guard, approval-queue) are unaffected and live
11
+ # in their own dedicated tests.
8
12
  #
9
- # Task 566 discriminator extended to `$MAXY_SPECIALIST` for maxy MCP
10
- # `Agent`-spawned specialists (separate top-level `claude rc` processes
11
- # with no `parent_tool_use_id` but `MAXY_SPECIALIST=<name>` on env, set
12
- # by pty-spawner.ts:1601). Calls with neither marker AND no
13
- # `MAXY_SESSION_ROLE=admin` fail closed with `reason=missing-marker` so a
14
- # future spawn shape cannot silently regress.
13
+ # Reintroducing the orchestration-only block requires a doctrine flip this
14
+ # test fails first and surfaces the change loudly.
15
15
 
16
16
  set -u
17
17
 
@@ -33,135 +33,75 @@ PASS=0; FAIL=0
33
33
  pass() { PASS=$((PASS+1)); echo "PASS: $1"; }
34
34
  fail() { FAIL=$((FAIL+1)); echo "FAIL: $1" >&2; }
35
35
 
36
- # The hook receives the parent session's transcript_path even on subagent
37
- # calls in Claude Code 2.1.x. The fixture mirrors production.
38
36
  SESSION_TRANSCRIPT="/tmp/sess/44283ae1-2b82-4bd7-b8af-99a11141ed4b.jsonl"
39
- SUBAGENT_PARENT_ID="toolu_01QFC4W32wFUyHy6WJMNjHf6"
40
37
 
41
- # run <tool> <parent_id> <expected_rc> <expected_pattern> <label>
42
- # Env knobs (default mirror production admin spawn):
43
- # MAXY_SESSION_ROLE_OVERRIDE defaults to "admin"; set empty to drop the role
44
- # MAXY_SPECIALIST_OVERRIDE — defaults to empty; set to a name to simulate
45
- # an MCP-Agent-spawned specialist
46
- run() {
47
- local tool="$1" parent_id="$2" expected_rc="$3" expected_pattern="$4" label="$5"
48
- local role="${MAXY_SESSION_ROLE_OVERRIDE-admin}"
49
- local specialist="${MAXY_SPECIALIST_OVERRIDE-}"
38
+ # run_admin_direct <tool> <expected_rc> <label>
39
+ # Pipes a minimal PreToolUse payload to the hook with MAXY_SESSION_ROLE=admin
40
+ # (the realistic admin spawn shape) and asserts the rc and that no
41
+ # orchestration-only / specialist-owned refusal line appears on stderr.
42
+ run_admin_direct() {
43
+ local tool="$1" expected_rc="$2" label="$3"
50
44
  local input_json
51
45
  input_json=$(python3 -c '
52
46
  import json, sys
53
- payload = {
47
+ print(json.dumps({
54
48
  "hook_event_name": "PreToolUse",
55
49
  "tool_name": sys.argv[1],
56
- "tool_input": {"file_path": "/tmp/x", "content": "x", "command": "echo hi"},
50
+ "tool_input": {"file_path": "/tmp/x", "content": "x", "command": "echo hi", "url": "https://example.com"},
57
51
  "transcript_path": sys.argv[2],
58
- }
59
- parent = sys.argv[3]
60
- if parent:
61
- payload["parent_tool_use_id"] = parent
62
- print(json.dumps(payload, separators=(",", ":")))
63
- ' "$tool" "$SESSION_TRANSCRIPT" "$parent_id")
52
+ }, separators=(",", ":")))
53
+ ' "$tool" "$SESSION_TRANSCRIPT")
64
54
  local stdout_file; stdout_file=$(mktemp); TMPFILES+=("$stdout_file")
65
55
  local stderr_file; stderr_file=$(mktemp); TMPFILES+=("$stderr_file")
66
56
  printf '%s' "$input_json" | \
67
- env -u MAXY_SESSION_ROLE -u MAXY_SPECIALIST \
68
- MAXY_SESSION_ROLE="$role" MAXY_SPECIALIST="$specialist" \
57
+ env -u MAXY_SPECIALIST MAXY_SESSION_ROLE=admin \
69
58
  bash "$HOOK" admin >"$stdout_file" 2>"$stderr_file"
70
59
  local rc=$?
71
60
  if [[ "$rc" -ne "$expected_rc" ]]; then
72
61
  fail "$label: expected exit $expected_rc, got $rc. Stderr: $(cat "$stderr_file")"
73
62
  return
74
63
  fi
75
- if [[ -n "$expected_pattern" ]] && ! grep -qE "$expected_pattern" "$stderr_file"; then
76
- fail "$label: stderr missing pattern '$expected_pattern'. Got: $(cat "$stderr_file")"
64
+ if grep -qE 'reason=orchestration-only|reason=specialist-owned|reason=missing-marker' "$stderr_file"; then
65
+ fail "$label: stderr carries a removed-gate reason line. Got: $(cat "$stderr_file")"
77
66
  return
78
67
  fi
79
68
  pass "$label"
80
69
  }
81
70
 
82
- # ── Admin-direct refusals (no parent_tool_use_id, no MAXY_SPECIALIST, role=admin) ───
83
- run Write "" 2 '\[admin-tool-gate\] role=admin parent_tool_use_id=- specialist=- session_role=admin tool=Write decision=block.*owner=content-producer' \
84
- "Test 1: admin-direct Write rejected with content-producer dispatch hint"
85
- run Edit "" 2 '\[admin-tool-gate\] role=admin .*tool=Edit decision=block.*owner=content-producer' \
86
- "Test 2: admin-direct Edit rejected"
87
- run MultiEdit "" 2 '\[admin-tool-gate\] role=admin .*tool=MultiEdit decision=block.*owner=content-producer' \
88
- "Test 3: admin-direct MultiEdit rejected"
89
- run Bash "" 2 '\[admin-tool-gate\] role=admin .*tool=Bash decision=block' \
90
- "Test 4: admin-direct Bash rejected"
91
- run WebFetch "" 2 '\[admin-tool-gate\] role=admin .*tool=WebFetch decision=block.*owner=research-assistant' \
92
- "Test 5: admin-direct WebFetch rejected"
93
- run WebSearch "" 2 '\[admin-tool-gate\] role=admin .*tool=WebSearch decision=block.*owner=research-assistant' \
94
- "Test 6: admin-direct WebSearch rejected"
95
-
96
- # ── In-window subagent passthrough (parent_tool_use_id non-empty) ───────
97
- run Write "$SUBAGENT_PARENT_ID" 0 "\[admin-tool-gate\] role=subagent parent_tool_use_id=${SUBAGENT_PARENT_ID} .*tool=Write decision=allow" \
98
- "Test 7: subagent Write passes through"
99
- run Edit "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*tool=Edit decision=allow' \
100
- "Test 8: subagent Edit passes through"
101
- run Bash "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*tool=Bash decision=allow' \
102
- "Test 9: subagent Bash passes through (Task 559 regression — coding-assistant git pull)"
103
- run WebFetch "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*tool=WebFetch decision=allow' \
104
- "Test 10: subagent WebFetch passes through"
105
- run MultiEdit "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*tool=MultiEdit decision=allow' \
106
- "Test 11: subagent MultiEdit passes through"
107
-
108
- # ── Unrelated tools untouched ───────────────────────────────────────────
109
- run Read "" 0 '' \
110
- "Test 12: admin-direct Read passes (Read is admin-keep-list)"
111
-
112
- # ── Task 560: admin-direct block emits propagation record ──────────────
113
- # Admin-direct path: MAXY_SESSION_ROLE=admin, no MAXY_SPECIALIST, no parent.
114
- PROP_TMPDIR=$(mktemp -d); TMPFILES+=("$PROP_TMPDIR")
115
- export MAXY_HOOK_BUFFER_DIR="$PROP_TMPDIR/buffers"
116
- export MAXY_HOOK_SERVER_LOG="$PROP_TMPDIR/server.log"
117
- SESSION="task560-test-sess"
118
- PROP_INPUT=$(python3 -c '
71
+ # ── Admin-direct passthrough (the Task 571 regression boundary) ──────────
72
+ run_admin_direct Bash 0 "Test 1: admin-direct Bash passes through"
73
+ run_admin_direct Edit 0 "Test 2: admin-direct Edit passes through (path /tmp/x is outside protected dirs)"
74
+ run_admin_direct Write 0 "Test 3: admin-direct Write passes through"
75
+ run_admin_direct MultiEdit 0 "Test 4: admin-direct MultiEdit passes through"
76
+ run_admin_direct WebFetch 0 "Test 5: admin-direct WebFetch passes through"
77
+ run_admin_direct WebSearch 0 "Test 6: admin-direct WebSearch passes through"
78
+ run_admin_direct Read 0 "Test 7: admin-direct Read passes through"
79
+
80
+ # ── Resource-specific blocks unaffected: protected code-dir Write still refuses ──
81
+ # Verifies the deletion was surgical — code-dir-protection-ui still fires.
82
+ PROTECTED_INPUT=$(python3 -c '
119
83
  import json, sys
120
- print(json.dumps({"hook_event_name":"PreToolUse","tool_name":"Bash",
121
- "tool_input":{"command":"ls"},"transcript_path":sys.argv[1],
122
- "session_id":sys.argv[2]}, separators=(",",":")))
123
- ' "$SESSION_TRANSCRIPT" "$SESSION")
124
- printf '%s' "$PROP_INPUT" | \
84
+ print(json.dumps({
85
+ "hook_event_name": "PreToolUse",
86
+ "tool_name": "Write",
87
+ "tool_input": {"file_path": "/srv/maxy/platform/ui/app/page.tsx", "content": "x"},
88
+ "transcript_path": sys.argv[1],
89
+ }, separators=(",", ":")))
90
+ ' "$SESSION_TRANSCRIPT")
91
+ PROT_STDOUT=$(mktemp); TMPFILES+=("$PROT_STDOUT")
92
+ PROT_STDERR=$(mktemp); TMPFILES+=("$PROT_STDERR")
93
+ printf '%s' "$PROTECTED_INPUT" | \
125
94
  env -u MAXY_SPECIALIST MAXY_SESSION_ROLE=admin \
126
- bash "$HOOK" admin >/dev/null 2>/dev/null
127
- BUF="$MAXY_HOOK_BUFFER_DIR/$SESSION.jsonl"
128
- if [[ -f "$BUF" ]] && grep -q '"hookName":"admin-tool-gate"' "$BUF" \
129
- && grep -q '"decision":"block"' "$BUF" \
130
- && grep -q '"reason":"orchestration-only"' "$BUF"; then
131
- pass "Test 13b: admin-direct Bash block emits Task 560 propagation record"
95
+ bash "$HOOK" admin >"$PROT_STDOUT" 2>"$PROT_STDERR"
96
+ PROT_RC=$?
97
+ if [[ "$PROT_RC" -eq 2 ]] && grep -q 'cannot modify platform UI source' "$PROT_STDERR"; then
98
+ pass "Test 8: admin-direct Write to platform/ui/ still blocked (resource-specific block intact)"
132
99
  else
133
- fail "Test 13b: propagation record missing/malformed $(cat "$BUF" 2>/dev/null || echo '<no buffer>')"
100
+ fail "Test 8: expected platform/ui code-dir block, got rc=$PROT_RC stdout=$(cat "$PROT_STDOUT") stderr=$(cat "$PROT_STDERR")"
134
101
  fi
135
- unset MAXY_HOOK_BUFFER_DIR MAXY_HOOK_SERVER_LOG
136
-
137
- # ── Task 566: maxy-MCP-Agent-spawned specialist passthrough ────────────
138
- # MCP-Agent specialists are separate top-level claude rc processes with no
139
- # parent_tool_use_id; pty-spawner stamps MAXY_SPECIALIST=<name> on the env.
140
- MAXY_SPECIALIST_OVERRIDE=content-producer \
141
- run Edit "" 0 '\[admin-tool-gate\] role=subagent .*specialist=content-producer .*tool=Edit decision=allow source=mcp-specialist' \
142
- "Test 14: mcp-specialist Edit passes through (Task 566)"
143
-
144
- MAXY_SPECIALIST_OVERRIDE=coding-assistant \
145
- run Bash "" 0 '\[admin-tool-gate\] role=subagent .*specialist=coding-assistant .*tool=Bash decision=allow source=mcp-specialist' \
146
- "Test 15: mcp-specialist Bash passes through (Task 559 regression class)"
147
-
148
- MAXY_SPECIALIST_OVERRIDE=research-assistant \
149
- run WebFetch "" 0 '\[admin-tool-gate\] role=subagent .*specialist=research-assistant .*tool=WebFetch decision=allow source=mcp-specialist' \
150
- "Test 16: mcp-specialist WebFetch passes through"
151
-
152
- # ── Task 566: missing-marker fail-closed ───────────────────────────────
153
- # No parent_tool_use_id, no MAXY_SPECIALIST, MAXY_SESSION_ROLE not 'admin'.
154
- MAXY_SESSION_ROLE_OVERRIDE="" MAXY_SPECIALIST_OVERRIDE="" \
155
- run Edit "" 2 '\[admin-tool-gate\] role=unknown .*tool=Edit decision=block reason=missing-marker' \
156
- "Test 17: missing-marker fail-closed (no positive role marker)"
157
-
158
- # ── Task 566: combined parent_tool_use_id + MAXY_SPECIALIST is also subagent ──
159
- MAXY_SPECIALIST_OVERRIDE=database-operator \
160
- run Bash "$SUBAGENT_PARENT_ID" 0 '\[admin-tool-gate\] role=subagent .*specialist=database-operator .*tool=Bash decision=allow source=task-subagent\+mcp-specialist' \
161
- "Test 18: combined parent + specialist resolves as task-subagent+mcp-specialist"
162
102
 
163
103
  echo
164
- echo "──────── pre-tool-use admin-tool-gate test summary ────────"
104
+ echo "──────── pre-tool-use admin-tool-gate test summary (post-Task 571) ────────"
165
105
  echo "PASS: $PASS"
166
106
  echo "FAIL: $FAIL"
167
107
 
@@ -14,11 +14,39 @@
14
14
  # (tests) without flock(1).
15
15
  : "${MAXY_HOOK_LOCK_ATTEMPTS:=100}"
16
16
  : "${MAXY_HOOK_LOCK_SLEEP:=0.01}"
17
+ # Task 569 — TTL for orphaned lockdirs left behind when a holder dies
18
+ # between `mkdir lock` and `rmdir lock` (SIGKILL, OOM, systemd restart).
19
+ # Default 5s is far longer than the legitimate hold (microseconds for a
20
+ # rename or append) and shorter than operator-noticeable propagation lag.
21
+ : "${MAXY_HOOK_LOCK_TTL_SECONDS:=5}"
22
+
23
+ # _hook_lockdir_age_seconds <lockdir> — prints integer age in seconds, or
24
+ # -1 if the lockdir cannot be stat'd. Portable across Linux (`stat -c`)
25
+ # and macOS (`stat -f`).
26
+ _hook_lockdir_age_seconds() {
27
+ local lockdir="$1" mtime now
28
+ mtime=$(stat -c %Y "$lockdir" 2>/dev/null || stat -f %m "$lockdir" 2>/dev/null)
29
+ [[ -z "$mtime" ]] && { echo -1; return; }
30
+ now=$(date +%s)
31
+ echo $((now - mtime))
32
+ }
17
33
 
18
34
  # _hook_acquire_lock <lockdir> — returns 0 on success, 1 on timeout.
35
+ # On each mkdir failure, if the lockdir is older than
36
+ # MAXY_HOOK_LOCK_TTL_SECONDS, the orphaned holder is presumed dead: the
37
+ # lockdir is force-removed, a stale-lock-reclaimed observability line is
38
+ # emitted, and one mkdir retry is attempted before resuming the spin.
19
39
  _hook_acquire_lock() {
20
- local lockdir="$1" i=0
40
+ local lockdir="$1" i=0 age
21
41
  while ! mkdir "$lockdir" 2>/dev/null; do
42
+ age=$(_hook_lockdir_age_seconds "$lockdir")
43
+ if [[ "$age" -ge "$MAXY_HOOK_LOCK_TTL_SECONDS" ]]; then
44
+ rmdir "$lockdir" 2>/dev/null || rm -rf "$lockdir" 2>/dev/null
45
+ mkdir -p "$(dirname "$MAXY_HOOK_SERVER_LOG")" 2>/dev/null
46
+ printf '[hook-propagate-error] reason=stale-lock-reclaimed lockdir=%s ageSeconds=%s\n' \
47
+ "$lockdir" "$age" >>"$MAXY_HOOK_SERVER_LOG" 2>/dev/null || true
48
+ mkdir "$lockdir" 2>/dev/null && return 0
49
+ fi
22
50
  i=$((i+1))
23
51
  [[ "$i" -ge "$MAXY_HOOK_LOCK_ATTEMPTS" ]] && return 1
24
52
  sleep "$MAXY_HOOK_LOCK_SLEEP" 2>/dev/null || true