@cohortapp/agent-sdk 2.12.0 → 2.14.0

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 (129) hide show
  1. package/bin/maestro.mjs +6 -2
  2. package/docs/guides/front-door-session.md +86 -0
  3. package/lib/cli/design.mjs +185 -0
  4. package/lib/cli/design.test.mjs +270 -0
  5. package/lib/cli/global-setup-extras.mjs +44 -0
  6. package/lib/cli/global-setup-extras.test.mjs +95 -0
  7. package/lib/cli/session.mjs +11 -1
  8. package/lib/cli/session.test.mjs +17 -6
  9. package/lib/collective/global-config.mjs +5 -0
  10. package/lib/collective/global-config.test.mjs +5 -0
  11. package/lib/collective/vendor-skills.mjs +305 -0
  12. package/lib/collective/vendor-skills.test.mjs +306 -0
  13. package/lib/design/design-md.mjs +793 -0
  14. package/lib/design/design-md.test.mjs +318 -0
  15. package/lib/design/fixtures/DESIGN.golden.md +238 -0
  16. package/lib/design/fixtures/PRODUCT.golden.md +67 -0
  17. package/lib/design/fixtures/foundation.json +133 -0
  18. package/lib/design/refresh-gate.mjs +154 -0
  19. package/lib/design/refresh-gate.test.mjs +144 -0
  20. package/lib/design/write.mjs +275 -0
  21. package/lib/design/write.test.mjs +241 -0
  22. package/lib/prompts/parallelism.mjs +79 -0
  23. package/lib/prompts/parallelism.test.mjs +177 -0
  24. package/lib/telemetry/collect.mjs +357 -5
  25. package/lib/telemetry/collect.test.mjs +285 -0
  26. package/package.json +1 -1
  27. package/plugins/maestro-skills/plugin.json +4 -0
  28. package/plugins/maestro-skills/skills/cohort-design.md +153 -0
  29. package/plugins/maestro-skills/vendor/emilkowalski/LICENSE +21 -0
  30. package/plugins/maestro-skills/vendor/emilkowalski/UPSTREAM.json +70 -0
  31. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/RECIPES.md +324 -0
  32. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/SKILL.md +199 -0
  33. package/plugins/maestro-skills/vendor/emilkowalski/skills/animation-vocabulary/SKILL.md +173 -0
  34. package/plugins/maestro-skills/vendor/emilkowalski/skills/apple-design/SKILL.md +282 -0
  35. package/plugins/maestro-skills/vendor/emilkowalski/skills/emil-design-eng/SKILL.md +674 -0
  36. package/plugins/maestro-skills/vendor/emilkowalski/skills/find-animation-opportunities/SKILL.md +132 -0
  37. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/AUDIT.md +115 -0
  38. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/PLAN-TEMPLATE.md +73 -0
  39. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/SKILL.md +101 -0
  40. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/PICKER.md +197 -0
  41. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/SKILL.md +90 -0
  42. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/SKILL.md +112 -0
  43. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/STANDARDS.md +187 -0
  44. package/plugins/maestro-skills/vendor/impeccable/LICENSE +191 -0
  45. package/plugins/maestro-skills/vendor/impeccable/NOTICE.md +11 -0
  46. package/plugins/maestro-skills/vendor/impeccable/SKILL.md +86 -0
  47. package/plugins/maestro-skills/vendor/impeccable/UPSTREAM.json +201 -0
  48. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-asset-producer.md +42 -0
  49. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-documenter.md +29 -0
  50. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-finish-reviewer.md +43 -0
  51. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-manual-edit-applier.md +97 -0
  52. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.md +312 -0
  53. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.native.md +58 -0
  54. package/plugins/maestro-skills/vendor/impeccable/reference/android.md +46 -0
  55. package/plugins/maestro-skills/vendor/impeccable/reference/animate.md +89 -0
  56. package/plugins/maestro-skills/vendor/impeccable/reference/audit.md +136 -0
  57. package/plugins/maestro-skills/vendor/impeccable/reference/audit.native.md +139 -0
  58. package/plugins/maestro-skills/vendor/impeccable/reference/bolder.md +33 -0
  59. package/plugins/maestro-skills/vendor/impeccable/reference/clarify.md +94 -0
  60. package/plugins/maestro-skills/vendor/impeccable/reference/colorize.md +86 -0
  61. package/plugins/maestro-skills/vendor/impeccable/reference/craft-floor.md +44 -0
  62. package/plugins/maestro-skills/vendor/impeccable/reference/craft.md +5 -0
  63. package/plugins/maestro-skills/vendor/impeccable/reference/critique.md +806 -0
  64. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/asset-producer.md +37 -0
  65. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/documenter.md +24 -0
  66. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/finish-reviewer.md +38 -0
  67. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/manual-edit-applier.md +92 -0
  68. package/plugins/maestro-skills/vendor/impeccable/reference/delight.md +70 -0
  69. package/plugins/maestro-skills/vendor/impeccable/reference/distill.md +111 -0
  70. package/plugins/maestro-skills/vendor/impeccable/reference/doctor.md +54 -0
  71. package/plugins/maestro-skills/vendor/impeccable/reference/document.md +416 -0
  72. package/plugins/maestro-skills/vendor/impeccable/reference/extract.md +69 -0
  73. package/plugins/maestro-skills/vendor/impeccable/reference/harden.md +336 -0
  74. package/plugins/maestro-skills/vendor/impeccable/reference/hooks.md +111 -0
  75. package/plugins/maestro-skills/vendor/impeccable/reference/init.md +131 -0
  76. package/plugins/maestro-skills/vendor/impeccable/reference/ios.md +51 -0
  77. package/plugins/maestro-skills/vendor/impeccable/reference/layout.md +84 -0
  78. package/plugins/maestro-skills/vendor/impeccable/reference/live-setup.md +104 -0
  79. package/plugins/maestro-skills/vendor/impeccable/reference/live.md +325 -0
  80. package/plugins/maestro-skills/vendor/impeccable/reference/new-work.md +147 -0
  81. package/plugins/maestro-skills/vendor/impeccable/reference/onboard.md +234 -0
  82. package/plugins/maestro-skills/vendor/impeccable/reference/operate.md +61 -0
  83. package/plugins/maestro-skills/vendor/impeccable/reference/optimize.md +258 -0
  84. package/plugins/maestro-skills/vendor/impeccable/reference/overdrive.md +127 -0
  85. package/plugins/maestro-skills/vendor/impeccable/reference/polish.md +105 -0
  86. package/plugins/maestro-skills/vendor/impeccable/reference/quieter.md +99 -0
  87. package/plugins/maestro-skills/vendor/impeccable/reference/routing.md +24 -0
  88. package/plugins/maestro-skills/vendor/impeccable/reference/shape.md +59 -0
  89. package/plugins/maestro-skills/vendor/impeccable/reference/typeset.md +80 -0
  90. package/plugins/maestro-skills/vendor/impeccable/reference/visualize.md +46 -0
  91. package/plugins/maestro-skills/vendor/taste-skill/LICENSE +21 -0
  92. package/plugins/maestro-skills/vendor/taste-skill/UPSTREAM.json +37 -0
  93. package/plugins/maestro-skills/vendor/taste-skill/skills/minimalist-skill/SKILL.md +85 -0
  94. package/plugins/maestro-skills/vendor/taste-skill/skills/redesign-skill/SKILL.md +178 -0
  95. package/plugins/maestro-skills/vendor/taste-skill/skills/soft-skill/SKILL.md +98 -0
  96. package/plugins/maestro-skills/vendor/taste-skill/skills/taste-skill/SKILL.md +1206 -0
  97. package/plugins/maestro-skills/vendor/unlazy/LICENSE +21 -0
  98. package/plugins/maestro-skills/vendor/unlazy/SECURITY.md +72 -0
  99. package/plugins/maestro-skills/vendor/unlazy/SKILL.md +104 -0
  100. package/plugins/maestro-skills/vendor/unlazy/UPSTREAM.json +94 -0
  101. package/plugins/maestro-skills/vendor/unlazy/references/dispatch.md +82 -0
  102. package/plugins/maestro-skills/vendor/unlazy/references/gates.md +149 -0
  103. package/plugins/maestro-skills/vendor/unlazy/references/method.md +49 -0
  104. package/plugins/maestro-skills/vendor/unlazy/references/orchestration.md +107 -0
  105. package/plugins/maestro-skills/vendor/unlazy/references/parallel.md +133 -0
  106. package/plugins/maestro-skills/vendor/unlazy/references/token-economy.md +48 -0
  107. package/plugins/maestro-skills/vendor/unlazy/scripts/dispatch-check.mjs +139 -0
  108. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-check.mjs +960 -0
  109. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-lint.mjs +245 -0
  110. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/check-supervisor.mjs +46 -0
  111. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/dispatch.mjs +293 -0
  112. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/gates.mjs +953 -0
  113. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/process-tree.mjs +161 -0
  114. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/regex-worker.mjs +9 -0
  115. package/plugins/maestro-skills/vendor/unlazy/templates/PLAN.md +116 -0
  116. package/plugins/maestro-skills/vendor/unlazy/templates/gates-leaf.md +51 -0
  117. package/plugins/maestro-skills/vendor/unlazy/templates/gates-node.md +51 -0
  118. package/scripts/ci/check-skill-packs.mjs +388 -0
  119. package/scripts/ci/check-skill-packs.test.mjs +495 -0
  120. package/scripts/ci/check.mjs +3 -0
  121. package/scripts/daemon/agent-daemon-design.test.mjs +238 -0
  122. package/scripts/daemon/agent-daemon.mjs +108 -0
  123. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +61 -2
  124. package/scripts/daemon/cadence-consumer.mjs +46 -22
  125. package/scripts/daemon/prompt-builder.mjs +19 -3
  126. package/scripts/local-triggers/autoupdate.test.mjs +33 -3
  127. package/scripts/vendor/skill-packs.mjs +354 -0
  128. package/scripts/vendor/sync-skill-packs.mjs +242 -0
  129. package/scripts/vendor/sync-skill-packs.test.mjs +103 -0
