agent-coord-mcp 0.19.1 → 0.24.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.
- package/README.md +1 -1
- package/dist/build.js.map +1 -1
- package/dist/roles.js +10 -0
- package/dist/roles.js.map +1 -1
- package/dist/server.js +39 -35
- package/dist/server.js.map +1 -1
- package/dist/tools/messaging.js +66 -5
- package/dist/tools/messaging.js.map +1 -1
- package/dist/tools/render.js +1 -83
- package/dist/tools/render.js.map +1 -1
- package/dist/tools/shared.js +1 -3
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/transport.js +78 -5
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/work.js +88 -34
- package/dist/tools/work.js.map +1 -1
- package/dist/work.js +1 -291
- package/dist/work.js.map +1 -1
- package/hooks/peek-coord.mjs +0 -0
- package/hooks/roles.mjs +12 -0
- package/hooks/tier.mjs +9 -4
- package/hooks/tmux-pusher.mjs +10 -5
- package/package.json +16 -16
- package/scripts/check-self-dependency.mjs +62 -14
- package/scripts/check-test-count.mjs +3 -3
- package/scripts/coord-node.sh +0 -0
- package/scripts/coord-token.mjs +0 -0
- package/scripts/spawn-agent.sh +0 -0
- package/scripts/stop-agent.sh +0 -0
- package/src/roles.ts +12 -0
- package/src/server.ts +51 -39
- package/src/tools/messaging.ts +62 -3
- package/src/tools/render.ts +1 -80
- package/src/tools/shared.ts +22 -77
- package/src/tools/transport.ts +78 -5
- package/src/tools/work.ts +107 -40
- package/src/work.ts +31 -386
- package/dist/tools.js +0 -1852
- package/dist/tools.js.map +0 -1
package/src/tools/messaging.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { adjustCursors } from "./admin.js";
|
|
2
2
|
import { RECORD_AUTHORITY, resolveRole, roleMatches } from "../roles.js";
|
|
3
|
-
import { ARCHIVE_STATUS_FILE, archiveJsonl, archiveInboxFile, archiveRoomFile } from "../store.js";
|
|
3
|
+
import { ARCHIVE_STATUS_FILE, ARCHIVE_INBOX_DIR, ARCHIVE_ROOMS_DIR, archiveJsonl, archiveInboxFile, archiveRoomFile } from "../store.js";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { existsSync, openSync, watch } from "node:fs";
|
|
6
6
|
import { promises as fsp } from "node:fs";
|
|
@@ -168,8 +168,40 @@ export const sendMessageSchema = {
|
|
|
168
168
|
text: z.string().min(1).optional(),
|
|
169
169
|
kind: z.enum(["decision", "status", "chatter"]).optional(),
|
|
170
170
|
record: messageRecordSchema.optional(),
|
|
171
|
+
// Additive reply link. Validated in the tool (malformed → {ok:false};
|
|
172
|
+
// unknown → store + warn) so the rejection shape matches identity-binding
|
|
173
|
+
// / unknown-recipient, not a schema throw. Existing records unchanged.
|
|
174
|
+
inReplyTo: z.string().optional(),
|
|
171
175
|
};
|
|
172
176
|
|
|
177
|
+
const MESSAGE_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
178
|
+
|
|
179
|
+
async function messageIdExists(id: string): Promise<boolean> {
|
|
180
|
+
const files: string[] = [];
|
|
181
|
+
for (const n of await listInboxFiles()) files.push(path.join(INBOX_DIR, n));
|
|
182
|
+
files.push(ROOM_FILE);
|
|
183
|
+
if (existsSync(ROOMS_DIR)) {
|
|
184
|
+
for (const n of await fsp.readdir(ROOMS_DIR)) {
|
|
185
|
+
if (n.endsWith(".jsonl")) files.push(path.join(ROOMS_DIR, n));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (existsSync(ARCHIVE_INBOX_DIR)) {
|
|
189
|
+
for (const n of await fsp.readdir(ARCHIVE_INBOX_DIR)) {
|
|
190
|
+
if (n.endsWith(".jsonl")) files.push(path.join(ARCHIVE_INBOX_DIR, n));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (existsSync(ARCHIVE_ROOMS_DIR)) {
|
|
194
|
+
for (const n of await fsp.readdir(ARCHIVE_ROOMS_DIR)) {
|
|
195
|
+
if (n.endsWith(".jsonl")) files.push(path.join(ARCHIVE_ROOMS_DIR, n));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
for (const f of files) {
|
|
199
|
+
const entries = await readJsonl<Message>(f);
|
|
200
|
+
if (entries.some((m) => m.id === id)) return true;
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
173
205
|
export async function sendMessageTool(args: {
|
|
174
206
|
from: string;
|
|
175
207
|
to?: string;
|
|
@@ -177,6 +209,7 @@ export async function sendMessageTool(args: {
|
|
|
177
209
|
text?: string;
|
|
178
210
|
kind?: "decision" | "status" | "chatter";
|
|
179
211
|
record?: MessageRecord;
|
|
212
|
+
inReplyTo?: string;
|
|
180
213
|
}) {
|
|
181
214
|
// Record authority first — a sender who may not emit this type is refused
|
|
182
215
|
// before any other check runs, so a rejected record writes nothing anywhere.
|
|
@@ -211,6 +244,28 @@ export async function sendMessageTool(args: {
|
|
|
211
244
|
};
|
|
212
245
|
}
|
|
213
246
|
|
|
247
|
+
let replyWarning: string | undefined;
|
|
248
|
+
if (args.inReplyTo !== undefined) {
|
|
249
|
+
if (!MESSAGE_ID_RE.test(args.inReplyTo)) {
|
|
250
|
+
return {
|
|
251
|
+
ok: false as const,
|
|
252
|
+
error: `inReplyTo '${args.inReplyTo}' is not a message id (expected uuid)`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
if (!(await messageIdExists(args.inReplyTo))) {
|
|
256
|
+
replyWarning =
|
|
257
|
+
`inReplyTo '${args.inReplyTo}' is not a known message — reply stored but no matching parent may exist`;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// The tmux pusher's injection guard (hooks/tmux-pusher.mjs `shouldInject`)
|
|
262
|
+
// never types leading-slash text into a pane — only `send_command`'s
|
|
263
|
+
// allowlist goes in raw. The message is still stored and readable, so say so
|
|
264
|
+
// here instead of letting the sender assume it landed on screen.
|
|
265
|
+
const slashWarning = /^\s*\//.test(text)
|
|
266
|
+
? "text starts with '/' — stored, but tmux-push transports never type leading-slash text into a pane (injection guard); prefix it (e.g. `AGENT_ACTION: run /<skill> …`) or use `send_command` for /clear and /compact"
|
|
267
|
+
: undefined;
|
|
268
|
+
|
|
214
269
|
// DM → inbox. Otherwise resolve the channel (default `general`), make sure it
|
|
215
270
|
// exists in the registry, and tag the message with its channel.
|
|
216
271
|
if (args.to) {
|
|
@@ -221,6 +276,7 @@ export async function sendMessageTool(args: {
|
|
|
221
276
|
to: args.to,
|
|
222
277
|
text,
|
|
223
278
|
...(args.record ? { record: args.record } : {}),
|
|
279
|
+
...(args.inReplyTo ? { inReplyTo: args.inReplyTo } : {}),
|
|
224
280
|
};
|
|
225
281
|
const target = inboxFile(args.to);
|
|
226
282
|
await appendJsonl(target, msg);
|
|
@@ -228,9 +284,10 @@ export async function sendMessageTool(args: {
|
|
|
228
284
|
// typo'd recipient shouldn't vanish silently — surface a warning when the
|
|
229
285
|
// target isn't a known agent so the caller can catch the mistake.
|
|
230
286
|
const reg = await readJson<AgentRegistry>(AGENTS_FILE, {});
|
|
231
|
-
const
|
|
287
|
+
const recipientWarning = reg[args.to]
|
|
232
288
|
? undefined
|
|
233
289
|
: `recipient '${args.to}' is not a registered agent — message stored in their inbox but no one may be listening`;
|
|
290
|
+
const warning = [recipientWarning, replyWarning, slashWarning].filter(Boolean).join("; ") || undefined;
|
|
234
291
|
return { ok: true, id: msg.id, target, room: undefined, warning };
|
|
235
292
|
}
|
|
236
293
|
|
|
@@ -244,11 +301,13 @@ export async function sendMessageTool(args: {
|
|
|
244
301
|
text,
|
|
245
302
|
...(args.kind ? { kind: args.kind } : {}),
|
|
246
303
|
...(args.record ? { record: args.record } : {}),
|
|
304
|
+
...(args.inReplyTo ? { inReplyTo: args.inReplyTo } : {}),
|
|
247
305
|
};
|
|
248
306
|
const target = roomFile(chan);
|
|
249
307
|
await appendJsonl(target, msg);
|
|
250
308
|
await maybeCompactRoom(chan);
|
|
251
|
-
|
|
309
|
+
const roomWarning = [replyWarning, slashWarning].filter(Boolean).join("; ") || undefined;
|
|
310
|
+
return { ok: true, id: msg.id, target, room: chan, ...(roomWarning ? { warning: roomWarning } : {}) };
|
|
252
311
|
}
|
|
253
312
|
|
|
254
313
|
// ---------- live compaction (self-limiting streams) ----------
|
package/src/tools/render.ts
CHANGED
|
@@ -1,80 +1 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// Every consumer downstream of a message — hooks/tier.mjs's prefix table, the
|
|
4
|
-
// UI's alert parser, a human reading a tmux pane — reads `text`. Phase 8 adds
|
|
5
|
-
// `record` alongside it, so the rendering must reproduce the byte layout those
|
|
6
|
-
// consumers already expect. Nothing downstream changes while agents migrate.
|
|
7
|
-
//
|
|
8
|
-
// This is a pure function of the record: no clock, no I/O, no registry. It
|
|
9
|
-
// never sees `from`, `to`, or anything the sender could use it to forge.
|
|
10
|
-
|
|
11
|
-
import type { DecisionPayload, MessageRecord, SummaryPayload, VerdictPayload } from "./shared.js";
|
|
12
|
-
|
|
13
|
-
// The case-sensitive prefixes at byte 0 of `text`, exactly as classifyTier
|
|
14
|
-
// matches them. `decision` is absent because it renders as a multi-line block,
|
|
15
|
-
// not a one-liner.
|
|
16
|
-
const PREFIX: Record<string, string> = {
|
|
17
|
-
blocker: "BLOCKER",
|
|
18
|
-
risk: "RISK",
|
|
19
|
-
done: "DONE",
|
|
20
|
-
fyi: "FYI",
|
|
21
|
-
action: "AGENT_ACTION",
|
|
22
|
-
go: "GO",
|
|
23
|
-
scope: "SCOPE CHANGE",
|
|
24
|
-
// `verdict` has no prefix in the v1 vocabulary — it is new in Phase 8. A gate
|
|
25
|
-
// PASS/FAIL was previously posted as prose, which is why verdicts could not
|
|
26
|
-
// be routed. Rendering it under its own prefix gives it one.
|
|
27
|
-
verdict: "VERDICT",
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
function isNonEmpty(v: unknown): v is string {
|
|
31
|
-
return typeof v === "string" && v.trim().length > 0;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// The playbook's decision packet (§Decision Packet Format), byte-for-byte —
|
|
35
|
-
// the UI parses this into a clickable decision card, and a layout that drifts
|
|
36
|
-
// still alerts loudly but loses the card. Returns null when any of the five
|
|
37
|
-
// fields is missing, so the caller can reject rather than emit a half packet.
|
|
38
|
-
function renderDecision(p: DecisionPayload): string | null {
|
|
39
|
-
if (!isNonEmpty(p.title) || !isNonEmpty(p.context)) return null;
|
|
40
|
-
if (!isNonEmpty(p.recommendation) || !isNonEmpty(p.ifNoAction)) return null;
|
|
41
|
-
if (!Array.isArray(p.options) || p.options.length === 0) return null;
|
|
42
|
-
if (!p.options.every(isNonEmpty)) return null;
|
|
43
|
-
return [
|
|
44
|
-
`DAVID_DECISION: ${p.title}`,
|
|
45
|
-
`Context: ${p.context}`,
|
|
46
|
-
"Options:",
|
|
47
|
-
...p.options.map((o, i) => `${i + 1}. ${o}`),
|
|
48
|
-
`Recommendation: ${p.recommendation}`,
|
|
49
|
-
`If no action: ${p.ifNoAction}`,
|
|
50
|
-
].join("\n");
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// `VERDICT: PASS <sha> — <notes>`. The sha is not optional in the rendering:
|
|
54
|
-
// a verdict that doesn't name the commit it was issued against is
|
|
55
|
-
// unfalsifiable the moment the branch moves.
|
|
56
|
-
function renderVerdict(p: VerdictPayload): string | null {
|
|
57
|
-
if (p.result !== "pass" && p.result !== "fail") return null;
|
|
58
|
-
if (!isNonEmpty(p.headRefOid)) return null;
|
|
59
|
-
const head = `${PREFIX.verdict}: ${p.result.toUpperCase()} ${p.headRefOid}`;
|
|
60
|
-
return isNonEmpty(p.notes) ? `${head} — ${p.notes}` : head;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Render a record to its text form, or null when the payload can't support one
|
|
64
|
-
// (absent, or missing a field the layout needs). Null is not an error here —
|
|
65
|
-
// the caller decides whether a record without a rendering is fatal, and it only
|
|
66
|
-
// is when there's no author-supplied `text` to fall back on.
|
|
67
|
-
export function renderRecord(record: MessageRecord): string | null {
|
|
68
|
-
if (!record || typeof record.type !== "string") return null;
|
|
69
|
-
const payload = record.payload as unknown;
|
|
70
|
-
if (payload === undefined || payload === null) return null;
|
|
71
|
-
if (typeof payload !== "object" || Array.isArray(payload)) return null;
|
|
72
|
-
|
|
73
|
-
if (record.type === "decision") return renderDecision(payload as DecisionPayload);
|
|
74
|
-
if (record.type === "verdict") return renderVerdict(payload as VerdictPayload);
|
|
75
|
-
|
|
76
|
-
const prefix = PREFIX[record.type];
|
|
77
|
-
if (!prefix) return null;
|
|
78
|
-
const { summary } = payload as SummaryPayload;
|
|
79
|
-
return isNonEmpty(summary) ? `${prefix}: ${summary}` : null;
|
|
80
|
-
}
|
|
1
|
+
export { renderRecord, PREFIX } from "@davidbalzan/groundwork-seam/protocol";
|
package/src/tools/shared.ts
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Citation,
|
|
3
|
+
DecisionPayload,
|
|
4
|
+
MessageRecord,
|
|
5
|
+
MessageRecordType,
|
|
6
|
+
SummaryPayload,
|
|
7
|
+
SummaryRecordType,
|
|
8
|
+
VerdictPayload,
|
|
9
|
+
} from "@davidbalzan/groundwork-seam/protocol";
|
|
1
10
|
import { randomUUID } from "node:crypto";
|
|
2
11
|
import { existsSync, openSync, watch } from "node:fs";
|
|
3
12
|
import { promises as fsp } from "node:fs";
|
|
@@ -92,82 +101,16 @@ export type TransportMarker = {
|
|
|
92
101
|
|
|
93
102
|
export type AgentRegistry = Record<string, AgentEntry>;
|
|
94
103
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
104
|
+
export type {
|
|
105
|
+
Citation,
|
|
106
|
+
MessageRecordType,
|
|
107
|
+
DecisionPayload,
|
|
108
|
+
VerdictPayload,
|
|
109
|
+
SummaryPayload,
|
|
110
|
+
SummaryRecordType,
|
|
111
|
+
MessageRecord,
|
|
101
112
|
};
|
|
102
113
|
|
|
103
|
-
// The protocol vocabulary the fleet already speaks. Today these live as
|
|
104
|
-
// case-sensitive prefixes at byte 0 of `text` (hooks/tier.mjs), parsed a
|
|
105
|
-
// second time by the UI for alert priority — so a greeting before the prefix
|
|
106
|
-
// silently downgrades a production blocker, and the two parsers can disagree
|
|
107
|
-
// about the same message. As a field there is nothing to mis-parse.
|
|
108
|
-
export type MessageRecordType =
|
|
109
|
-
| "blocker" // BLOCKER: work cannot continue
|
|
110
|
-
| "decision" // DAVID_DECISION: the human must decide
|
|
111
|
-
| "risk" // RISK: quality/security/cost/product risk
|
|
112
|
-
| "done" // DONE: completed work, must cite
|
|
113
|
-
| "fyi" // FYI: no action needed
|
|
114
|
-
| "action" // AGENT_ACTION: another agent can handle it
|
|
115
|
-
| "go" // GO: a work order
|
|
116
|
-
| "scope" // SCOPE CHANGE: amends a contract in flight
|
|
117
|
-
| "verdict"; // (new) a gate PASS/FAIL — has no prefix today
|
|
118
|
-
|
|
119
|
-
// The five fields of the playbook's decision packet (§Decision Packet Format).
|
|
120
|
-
// Named to match that layout because `renderRecord` reproduces it byte-for-byte
|
|
121
|
-
// — the UI parses the rendering into a clickable decision card.
|
|
122
|
-
export type DecisionPayload = {
|
|
123
|
-
title: string;
|
|
124
|
-
context: string;
|
|
125
|
-
options: string[];
|
|
126
|
-
recommendation: string;
|
|
127
|
-
ifNoAction: string;
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
// A gate verdict. `headRefOid` pins WHICH commit was gated: a PASS that doesn't
|
|
131
|
-
// name the sha it was issued against is unfalsifiable once the branch moves.
|
|
132
|
-
export type VerdictPayload = {
|
|
133
|
-
result: "pass" | "fail";
|
|
134
|
-
headRefOid: string;
|
|
135
|
-
notes?: string;
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
// Everything else carries prose. One line, because these render as
|
|
139
|
-
// `<PREFIX>: <summary>` and a paragraph in a tmux pane is a wall, not a status.
|
|
140
|
-
export type SummaryPayload = { summary: string };
|
|
141
|
-
|
|
142
|
-
// Message types whose payload is just a summary.
|
|
143
|
-
export type SummaryRecordType = "blocker" | "risk" | "fyi" | "action" | "go" | "scope";
|
|
144
|
-
|
|
145
|
-
// Structured counterpart to `text`, NOT a replacement: `text` is the rendering,
|
|
146
|
-
// and a tmux pane can only receive text. A v1 agent that has never heard of
|
|
147
|
-
// `record` omits it entirely and behaves byte-identically.
|
|
148
|
-
//
|
|
149
|
-
// `payload` stays OPTIONAL on every arm — Phase 8 is additive, so no new
|
|
150
|
-
// required field may appear on the wire. What is pinned is the shape *if* a
|
|
151
|
-
// payload is supplied: a `decision` carrying three of its five fields is
|
|
152
|
-
// structurally wrong for the type it claims and is rejected, while a payload
|
|
153
|
-
// with extra unknown keys passes through untouched (a v3 sender must not be
|
|
154
|
-
// broken by a v2 server, and stripping would silently drop data on the way to
|
|
155
|
-
// disk).
|
|
156
|
-
//
|
|
157
|
-
// `cites` is likewise optional here. `done` requires a PR citation, but that is
|
|
158
|
-
// enforced in sendMessageTool as a plain {ok:false,error} rather than a schema
|
|
159
|
-
// rejection — see the identity-binding precedent in src/server.ts.
|
|
160
|
-
//
|
|
161
|
-
// UNTRUSTED, exactly like `from`. A peer can claim any type here, so trust
|
|
162
|
-
// decisions (the SCOPE countersignature, gate-runner routing) must still
|
|
163
|
-
// resolve the sender against the registry — typed is not the same as
|
|
164
|
-
// authenticated, and `record` must never become a path to setting `urgent`.
|
|
165
|
-
export type MessageRecord =
|
|
166
|
-
| { type: "decision"; payload?: DecisionPayload; cites?: Citation[] }
|
|
167
|
-
| { type: "verdict"; payload?: VerdictPayload; cites?: Citation[] }
|
|
168
|
-
| { type: "done"; payload?: SummaryPayload; cites?: Citation[] }
|
|
169
|
-
| { type: SummaryRecordType; payload?: SummaryPayload; cites?: Citation[] };
|
|
170
|
-
|
|
171
114
|
export type Message = {
|
|
172
115
|
id: string;
|
|
173
116
|
ts: number;
|
|
@@ -198,6 +141,10 @@ export type Message = {
|
|
|
198
141
|
kind?: "decision" | "status" | "chatter";
|
|
199
142
|
// Typed protocol record (Phase 8). Optional and additive; see MessageRecord.
|
|
200
143
|
record?: MessageRecord;
|
|
144
|
+
// Additive reply link (optional). A DM that answers a DAVID_DECISION
|
|
145
|
+
// (or any other message) carries the parent message id so consumers can
|
|
146
|
+
// mark the packet answered. Absent on every v1/v2 message — those stay valid.
|
|
147
|
+
inReplyTo?: string;
|
|
201
148
|
};
|
|
202
149
|
|
|
203
150
|
// THE retention predicate — one definition, three call sites (prune,
|
|
@@ -215,9 +162,7 @@ export type Message = {
|
|
|
215
162
|
//
|
|
216
163
|
// Deliberately structural about `record`: it takes anything with the two
|
|
217
164
|
// fields, so status entries and archive rows can be passed without a cast.
|
|
218
|
-
export
|
|
219
|
-
return e?.kind === "decision" || e?.record?.type === "decision";
|
|
220
|
-
}
|
|
165
|
+
export { isDecision } from "@davidbalzan/groundwork-seam/protocol";
|
|
221
166
|
|
|
222
167
|
export type StatusEntry = {
|
|
223
168
|
id: string;
|
package/src/tools/transport.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { roleInputSchema, type RoleArg } from "../roles.js";
|
|
|
5
5
|
import { attributeWriter, isGitRepo, lastWriterOf, loadScopes, ownsDocument } from "./scopes.js";
|
|
6
6
|
import { sendMessageTool, readMessagesTool } from "./messaging.js";
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
|
-
import { existsSync, openSync, watch } from "node:fs";
|
|
8
|
+
import { existsSync, openSync, readFileSync, watch } from "node:fs";
|
|
9
9
|
import { promises as fsp } from "node:fs";
|
|
10
10
|
import { spawn, spawnSync } from "node:child_process";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
@@ -699,6 +699,63 @@ function resolvePusherPath(): string {
|
|
|
699
699
|
return path.resolve(here, "..", "..", "hooks", "tmux-pusher.mjs");
|
|
700
700
|
}
|
|
701
701
|
|
|
702
|
+
/** Running server package + optional git identity. `fromDir` is a test seam. */
|
|
703
|
+
export function resolveServerIdentity(fromDir?: string): {
|
|
704
|
+
path: string;
|
|
705
|
+
version: string;
|
|
706
|
+
branch?: string;
|
|
707
|
+
sha?: string;
|
|
708
|
+
} {
|
|
709
|
+
const start = fromDir ?? path.dirname(fileURLToPath(import.meta.url));
|
|
710
|
+
let dir = start;
|
|
711
|
+
let pkgFile: string | undefined;
|
|
712
|
+
for (let i = 0; i < 8; i++) {
|
|
713
|
+
const candidate = path.join(dir, "package.json");
|
|
714
|
+
if (existsSync(candidate)) {
|
|
715
|
+
pkgFile = candidate;
|
|
716
|
+
break;
|
|
717
|
+
}
|
|
718
|
+
const parent = path.dirname(dir);
|
|
719
|
+
if (parent === dir) break;
|
|
720
|
+
dir = parent;
|
|
721
|
+
}
|
|
722
|
+
const pkgPath = pkgFile ?? path.resolve(start, "..", "..", "package.json");
|
|
723
|
+
const pkgDir = path.dirname(pkgPath);
|
|
724
|
+
let version = "unknown";
|
|
725
|
+
try {
|
|
726
|
+
const raw = JSON.parse(readFileSync(pkgPath, "utf8")) as { version?: string };
|
|
727
|
+
if (typeof raw.version === "string" && raw.version) version = raw.version;
|
|
728
|
+
} catch {
|
|
729
|
+
/* leave unknown — never invent a version */
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const out: { path: string; version: string; branch?: string; sha?: string } = {
|
|
733
|
+
path: pkgDir,
|
|
734
|
+
version,
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
dir = pkgDir;
|
|
738
|
+
let gitRoot: string | undefined;
|
|
739
|
+
for (let i = 0; i < 10; i++) {
|
|
740
|
+
if (existsSync(path.join(dir, ".git"))) {
|
|
741
|
+
gitRoot = dir;
|
|
742
|
+
break;
|
|
743
|
+
}
|
|
744
|
+
const parent = path.dirname(dir);
|
|
745
|
+
if (parent === dir) break;
|
|
746
|
+
dir = parent;
|
|
747
|
+
}
|
|
748
|
+
if (gitRoot && isGitRepo(gitRoot)) {
|
|
749
|
+
const branch = spawnSync("git", ["-C", gitRoot, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
|
|
750
|
+
const sha = spawnSync("git", ["-C", gitRoot, "rev-parse", "HEAD"], { encoding: "utf8" });
|
|
751
|
+
const b = branch.status === 0 ? String(branch.stdout).trim() : "";
|
|
752
|
+
const s = sha.status === 0 ? String(sha.stdout).trim() : "";
|
|
753
|
+
if (b) out.branch = b;
|
|
754
|
+
if (s) out.sha = s;
|
|
755
|
+
}
|
|
756
|
+
return out;
|
|
757
|
+
}
|
|
758
|
+
|
|
702
759
|
// Freshness basis for the stale-pusher-script mechanism: the newest mtime
|
|
703
760
|
// across the pusher's source dir (hooks/*.mjs), NOT just the entry file. The
|
|
704
761
|
// pusher imports submit.mjs / tier.mjs / roles.mjs, so a fix touching only an
|
|
@@ -1586,18 +1643,34 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1586
1643
|
});
|
|
1587
1644
|
}
|
|
1588
1645
|
|
|
1589
|
-
// 10. Environment sanity. Report only.
|
|
1646
|
+
// 10. Environment sanity. Report only. Path + version come from the running
|
|
1647
|
+
// module's package.json so a stale/decoy bin is named (LESSONS #35). Git
|
|
1648
|
+
// identity is walk-up-to-.git + rev-parse (kit monorepo root); omitted
|
|
1649
|
+
// entirely when there is no checkout — never invented.
|
|
1590
1650
|
{
|
|
1591
1651
|
const tmuxProbe = spawnSync("tmux", ["-V"]);
|
|
1592
1652
|
const tmuxOk = tmuxProbe.status === 0;
|
|
1653
|
+
const ident = resolveServerIdentity();
|
|
1654
|
+
const loc = ident.branch && ident.sha
|
|
1655
|
+
? `path=${ident.path} version=${ident.version} ${ident.branch}@${ident.sha.slice(0, 12)}`
|
|
1656
|
+
: `path=${ident.path} version=${ident.version}`;
|
|
1657
|
+
const items = [
|
|
1658
|
+
`root=${ROOT}`,
|
|
1659
|
+
`execPath=${process.execPath}`,
|
|
1660
|
+
`inTmux=${!!process.env.TMUX_PANE}`,
|
|
1661
|
+
`path=${ident.path}`,
|
|
1662
|
+
`version=${ident.version}`,
|
|
1663
|
+
];
|
|
1664
|
+
if (ident.branch) items.push(`branch=${ident.branch}`);
|
|
1665
|
+
if (ident.sha) items.push(`sha=${ident.sha}`);
|
|
1593
1666
|
findings.push({
|
|
1594
1667
|
check: "environment",
|
|
1595
1668
|
level: tmuxOk ? "ok" : "warn",
|
|
1596
1669
|
detail: tmuxOk
|
|
1597
|
-
? `root=${ROOT}; node=${process.execPath}; tmux=${(tmuxProbe.stdout ?? "").toString().trim() || "present"}`
|
|
1598
|
-
: `root=${ROOT}; node=${process.execPath}; tmux NOT on PATH — the tmux-push transport will not work`,
|
|
1670
|
+
? `root=${ROOT}; node=${process.execPath}; ${loc}; tmux=${(tmuxProbe.stdout ?? "").toString().trim() || "present"}`
|
|
1671
|
+
: `root=${ROOT}; node=${process.execPath}; ${loc}; tmux NOT on PATH — the tmux-push transport will not work`,
|
|
1599
1672
|
fixable: false,
|
|
1600
|
-
items
|
|
1673
|
+
items,
|
|
1601
1674
|
});
|
|
1602
1675
|
}
|
|
1603
1676
|
|