@@ -0,0 +1,177 @@
1
+ /**
2
+ * parallelism.test.mjs — the fleet-wide parallelism directive (WP-M7 mechanic 5).
3
+ *
4
+ * Three things are pinned here, and each has a distinct failure it prevents:
5
+ *
6
+ * 1. THE BYTES. The directive is quoted verbatim in the design spec and is the
7
+ * one place a fleet-wide behaviour change is auditable. A silent reword —
8
+ * dropping the file-scope sentence, say — would turn "maximum safe
9
+ * parallelism" into two agents editing one file. So the expected text is
10
+ * written out longhand below, NOT derived from the constant.
11
+ * 2. IDEMPOTENCE. Two seams apply it (`session spawn` and the daemon's
12
+ * prompt-builder) and they compose. A doubled directive reads as emphasis
13
+ * to a model, which is exactly how the safety sentence loses to the
14
+ * permission sentence.
15
+ * 3. BOTH WIRINGS. A constant nothing calls is worth nothing; the two seams
16
+ * are driven for real here.
17
+ */
18
+
19
+ import { test } from "node:test";
20
+ import assert from "node:assert/strict";
21
+
22
+ import { PARALLELISM_DIRECTIVE, withParallelism, countParallelism } from "./parallelism.mjs";
23
+
24
+ /**
25
+ * The directive, longhand. Deliberately NOT built from the import: a test that
26
+ * compares a constant to itself proves nothing about the wording.
27
+ */
28
+ const EXPECTED = [
29
+ "You are allowed to run multiple sub-agents concurrently.",
30
+ "",
31
+ "When the plan contains independent tasks, dispatch them together in a single parallel batch rather than waiting for each task to finish before starting the next.",
32
+ "",
33
+ "Only run tasks sequentially when one task genuinely depends on the output of another.",
34
+ "",
35
+ "Before dispatching any batch, check the planned file scope for each task and ensure that no two agents are assigned to edit the same file. If tasks would overlap on a file, sequence those tasks or redefine their scope to eliminate the conflict.",
36
+ "",
37
+ "Default to maximum safe parallelism.",
38
+ ].join("\n");
39
+
40
+ test("PARALLELISM_DIRECTIVE is the spec's text, byte for byte", () => {
41
+ assert.equal(PARALLELISM_DIRECTIVE, EXPECTED);
42
+ // The two sentences that carry the whole point, named individually so a
43
+ // partial reword fails with a readable diff rather than one giant blob.
44
+ assert.match(PARALLELISM_DIRECTIVE, /single parallel batch/);
45
+ assert.match(PARALLELISM_DIRECTIVE, /no two agents are assigned to edit the same file/);
46
+ assert.ok(!PARALLELISM_DIRECTIVE.endsWith("\n"), "no trailing newline — the joiner adds the separator");
47
+ });
48
+
49
+ test("withParallelism prepends once and is idempotent", () => {
50
+ const prompt = "Reply to the message below.";
51
+ const once = withParallelism(prompt);
52
+ assert.ok(once.startsWith(PARALLELISM_DIRECTIVE), "the directive leads");
53
+ assert.ok(once.endsWith(prompt), "the caller's prompt survives intact");
54
+ assert.equal(countParallelism(once), 1);
55
+
56
+ const twice = withParallelism(once);
57
+ assert.equal(twice, once, "a second application changes nothing");
58
+ assert.equal(countParallelism(twice), 1);
59
+
60
+ const thrice = withParallelism(withParallelism(withParallelism(prompt)));
61
+ assert.equal(countParallelism(thrice), 1);
62
+ });
63
+
64
+ test("withParallelism tolerates an empty or non-string prompt", () => {
65
+ assert.equal(withParallelism(""), PARALLELISM_DIRECTIVE);
66
+ assert.equal(withParallelism(" \n "), PARALLELISM_DIRECTIVE);
67
+ assert.equal(withParallelism(undefined), PARALLELISM_DIRECTIVE);
68
+ assert.equal(withParallelism(null), PARALLELISM_DIRECTIVE);
69
+ assert.equal(withParallelism(42), PARALLELISM_DIRECTIVE);
70
+ });
71
+
72
+ test("a prompt that already carries the directive mid-body is not re-led", () => {
73
+ const embedded = `Context first.\n\n${PARALLELISM_DIRECTIVE}\n\nThen the task.`;
74
+ assert.equal(withParallelism(embedded), embedded);
75
+ assert.equal(countParallelism(withParallelism(embedded)), 1);
76
+ });
77
+
78
+ test("countParallelism counts non-overlapping occurrences", () => {
79
+ assert.equal(countParallelism(""), 0);
80
+ assert.equal(countParallelism(null), 0);
81
+ assert.equal(countParallelism("nothing here"), 0);
82
+ assert.equal(countParallelism(`${PARALLELISM_DIRECTIVE}\n\n${PARALLELISM_DIRECTIVE}`), 2);
83
+ });
84
+
85
+ // ── the two wired seams ─────────────────────────────────────────────────────
86
+
87
+ test("maestro session spawn leads every peer prompt with the directive, exactly once", async () => {
88
+ const { buildSpawnArgs } = await import("../cli/session.mjs");
89
+ const prompt = "Sweep the backlog and file what you find.";
90
+ const args = buildSpawnArgs({ first: "nova", slug: "sweep", prompt, env: {}, allowedTools: [] });
91
+
92
+ assert.equal(args[0], "--name");
93
+ assert.equal(args[1], "nova-sweep");
94
+ const sent = args[args.length - 1];
95
+ assert.ok(sent.startsWith(PARALLELISM_DIRECTIVE), "the peer's prompt is led by the directive");
96
+ assert.ok(sent.endsWith(prompt));
97
+ assert.equal(countParallelism(sent), 1);
98
+
99
+ // The composing case: a prompt that already carries it is not doubled.
100
+ const pre = withParallelism(prompt);
101
+ const again = buildSpawnArgs({ first: "nova", slug: "sweep", prompt: pre, env: {}, allowedTools: [] });
102
+ assert.equal(countParallelism(again[again.length - 1]), 1);
103
+ assert.equal(again[again.length - 1], pre);
104
+ });
105
+
106
+ test("the daemon's prompt-builder leads every sub-session prompt with the directive, exactly once", async (t) => {
107
+ const { mkdtempSync, mkdirSync, writeFileSync } = await import("node:fs");
108
+ const { join } = await import("node:path");
109
+ const { tmpdir } = await import("node:os");
110
+
111
+ const root = mkdtempSync(join(tmpdir(), "maestro-parallelism-prompt-"));
112
+ mkdirSync(join(root, "config"), { recursive: true });
113
+ writeFileSync(join(root, "config", "agent.json"), JSON.stringify({ firstName: "Nova" }));
114
+ const prevAgentDir = process.env.AGENT_DIR;
115
+ process.env.AGENT_DIR = root;
116
+ t.after(() => {
117
+ if (prevAgentDir === undefined) delete process.env.AGENT_DIR;
118
+ else process.env.AGENT_DIR = prevAgentDir;
119
+ });
120
+
121
+ const url = new URL("../../scripts/daemon/prompt-builder.mjs", import.meta.url);
122
+ const pb = await import(`${url.href}?parallelism=${Math.random()}`);
123
+ const prompt = await pb.buildPrompt(
124
+ { sender: "Ana", content: "Three unrelated things need doing.", channel: "slack" },
125
+ { action: "respond", priority: "normal", summary: "three things" },
126
+ { type: "inbox" },
127
+ );
128
+
129
+ assert.ok(prompt.startsWith(PARALLELISM_DIRECTIVE), "the directive leads the built prompt");
130
+ assert.equal(countParallelism(prompt), 1);
131
+ assert.match(prompt, /Three unrelated things need doing\./, "the item's own content is still there");
132
+ });
133
+
134
+ test("the persona's precedence sentence names itself, so leading the prompt with the directive cannot mis-scope it", async (t) => {
135
+ // The sentence used to read "The section above … anything below". That was
136
+ // true only while the persona was the first thing in the prompt; once the
137
+ // directive leads, "above" has two candidate referents and the directive
138
+ // itself falls outside the rule (it is above, not below). Nothing else pins
139
+ // this ordering, so it is pinned here, beside the change that broke it.
140
+ const { mkdtempSync, mkdirSync, writeFileSync } = await import("node:fs");
141
+ const { join } = await import("node:path");
142
+ const { tmpdir } = await import("node:os");
143
+
144
+ const root = mkdtempSync(join(tmpdir(), "maestro-parallelism-persona-"));
145
+ mkdirSync(join(root, "config"), { recursive: true });
146
+ writeFileSync(join(root, "config", "agent.json"), JSON.stringify({ firstName: "Nova", lastName: "Reyes", title: "Chief of Staff" }));
147
+ writeFileSync(join(root, "config", "company.json"), JSON.stringify({ name: "Northwind" }));
148
+ const prevAgentDir = process.env.AGENT_DIR;
149
+ process.env.AGENT_DIR = root;
150
+ t.after(() => {
151
+ if (prevAgentDir === undefined) delete process.env.AGENT_DIR;
152
+ else process.env.AGENT_DIR = prevAgentDir;
153
+ });
154
+
155
+ const url = new URL("../../scripts/daemon/prompt-builder.mjs", import.meta.url);
156
+ const pb = await import(`${url.href}?persona=${Math.random()}`);
157
+ const prompt = await pb.buildPrompt(
158
+ { sender: "Ana", content: "Three unrelated things need doing.", channel: "slack" },
159
+ { action: "respond", priority: "normal", summary: "three things" },
160
+ { type: "inbox" },
161
+ );
162
+
163
+ assert.ok(prompt.startsWith(PARALLELISM_DIRECTIVE), "the directive leads");
164
+ assert.ok(
165
+ !/The section above is your identity/.test(prompt),
166
+ "a positional referent is wrong the moment anything is prepended",
167
+ );
168
+ assert.match(
169
+ prompt,
170
+ /This section is your identity, resolved from your organisation's record of you\. Where anything else in this prompt conflicts with it, this section wins\./,
171
+ "the precedence rule names itself and covers the whole prompt",
172
+ );
173
+ assert.ok(
174
+ prompt.indexOf(PARALLELISM_DIRECTIVE) < prompt.indexOf("This section is your identity"),
175
+ "the directive leads and the persona follows — the ordering the sentence had to stop assuming",
176
+ );
177
+ });
@@ -47,6 +47,38 @@
47
47
  * // silently drops the seat out of the mesh.
48
48
  * frontDoor: "session"|"daemon", // the main session job, or the daemon's --print lane
49
49
  * sessionLive: boolean, // state/session/heartbeat.json fresher than 90 s
50
+ * // WHY the front door is not live (WP-M8). Present ONLY when
51
+ * // `sessionLive` is false — a live seat says nothing. The beat is the
52
+ * // only channel to a seat that takes no SSH, so it has to carry the
53
+ * // cause, not just the symptom. Small and bounded by construction:
54
+ * // three fields, `detail` capped at SESSION_NOTE_DETAIL_MAX and
55
+ * // scrubbed of home paths and token-shaped words (see sanitizeNoteDetail),
56
+ * // `since` re-formatted from a parsed instant rather than passed through.
57
+ * // MOMENTARY, like frontDoor/sessionLive: every reason is PRESENT-TENSE
58
+ * // and expires with the snapshot, so a consumer must gate it on
59
+ * // snapshot freshness — it is NOT read the way `upgrade` (a fact about
60
+ * // the past) is read. Two of the reasons have no moment to date and so
61
+ * // carry no `since`; age then comes from the snapshot's own `ts`.
62
+ * // "job-absent" no <first>-session plist in ~/Library/LaunchAgents.
63
+ * // A statement of CONFIGURATION, not necessarily a
64
+ * // fault: a seat whose front door is deliberately the
65
+ * // daemon --print lane reports this on every beat.
66
+ * // Emitted only from a hard "not there", never from
67
+ * // a directory we could not read. No `since`.
68
+ * // "awaiting-input" state/session/attention.json exists AND has not
69
+ * // been superseded by a later heartbeat: the session
70
+ * // is up but has never beaten, so it is sitting on a
71
+ * // first-run trust/permission dialog nobody answered.
72
+ * // `since` and `detail` carry the supervisor's own
73
+ * // attentionRecord moment + hint (truncated).
74
+ * // "heartbeat-stale" a heartbeat exists but has aged out — the supervisor
75
+ * // died or the mux session ended. `since` is the last beat.
76
+ * // "heartbeat-unreadable" the file is there and will not parse: the job
77
+ * // may well have beaten, so this is NOT "never beaten".
78
+ * // "never-beaten" no heartbeat file at all and no watchdog record yet.
79
+ * // `detail` claims the job is installed only when its
80
+ * // presence was actually established. No `since`.
81
+ * sessionNote?: { reason: string, since?: ISO8601, detail?: string },
50
82
  * // LAST UPGRADE — the outcome autoupdate.sh recorded in
51
83
  * // state/autoupdate/last.json (WP-M6); absent until a first attempt.
52
84
  * // Also on `machine` (open record), so hq's fleet view can show it next
@@ -92,6 +124,7 @@ import { probeClaude as defaultProbeClaude } from "../setup/claude-probe.mjs";
92
124
  import { list as listPresence } from "../collective/presence.mjs";
93
125
  import { billableUsd } from "../cost/ledger-row.mjs";
94
126
  import { liveClaudeStats } from "../resource-governor.mjs";
127
+ import { agentFirstName } from "../session/identity.mjs";
95
128
 
96
129
  /** Default temperature probe ceiling — used only as a guard in alerts; here we just report. */
97
130
  const VALID_STATES = new Set(["active", "idle", "busy", "error", "offline"]);
@@ -1031,6 +1064,216 @@ export function sessionLiveness(hb, nowMs, opts = {}) {
1031
1064
  return { frontDoor: "session", sessionLive: live };
1032
1065
  }
1033
1066
 
1067
+ /** Hard cap on `machine.sessionNote.detail` — the beat is a heartbeat, not a log. */
1068
+ export const SESSION_NOTE_DETAIL_MAX = 200;
1069
+
1070
+ /**
1071
+ * Absolute paths that could name a person, a home or a private temp dir.
1072
+ *
1073
+ * A path segment may CONTAIN a space — `/Users/Olivia Chen/…` is an ordinary
1074
+ * macOS home — so the match does not simply stop at the first whitespace, which
1075
+ * left `<path> Chen/olivia-ai/state` (a surname and the seat's layout) standing
1076
+ * in the note. A space is swallowed only when what follows it is still a path
1077
+ * (more non-space characters and then a `/`), so ordinary prose after a path
1078
+ * survives: `blocked in /Users/maya and then retry` redacts only the path.
1079
+ */
1080
+ const NOTE_PATH_RE = /(?:~|\/(?:Users|home|private|var\/folders))\/(?:[^\s"'`]|[ \t](?=[^\s"'`]*\/))*/g;
1081
+ /** Token-shaped words: an api-key prefix, or any long opaque run (uuids included). */
1082
+ const NOTE_TOKEN_RE = /\b(?:sk-[A-Za-z0-9_-]{8,}|[A-Za-z0-9][A-Za-z0-9_-]{31,})\b/g;
1083
+
1084
+ /**
1085
+ * Pure: make a free-text note safe and small enough to ride every beat.
1086
+ *
1087
+ * The text this scrubs is OUR OWN (the supervisor's attention hint), so it is
1088
+ * not a sanitiser standing between us and an attacker — it is the guarantee the
1089
+ * contract makes to hq and to every operator reading a fleet view: a note never
1090
+ * carries a home directory, a session id, a token or a file path, and never
1091
+ * grows past {@link SESSION_NOTE_DETAIL_MAX}. The hint names an attach command
1092
+ * (`tmux attach -t =maestro-alex`), which is exactly the actionable part and
1093
+ * survives; the paths and ids around it do not.
1094
+ *
1095
+ * @param {unknown} text
1096
+ * @param {number} [max]
1097
+ * @returns {string} "" when there is nothing safe left to say
1098
+ */
1099
+ export function sanitizeNoteDetail(text, max = SESSION_NOTE_DETAIL_MAX) {
1100
+ if (typeof text !== "string" || !text) return "";
1101
+ const cap = Number.isFinite(max) && max > 0 ? Math.floor(max) : SESSION_NOTE_DETAIL_MAX;
1102
+ let out = text.replace(/[\u0000-\u001F\u007F]+/g, " ");
1103
+ out = out.replace(NOTE_PATH_RE, "<path>");
1104
+ out = out.replace(NOTE_TOKEN_RE, "<redacted>");
1105
+ out = out.replace(/\s+/g, " ").trim();
1106
+ if (out.length > cap) out = `${out.slice(0, cap - 1).trimEnd()}…`;
1107
+ return out;
1108
+ }
1109
+
1110
+ /** ms-or-ISO `ts` off a heartbeat record -> epoch ms, else null. */
1111
+ function heartbeatTs(hb) {
1112
+ if (!hb || typeof hb !== "object") return null;
1113
+ const ts = typeof hb.ts === "number" ? hb.ts : Date.parse(hb.ts);
1114
+ return Number.isFinite(ts) ? ts : null;
1115
+ }
1116
+
1117
+ /**
1118
+ * Pure: an ISO-8601 string -> epoch ms, else null.
1119
+ *
1120
+ * `since` rides the beat as the note's only structured field, and it used to
1121
+ * ride VERBATIM behind a bare `Date.parse` finiteness check — which V8's legacy
1122
+ * date parser passes for a string carrying trailing prose, so a home path or a
1123
+ * token-shaped run could travel on the one field {@link sanitizeNoteDetail}
1124
+ * does not cover. Parsing to ms here and re-formatting through `toISOString()`
1125
+ * makes `since` bounded and canonical by CONSTRUCTION rather than by trusting
1126
+ * the file it came from.
1127
+ */
1128
+ function instantMs(v) {
1129
+ if (typeof v !== "string" || !v) return null;
1130
+ const ms = Date.parse(v);
1131
+ return Number.isFinite(ms) ? ms : null;
1132
+ }
1133
+
1134
+ /**
1135
+ * The supervisor watchdog's vocabulary (`lib/session/first-run#heartbeatSilence`)
1136
+ * in plain words.
1137
+ *
1138
+ * Its `"no-heartbeat"` means "the session is UP and has never beaten"; the
1139
+ * beat's own reasons of nearly the same spelling mean something else, and the
1140
+ * two met inside one payload (`{reason:"awaiting-input", detail:"no-heartbeat: …"}`),
1141
+ * which reads as a contradiction to anything that shows reason and detail side
1142
+ * by side. An unrecognised value still passes through verbatim — a word we do
1143
+ * not know is better than a word we invent.
1144
+ */
1145
+ const ATTENTION_WHY = {
1146
+ "no-heartbeat": "up, never beaten",
1147
+ "stale-heartbeat": "no beat since this launch",
1148
+ };
1149
+
1150
+ /**
1151
+ * Pure: WHY the front door is not live — the beat's `machine.sessionNote`.
1152
+ *
1153
+ * THE GAP (WP-M8): after 2.12.0, seats A010 and A016 beat
1154
+ * `frontDoor:"daemon", sessionLive:false` for days and nothing on the beat
1155
+ * could tell a job that was never installed from a session sitting on an
1156
+ * unanswered first-run dialog from a supervisor that had died — three states an
1157
+ * operator fixes three different ways. No seat in the fleet accepts SSH, so the
1158
+ * beat is the only channel; the seat already knew the answer
1159
+ * (`state/session/attention.json`), it just never left the machine.
1160
+ *
1161
+ * ORDER, and why each step outranks the next:
1162
+ *
1163
+ * 1. `job-absent` first: a seat with no job cannot have a meaningful heartbeat
1164
+ * or attention record, and a stale `attention.json` left behind by a
1165
+ * previous install must not mask "the job is gone". It is a statement of
1166
+ * CONFIGURATION, not necessarily a fault — a seat whose front door is
1167
+ * deliberately the daemon --print lane reports it on every beat, and the
1168
+ * detail says so rather than accusing the seat.
1169
+ * 2. `awaiting-input` next — but ONLY while the record still describes the
1170
+ * present. `scripts/session/supervisor.mjs` clears `attention.json` at the
1171
+ * START of the next launch and never when the human answers the dialog, so
1172
+ * a record can outlive its truth by days: the session gets answered, beats
1173
+ * for a week, then dies. A heartbeat LATER than the record is proof the
1174
+ * dialog was answered, and the record is then superseded — otherwise the
1175
+ * beat hands the operator a week-old attach command for a mux session that
1176
+ * no longer exists while the real fault stays invisible.
1177
+ * 3. `heartbeat-stale` / `heartbeat-unreadable` / `never-beaten` last, from
1178
+ * what the heartbeat file itself can prove.
1179
+ *
1180
+ * Every emitted `detail` must be TRUE of the state it describes: an unknown is
1181
+ * never reported as an absence (`job-absent` comes only from a hard `false`)
1182
+ * and, symmetrically, never as a presence (`never-beaten` says the job is
1183
+ * installed only when it was actually checked).
1184
+ *
1185
+ * Everything is INJECTED — no clock, no fs, no launchctl. Every malformed
1186
+ * input resolves to a coarser answer or to `null`; nothing here throws.
1187
+ *
1188
+ * @param {object} a
1189
+ * @param {object|null} [a.heartbeat] parsed state/session/heartbeat.json (null when absent/corrupt)
1190
+ * @param {boolean} [a.heartbeatUnreadable] the file was THERE but would not parse (a different fault from "never beaten")
1191
+ * @param {object|null} [a.attention] parsed state/session/attention.json (lib/session/first-run#attentionRecord)
1192
+ * @param {boolean|null} [a.jobInstalled] is the `<first>-session` launchd job present? null = COULD NOT TELL (reported as neither an absence nor a presence)
1193
+ * @param {string} [a.label] the launchd label, for the job-absent detail
1194
+ * @param {number} a.now epoch ms
1195
+ * @param {number} [a.staleMs] heartbeat staleness bound (default {@link SESSION_STALE_MS})
1196
+ * @returns {{reason:string, since?:string, detail?:string}|null} null when the front door is live (or `now` is unusable)
1197
+ */
1198
+ export function sessionNote(a = {}) {
1199
+ const o = a && typeof a === "object" ? a : {};
1200
+ const now = Number(o.now);
1201
+ if (!Number.isFinite(now)) return null;
1202
+ const staleMs = Number.isFinite(o.staleMs) ? o.staleMs : SESSION_STALE_MS;
1203
+ const hb = o.heartbeat && typeof o.heartbeat === "object" && !Array.isArray(o.heartbeat) ? o.heartbeat : null;
1204
+
1205
+ // A live front door has nothing to explain — the note is an EXCEPTION field.
1206
+ if (sessionLiveness(hb, now, { staleMs }).sessionLive) return null;
1207
+
1208
+ // 1. The job is not on this seat at all. `false` only; `null` is "unknown",
1209
+ // and an unknown must never be reported as an absence.
1210
+ if (o.jobInstalled === false) {
1211
+ const label = typeof o.label === "string" && o.label.trim() ? o.label.trim() : "";
1212
+ return {
1213
+ reason: "job-absent",
1214
+ detail: sanitizeNoteDetail(
1215
+ `no ${label || "main-session"} launchd job on this seat — the daemon --print lane is the front door (by configuration, or the session job was never installed)`,
1216
+ ),
1217
+ };
1218
+ }
1219
+
1220
+ const hbMs = heartbeatTs(hb);
1221
+
1222
+ // 2. The supervisor's watchdog has already named it: alive, never beaten —
1223
+ // a first-run trust/permission dialog with nobody attached to answer.
1224
+ // Unless a LATER heartbeat proves the dialog was answered and the record
1225
+ // is simply one the supervisor never got to clear (see ORDER above). A
1226
+ // record with no usable `since` cannot be ordered against the heartbeat,
1227
+ // and a heartbeat with a real timestamp is the harder evidence, so that
1228
+ // too counts as superseded.
1229
+ const att = o.attention && typeof o.attention === "object" && !Array.isArray(o.attention) ? o.attention : null;
1230
+ const attMs = att ? instantMs(att.since) : null;
1231
+ const superseded = att !== null && hbMs !== null && (attMs === null || hbMs > attMs);
1232
+ if (att && !superseded) {
1233
+ const note = { reason: "awaiting-input" };
1234
+ if (attMs !== null) note.since = new Date(attMs).toISOString();
1235
+ const raw = typeof att.reason === "string" ? att.reason.trim() : "";
1236
+ const why = raw ? (ATTENTION_WHY[raw] || raw) : "";
1237
+ const hint = typeof att.hint === "string" ? att.hint : "";
1238
+ const detail = sanitizeNoteDetail(why && hint ? `${why}: ${hint}` : why || hint);
1239
+ if (detail) note.detail = detail;
1240
+ return note;
1241
+ }
1242
+
1243
+ // 3. It beat once and stopped: the supervisor died, or the mux session ended.
1244
+ if (hbMs !== null) {
1245
+ const ageMs = now - hbMs;
1246
+ return {
1247
+ reason: "heartbeat-stale",
1248
+ since: new Date(hbMs).toISOString(),
1249
+ detail: ageMs < 0 ? "last beat is in the future (clock skew)" : `no beat for ${Math.round(ageMs / 1000)} s`,
1250
+ };
1251
+ }
1252
+
1253
+ // 4. A heartbeat file is THERE but says nothing usable. Not the same fault as
1254
+ // "never beaten" — the job may well have beaten and the file be corrupt —
1255
+ // so it gets its own reason and its own fix.
1256
+ if (hb || o.heartbeatUnreadable === true) {
1257
+ return {
1258
+ reason: "heartbeat-unreadable",
1259
+ detail: hb
1260
+ ? "the heartbeat file is present but carries no readable timestamp"
1261
+ : "the heartbeat file is present but could not be parsed",
1262
+ };
1263
+ }
1264
+
1265
+ // 5. Nothing on disk at all: a job that has never beaten and whose watchdog
1266
+ // has not fired yet. What we say about the job depends on whether its
1267
+ // presence was actually established — `true`, and only `true`, licenses
1268
+ // "the session job is installed".
1269
+ return {
1270
+ reason: "never-beaten",
1271
+ detail: o.jobInstalled === true
1272
+ ? "the session job is installed but has never written a heartbeat"
1273
+ : "no heartbeat from the session job, and its presence on this seat could not be checked",
1274
+ };
1275
+ }
1276
+
1034
1277
  /**
1035
1278
  * Pure: the beat's `machine.upgrade` from state/autoupdate/last.json
1036
1279
  * (`{from,to,at,ok,healthy,reason}` as autoupdate.sh writes it). Only the four
@@ -1053,10 +1296,87 @@ function readAutoupdateLast(agentRoot) {
1053
1296
  return safeReadJson(join(resolve(agentRoot), "state", "autoupdate", "last.json"));
1054
1297
  }
1055
1298
 
1056
- /** Read state/session/heartbeat.json; null when absent or malformed. */
1057
- function readSessionHeartbeat(agentRoot) {
1299
+ /**
1300
+ * Read state/session/heartbeat.json, distinguishing ABSENT from PRESENT-BUT-
1301
+ * UNPARSEABLE.
1302
+ *
1303
+ * `safeReadJson` maps both to `null`, which is right for liveness (neither is a
1304
+ * live beat) and wrong for the note: a corrupt file told the operator the job
1305
+ * "has never written a heartbeat" when in fact it had, and the fix for the two
1306
+ * is not the same. The two facts are separated here and rejoined in
1307
+ * {@link sessionNote}.
1308
+ *
1309
+ * @returns {{heartbeat:object|null, unreadable:boolean}}
1310
+ */
1311
+ function readSessionHeartbeatState(agentRoot) {
1312
+ if (!agentRoot) return { heartbeat: null, unreadable: false };
1313
+ let text;
1314
+ try {
1315
+ text = readFileSync(join(resolve(agentRoot), "state", "session", "heartbeat.json"), "utf8");
1316
+ } catch {
1317
+ return { heartbeat: null, unreadable: false }; // no file: the job has never beaten
1318
+ }
1319
+ try {
1320
+ const v = JSON.parse(text);
1321
+ if (v && typeof v === "object" && !Array.isArray(v)) return { heartbeat: v, unreadable: false };
1322
+ return { heartbeat: null, unreadable: true }; // a JSON scalar is not a heartbeat
1323
+ } catch {
1324
+ return { heartbeat: null, unreadable: true };
1325
+ }
1326
+ }
1327
+
1328
+ /** Read state/session/attention.json (the supervisor's watchdog record); null when absent or malformed. */
1329
+ function readSessionAttention(agentRoot) {
1058
1330
  if (!agentRoot) return null;
1059
- return safeReadJson(join(resolve(agentRoot), "state", "session", "heartbeat.json"));
1331
+ return safeReadJson(join(resolve(agentRoot), "state", "session", "attention.json"));
1332
+ }
1333
+
1334
+ /**
1335
+ * The launchd label of this seat's main-session job, as
1336
+ * `scripts/local-triggers/generate-plists.sh` and `maestro session` spell it.
1337
+ * "" when the first name cannot be derived.
1338
+ */
1339
+ function sessionJobLabel(agentRoot, first) {
1340
+ // NOT lower-cased: `agentFirstName` deliberately leaves its directory-name
1341
+ // fallback as-is (`~/Maya-ai` -> `Maya`) because the generator does, so
1342
+ // lower-casing an INJECTED first here built a label the plist file does not
1343
+ // carry — and a label that does not match is how a healthy seat gets accused
1344
+ // of `job-absent`, the one verdict that must come from a hard fact.
1345
+ const f = typeof first === "string" && first.trim() ? first.trim() : (agentRoot ? agentFirstName(resolve(agentRoot)) : "");
1346
+ return f ? `ai.maestro.${f}-session` : "";
1347
+ }
1348
+
1349
+ /**
1350
+ * Is the main-session launchd job on this seat? A filename scan of
1351
+ * ~/Library/LaunchAgents — a readdir, NOT a `launchctl list` fork, because this
1352
+ * runs on every presence beat.
1353
+ *
1354
+ * Returns `null` for "could not tell" (no label, no home, an unreadable
1355
+ * directory, a non-darwin box) and only ever `false` when the directory was
1356
+ * read and the label was genuinely not in it: `sessionNote` reports an absence
1357
+ * only from a `false`, never from an unknown.
1358
+ *
1359
+ * @returns {boolean|null}
1360
+ */
1361
+ function sessionJobInstalled(o, label) {
1362
+ if (typeof o.sessionJobInstalled === "boolean") return o.sessionJobInstalled;
1363
+ if (!label) return null;
1364
+ let names = o.launchAgents;
1365
+ if (!Array.isArray(names)) {
1366
+ try {
1367
+ const osImpl = o.os || os;
1368
+ const home = typeof osImpl.homedir === "function" ? String(osImpl.homedir() || "") : "";
1369
+ if (!home) return null;
1370
+ names = readdirSync(join(home, "Library", "LaunchAgents"));
1371
+ } catch {
1372
+ return null; // no directory, no permission, not a Mac — unknown, not absent
1373
+ }
1374
+ }
1375
+ // Case-insensitively: HFS+/APFS are case-preserving but case-INsensitive by
1376
+ // default, so `ai.maestro.Maya-session.plist` and the lower-cased label are
1377
+ // the same file to launchd. A case difference must not read as an absence.
1378
+ const want = label.toLowerCase();
1379
+ return names.some((n) => String(n).replace(/\.plist$/, "").toLowerCase() === want);
1060
1380
  }
1061
1381
 
1062
1382
  // ---------------------------------------------------------------------------
@@ -1357,12 +1677,39 @@ export async function collectStatus(o = {}) {
1357
1677
  // and `machine` open, so this is the only place a new seat-level fact can
1358
1678
  // land without a lock-step hq deploy. `session` stays `null` when idle.
1359
1679
  let door = { frontDoor: "daemon", sessionLive: false };
1680
+ let heartbeat = null;
1681
+ let heartbeatUnreadable = false;
1360
1682
  try {
1361
- const hb = opt.heartbeat !== undefined ? opt.heartbeat : readSessionHeartbeat(opt.agentRoot);
1362
- door = sessionLiveness(hb, nowMs, { staleMs: opt.sessionStaleMs });
1683
+ if (opt.heartbeat !== undefined) heartbeat = opt.heartbeat;
1684
+ else {
1685
+ const hbState = readSessionHeartbeatState(opt.agentRoot);
1686
+ heartbeat = hbState.heartbeat;
1687
+ heartbeatUnreadable = hbState.unreadable;
1688
+ }
1689
+ door = sessionLiveness(heartbeat, nowMs, { staleMs: opt.sessionStaleMs });
1363
1690
  } catch { /* defaults: daemon front door, not live */ }
1364
1691
  machine = { ...machine, ...door };
1365
1692
 
1693
+ // 4b-ii. WHY it is not live (WP-M8) — `machine.sessionNote`, present ONLY
1694
+ // when `sessionLive` is false. The decision is the pure `sessionNote`; this
1695
+ // is just the reads that feed it, and it is fail-open in the same way every
1696
+ // other probe here is: a throw anywhere drops the field, never the beat.
1697
+ if (door.sessionLive !== true) {
1698
+ try {
1699
+ const label = typeof opt.sessionLabel === "string" ? opt.sessionLabel : sessionJobLabel(opt.agentRoot, opt.agentFirst);
1700
+ const note = sessionNote({
1701
+ heartbeat,
1702
+ heartbeatUnreadable: opt.heartbeatUnreadable !== undefined ? opt.heartbeatUnreadable === true : heartbeatUnreadable,
1703
+ attention: opt.attention !== undefined ? opt.attention : readSessionAttention(opt.agentRoot),
1704
+ jobInstalled: sessionJobInstalled(opt, label),
1705
+ label,
1706
+ now: nowMs,
1707
+ staleMs: opt.sessionStaleMs,
1708
+ });
1709
+ if (note) machine.sessionNote = note;
1710
+ } catch { /* no sessionNote — the beat still carries frontDoor/sessionLive */ }
1711
+ }
1712
+
1366
1713
  // 4c. last upgrade outcome (WP-M6) — `machine.upgrade`, absent until the
1367
1714
  // first autoupdate attempt; a corrupt file drops the field, never the beat.
1368
1715
  try {
@@ -1409,6 +1756,11 @@ export const _internals = {
1409
1756
  collectTailnetIp,
1410
1757
  readSdkVersion,
1411
1758
  sessionLiveness,
1759
+ sessionNote,
1760
+ sanitizeNoteDetail,
1761
+ sessionJobLabel,
1762
+ sessionJobInstalled,
1763
+ readSessionAttention,
1412
1764
  upgradeSummary,
1413
1765
  detectClaudeAuth,
1414
1766
  resetAuthProbeCache,