@mmnto/cli 1.114.0 → 1.116.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/dist/commands/doctor-compliance.test.js +17 -0
- package/dist/commands/doctor-compliance.test.js.map +1 -1
- package/dist/commands/doctor-seat-identity.d.ts +87 -0
- package/dist/commands/doctor-seat-identity.d.ts.map +1 -0
- package/dist/commands/doctor-seat-identity.js +427 -0
- package/dist/commands/doctor-seat-identity.js.map +1 -0
- package/dist/commands/doctor-seat-identity.test.d.ts +13 -0
- package/dist/commands/doctor-seat-identity.test.d.ts.map +1 -0
- package/dist/commands/doctor-seat-identity.test.js +390 -0
- package/dist/commands/doctor-seat-identity.test.js.map +1 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +3 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +1 -0
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/ecl-gc.d.ts.map +1 -1
- package/dist/commands/ecl-gc.js +18 -7
- package/dist/commands/ecl-gc.js.map +1 -1
- package/dist/commands/ecl-gc.test.js +63 -1
- package/dist/commands/ecl-gc.test.js.map +1 -1
- package/dist/commands/eject.d.ts +62 -3
- package/dist/commands/eject.d.ts.map +1 -1
- package/dist/commands/eject.js +382 -47
- package/dist/commands/eject.js.map +1 -1
- package/dist/commands/eject.test.js +557 -7
- package/dist/commands/eject.test.js.map +1 -1
- package/dist/commands/gemini-beforetool-contract.test.d.ts +2 -0
- package/dist/commands/gemini-beforetool-contract.test.d.ts.map +1 -0
- package/dist/commands/gemini-beforetool-contract.test.js +106 -0
- package/dist/commands/gemini-beforetool-contract.test.js.map +1 -0
- package/dist/commands/gemini-sessionstart-contract.test.d.ts +2 -0
- package/dist/commands/gemini-sessionstart-contract.test.d.ts.map +1 -0
- package/dist/commands/gemini-sessionstart-contract.test.js +199 -0
- package/dist/commands/gemini-sessionstart-contract.test.js.map +1 -0
- package/dist/commands/init-templates.d.ts +4 -4
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +279 -16
- package/dist/commands/init-templates.js.map +1 -1
- package/dist/commands/init.test.js +25 -7
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/install-hooks-exit-contract.test.js +5 -0
- package/dist/commands/install-hooks-exit-contract.test.js.map +1 -1
- package/dist/commands/mail-degraded-e4.test.js +126 -0
- package/dist/commands/mail-degraded-e4.test.js.map +1 -1
- package/dist/commands/mail.d.ts +19 -1
- package/dist/commands/mail.d.ts.map +1 -1
- package/dist/commands/mail.js +195 -30
- package/dist/commands/mail.js.map +1 -1
- package/dist/commands/mail.test.js +291 -3
- package/dist/commands/mail.test.js.map +1 -1
- package/dist/commands/orient.d.ts.map +1 -1
- package/dist/commands/orient.js +82 -0
- package/dist/commands/orient.js.map +1 -1
- package/dist/commands/orient.test.js +144 -1
- package/dist/commands/orient.test.js.map +1 -1
- package/dist/commands/retrospect.d.ts.map +1 -1
- package/dist/commands/retrospect.js +7 -3
- package/dist/commands/retrospect.js.map +1 -1
- package/dist/commands/seat-cli-wiring.test.d.ts +20 -0
- package/dist/commands/seat-cli-wiring.test.d.ts.map +1 -0
- package/dist/commands/seat-cli-wiring.test.js +147 -0
- package/dist/commands/seat-cli-wiring.test.js.map +1 -0
- package/dist/commands/seat-poll-chain.test.d.ts +13 -0
- package/dist/commands/seat-poll-chain.test.d.ts.map +1 -0
- package/dist/commands/seat-poll-chain.test.js +103 -0
- package/dist/commands/seat-poll-chain.test.js.map +1 -0
- package/dist/commands/seat.d.ts +92 -0
- package/dist/commands/seat.d.ts.map +1 -0
- package/dist/commands/seat.js +649 -0
- package/dist/commands/seat.js.map +1 -0
- package/dist/commands/seat.test.d.ts +19 -0
- package/dist/commands/seat.test.d.ts.map +1 -0
- package/dist/commands/seat.test.js +532 -0
- package/dist/commands/seat.test.js.map +1 -0
- package/dist/commands/sessionstart-manifest-contract.test.d.ts +2 -0
- package/dist/commands/sessionstart-manifest-contract.test.d.ts.map +1 -0
- package/dist/commands/sessionstart-manifest-contract.test.js +186 -0
- package/dist/commands/sessionstart-manifest-contract.test.js.map +1 -0
- package/dist/commands/triage-pr.d.ts.map +1 -1
- package/dist/commands/triage-pr.js +2 -0
- package/dist/commands/triage-pr.js.map +1 -1
- package/dist/hooks/__tests__/auto-context.test.js +20 -0
- package/dist/hooks/__tests__/auto-context.test.js.map +1 -1
- package/dist/hooks/auto-context.d.ts +19 -0
- package/dist/hooks/auto-context.d.ts.map +1 -1
- package/dist/hooks/auto-context.js +22 -4
- package/dist/hooks/auto-context.js.map +1 -1
- package/dist/index.js +71 -0
- package/dist/index.js.map +1 -1
- package/dist/parsers/bot-review-parser.d.ts +8 -1
- package/dist/parsers/bot-review-parser.d.ts.map +1 -1
- package/dist/parsers/bot-review-parser.js +21 -1
- package/dist/parsers/bot-review-parser.js.map +1 -1
- package/dist/parsers/bot-review-parser.test.js +23 -0
- package/dist/parsers/bot-review-parser.test.js.map +1 -1
- package/dist/parsers/triage-severity-mapper.d.ts.map +1 -1
- package/dist/parsers/triage-severity-mapper.js +32 -1
- package/dist/parsers/triage-severity-mapper.js.map +1 -1
- package/dist/parsers/triage-severity-mapper.test.js +57 -0
- package/dist/parsers/triage-severity-mapper.test.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `totem seat add | suspend | remove | list` — the declared seat-lifecycle
|
|
3
|
+
* verbs (mmnto-ai/totem#2511 slice 2).
|
|
4
|
+
*
|
|
5
|
+
* Seat EXISTENCE stays dir-derived (the mmnto-ai/totem#2141 dirs-union
|
|
6
|
+
* invariant): these verbs do not mint a second registration surface. What they
|
|
7
|
+
* add is the DECLARED half — an `active | suspended | retired` marker written
|
|
8
|
+
* by an operator word, which downstream consumers (broadcast denominators,
|
|
9
|
+
* annotations, the totem-status#127 session projection) read. Session state
|
|
10
|
+
* (`LIVE/IDLE/FAULTED`) is a different axis and never appears here.
|
|
11
|
+
*
|
|
12
|
+
* Two disciplines run through every verb:
|
|
13
|
+
* - **Emit, never apply.** A seat's birth and retirement touch surfaces this
|
|
14
|
+
* binary cannot reach honestly — vendor hook files (mmnto-ai/totem#2612 is
|
|
15
|
+
* the live tracked-file-mutation bug class), `totem-status`'s
|
|
16
|
+
* `agentHomeMap`, the mmnto-ai/totem-strategy#611-frozen `eclCohortRepos`,
|
|
17
|
+
* four prose copies of the signoff seat table, the strategy-owned parity
|
|
18
|
+
* manifest. Every one of them is printed as a checklist line and NONE of
|
|
19
|
+
* them is written.
|
|
20
|
+
* - **Nothing is deleted.** `seat remove` writes a `retired` tombstone and
|
|
21
|
+
* leaves the outbox, processed marks, and journals exactly where they are;
|
|
22
|
+
* retention policy is mmnto-ai/totem#2518's lane. Lifecycle transitions
|
|
23
|
+
* also never discharge an obligation — that is an explicit disposition
|
|
24
|
+
* with provenance (mmnto-ai/totem-status#127).
|
|
25
|
+
*
|
|
26
|
+
* All human output goes to STDOUT as one stream. The product of these verbs is
|
|
27
|
+
* a report an operator acts on and pipes (`totem seat add x > wiring.txt`), so
|
|
28
|
+
* splitting it across stdout/stderr would hand over a checklist with holes in
|
|
29
|
+
* it. Failures still travel as `TotemError` to the index.ts error boundary,
|
|
30
|
+
* which prints them on stderr and sets a non-zero exit.
|
|
31
|
+
*
|
|
32
|
+
* The marker schema lives in core (`seat-lifecycle.ts`) and is never redefined
|
|
33
|
+
* here: this layer composes markers and hands them to `writeSeatLifecycle`.
|
|
34
|
+
*/
|
|
35
|
+
import * as fs from 'node:fs';
|
|
36
|
+
import * as path from 'node:path';
|
|
37
|
+
// ─── Constants ──────────────────────────────────────────
|
|
38
|
+
const TOTEM_DIR = '.totem';
|
|
39
|
+
const ORCHESTRATION_DIR = 'orchestration';
|
|
40
|
+
/** The optional `host_agents` override file (`ConfigSchema.passthrough()`). */
|
|
41
|
+
const CONFIG_FILENAME = 'config.json';
|
|
42
|
+
const HOST_AGENTS_KEY = 'host_agents';
|
|
43
|
+
/** The three per-seat coordination subdirs a birth creates (ADR-106 § 3). */
|
|
44
|
+
const SEAT_SUBDIRS = ['outbox', 'processed', 'journal'];
|
|
45
|
+
/**
|
|
46
|
+
* Env var whose FIRST usable entry attributes a transition. Unset (or holding
|
|
47
|
+
* nothing usable) means the marker's `by` is honestly ABSENT — a stamped
|
|
48
|
+
* absence, never a fabricated attribution (the mmnto-ai/totem#2625 MB-2 rule).
|
|
49
|
+
*/
|
|
50
|
+
const SELF_AGENT_ENV_VAR = 'TOTEM_SELF_AGENT';
|
|
51
|
+
/** Width of the `──` rules that delimit an emitted checklist block. */
|
|
52
|
+
const RULE_WIDTH = 74;
|
|
53
|
+
/** Column floors for the `seat list` table, so headers never collapse. */
|
|
54
|
+
const LIST_COLUMN_MIN_WIDTHS = { seat: 4, state: 5, since: 5, source: 6 };
|
|
55
|
+
/** Placeholder for a column with nothing to render (a markerless seat). */
|
|
56
|
+
const EMPTY_CELL = '—';
|
|
57
|
+
// ─── Local helpers ──────────────────────────────────────
|
|
58
|
+
function describe(err) {
|
|
59
|
+
return err instanceof Error ? err.message : String(err);
|
|
60
|
+
}
|
|
61
|
+
/** One `process.stdout.write` per block, so a report never interleaves. */
|
|
62
|
+
function emit(lines) {
|
|
63
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
64
|
+
}
|
|
65
|
+
function rule() {
|
|
66
|
+
return '─'.repeat(RULE_WIDTH);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* `throwIfNoEntry: false` suppresses ENOENT only; any OTHER stat failure
|
|
70
|
+
* (EACCES, ENOTDIR, ELOOP) is rethrown as a TotemError naming the path — a
|
|
71
|
+
* permission wall must fail loud, never read as "seat does not exist" (a
|
|
72
|
+
* false-unknown would route the operator to `seat add` on a tree they cannot
|
|
73
|
+
* even stat).
|
|
74
|
+
*/
|
|
75
|
+
function directoryExists(target, TotemErrorCtor) {
|
|
76
|
+
let stat;
|
|
77
|
+
try {
|
|
78
|
+
stat = fs.statSync(target, { throwIfNoEntry: false });
|
|
79
|
+
// totem-context: intentional rethrow-as-TotemError — non-ENOENT stat failures must surface loudly with the path, not degrade to "not a directory here".
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
throw new TotemErrorCtor('CHECK_FAILED', `cannot stat ${target}: ${String(err)}`, 'check permissions on the .totem/orchestration tree.', err);
|
|
83
|
+
}
|
|
84
|
+
return stat !== undefined && stat.isDirectory();
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The acting seat for a marker's `by`: the SINGLE usable `TOTEM_SELF_AGENT`
|
|
88
|
+
* entry, or `undefined`. Never a guess — an unset env records the transition
|
|
89
|
+
* without attribution, and a MULTI-seat env list is ambiguous, so under the
|
|
90
|
+
* stamped-absence rule (#2625 MB-2) it also records absence: "the first entry"
|
|
91
|
+
* is no evidence that seat ran the verb.
|
|
92
|
+
*/
|
|
93
|
+
function resolveActingSeat(env, isSafe) {
|
|
94
|
+
const raw = env[SELF_AGENT_ENV_VAR];
|
|
95
|
+
if (typeof raw !== 'string' || raw.trim().length === 0) {
|
|
96
|
+
return { by: undefined, ambiguous: false, setButUnusable: false };
|
|
97
|
+
}
|
|
98
|
+
const usable = raw
|
|
99
|
+
.split(',')
|
|
100
|
+
.map((entry) => entry.trim())
|
|
101
|
+
.filter(isSafe);
|
|
102
|
+
if (usable.length === 1)
|
|
103
|
+
return { by: usable[0], ambiguous: false, setButUnusable: false };
|
|
104
|
+
// Distinct absences (re-arm P-3): a SET-but-unusable env must not render as
|
|
105
|
+
// "not set" — a false fact about the environment inside a feature whose
|
|
106
|
+
// thesis is stamped absence over invention.
|
|
107
|
+
return { by: undefined, ambiguous: usable.length > 1, setButUnusable: usable.length === 0 };
|
|
108
|
+
}
|
|
109
|
+
/** One-line disclosure when ambiguity (not absence) caused the `by` omission. */
|
|
110
|
+
function attributionNote(acting) {
|
|
111
|
+
return acting.ambiguous
|
|
112
|
+
? [
|
|
113
|
+
` · by omitted: ${SELF_AGENT_ENV_VAR} lists multiple seats — the marker records`,
|
|
114
|
+
' stamped absence rather than a guess at which one acted.',
|
|
115
|
+
]
|
|
116
|
+
: [];
|
|
117
|
+
}
|
|
118
|
+
/** `state · since · by · reason` for a one-line marker echo. */
|
|
119
|
+
function describeMarker(marker) {
|
|
120
|
+
const parts = [`state ${marker.state}`, `since ${marker.since}`];
|
|
121
|
+
parts.push(marker.by === undefined ? 'by (not recorded)' : `by ${marker.by}`);
|
|
122
|
+
if (marker.reason !== undefined)
|
|
123
|
+
parts.push(`reason ${JSON.stringify(marker.reason)}`);
|
|
124
|
+
return parts.join(' · ');
|
|
125
|
+
}
|
|
126
|
+
// ─── Shared prose ───────────────────────────────────────
|
|
127
|
+
/**
|
|
128
|
+
* Lifecycle never closes an obligation edge. Printed by both exclusion verbs
|
|
129
|
+
* because the whole failure this feature is aimed at is a seat that stops
|
|
130
|
+
* answering while its obligations quietly look discharged.
|
|
131
|
+
*/
|
|
132
|
+
const OBLIGATIONS_NOTE = [
|
|
133
|
+
' · Open obligations held by this seat are NOT discharged. A lifecycle',
|
|
134
|
+
' transition is not a disposition — dispose of each edge explicitly, with',
|
|
135
|
+
' provenance, per mmnto-ai/totem-status#127.',
|
|
136
|
+
];
|
|
137
|
+
// ─── Core-bound helpers ─────────────────────────────────
|
|
138
|
+
/**
|
|
139
|
+
* Every helper that touches a core VALUE lives behind this factory and closes
|
|
140
|
+
* over the one dynamically-imported barrel instance (a static value import
|
|
141
|
+
* would defeat the lazy-loading contract). Bodies are otherwise ordinary
|
|
142
|
+
* module helpers — the factory exists only to carry `core`.
|
|
143
|
+
*/
|
|
144
|
+
function coreHelpers(core) {
|
|
145
|
+
const { deriveSeatStatuses, isPathSafeAgentId, readSeatLifecycle, resolveTotemRepoRootSync, sanitizeForTerminal, SEAT_LIFECYCLE_SCHEMA_VERSION, TotemConfigError, TotemError, writeFileAtomicSync, } = core;
|
|
146
|
+
/**
|
|
147
|
+
* Repo roots and error text originate outside this process: sanitize and
|
|
148
|
+
* flatten before any terminal write so embedded ANSI or newlines cannot
|
|
149
|
+
* forge extra report lines (the `wt.ts` render precedent). Seat ids are
|
|
150
|
+
* already through `isPathSafeAgentId`, which excludes control characters and
|
|
151
|
+
* whitespace outright.
|
|
152
|
+
*/
|
|
153
|
+
function render(text) {
|
|
154
|
+
return sanitizeForTerminal(text)
|
|
155
|
+
.replace(/[\t\n]+/g, ' ')
|
|
156
|
+
.replace(/ {2,}/g, ' ')
|
|
157
|
+
.trim();
|
|
158
|
+
}
|
|
159
|
+
/** One guard for every verb — no second seat-id validator exists. */
|
|
160
|
+
function requireSafeSeatId(verb, seatId) {
|
|
161
|
+
if (!isPathSafeAgentId(seatId)) {
|
|
162
|
+
throw new TotemConfigError(`seat ${verb}: invalid seat id ${JSON.stringify(seatId)}`, 'A seat id is a single path segment: no separators, `..`, null byte, control/whitespace, or win32-reserved characters. Example: totem-claude', 'CONFIG_INVALID');
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Walk-start, not definitive root (the `resolveTotemRepoRootSync` contract):
|
|
167
|
+
* a seat verb run from inside `.totem/orchestration/<seat>/journal/` must
|
|
168
|
+
* still resolve the repo, not the subdir.
|
|
169
|
+
*/
|
|
170
|
+
function repoRootFor(options) {
|
|
171
|
+
return resolveTotemRepoRootSync(undefined, options.cwdForTest ?? process.cwd());
|
|
172
|
+
}
|
|
173
|
+
function seatDirFor(repoRoot, seatId) {
|
|
174
|
+
return path.join(repoRoot, TOTEM_DIR, ORCHESTRATION_DIR, seatId);
|
|
175
|
+
}
|
|
176
|
+
function configPathFor(repoRoot) {
|
|
177
|
+
return path.join(repoRoot, TOTEM_DIR, ORCHESTRATION_DIR, CONFIG_FILENAME);
|
|
178
|
+
}
|
|
179
|
+
/** Every registered seat with its state, for an unknown-seat error. */
|
|
180
|
+
function knownSeatLabels(repoRoot) {
|
|
181
|
+
return deriveSeatStatuses(repoRoot).map((status) => `${status.seat} (${status.state})`);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* `suspend`/`remove` refuse an unknown seat and LIST what exists — a typo'd
|
|
185
|
+
* id must never mint a marker for a seat nobody has.
|
|
186
|
+
*/
|
|
187
|
+
function requireKnownSeat(verb, repoRoot, seatId) {
|
|
188
|
+
if (directoryExists(seatDirFor(repoRoot, seatId), TotemError))
|
|
189
|
+
return;
|
|
190
|
+
const known = knownSeatLabels(repoRoot);
|
|
191
|
+
throw new TotemConfigError(`seat ${verb}: unknown seat "${seatId}" — no seat directory at ${render(seatDirFor(repoRoot, seatId))}`, known.length === 0
|
|
192
|
+
? 'No seats are registered in this repo yet (the dirs ARE the roster — mmnto-ai/totem#2141). Register one with `totem seat add <seat-id>`.'
|
|
193
|
+
: `Known seats: ${known.join(', ')}. Register a new seat with \`totem seat add <seat-id>\`.`, 'CONFIG_INVALID');
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Compose a marker. `by` and `reason` are omitted rather than nulled — the
|
|
197
|
+
* core schema is `.strict()`, and an absent field is the honest shape for
|
|
198
|
+
* "nothing was recorded".
|
|
199
|
+
*/
|
|
200
|
+
function buildMarker(state, since, by, reason) {
|
|
201
|
+
return {
|
|
202
|
+
schemaVersion: SEAT_LIFECYCLE_SCHEMA_VERSION,
|
|
203
|
+
state,
|
|
204
|
+
since,
|
|
205
|
+
...(by === undefined ? {} : { by }),
|
|
206
|
+
...(reason === undefined ? {} : { reason }),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Read a seat's declared state plus the degraded-read warning, if any. The
|
|
211
|
+
* read never throws (core's fail-open contract), so a corrupt marker reads
|
|
212
|
+
* as `active` and the warning is surfaced to the operator verbatim.
|
|
213
|
+
*/
|
|
214
|
+
function readState(repoRoot, seatId) {
|
|
215
|
+
const read = readSeatLifecycle(repoRoot, seatId);
|
|
216
|
+
return {
|
|
217
|
+
state: read.state,
|
|
218
|
+
...(read.marker === undefined ? {} : { since: read.marker.since }),
|
|
219
|
+
...(read.warning === undefined ? {} : { warning: read.warning }),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Append the seat to `host_agents` in an EXISTING `config.json`, preserving
|
|
224
|
+
* every existing entry and every unknown passthrough key (core's
|
|
225
|
+
* `ConfigSchema` is `.passthrough()`, and other consumers keep their own keys
|
|
226
|
+
* in this file). The file is never CREATED here: a repo without one resolves
|
|
227
|
+
* its roster from dirs ∪ map, and minting the file would replace that.
|
|
228
|
+
*
|
|
229
|
+
* The same reasoning is why a config that exists WITHOUT a `host_agents` key
|
|
230
|
+
* is left alone: the key has REPLACE semantics
|
|
231
|
+
* (`orchestration-resolver.ts:374-399`), so writing `[<new seat>]` into a
|
|
232
|
+
* config that had none would suppress every other present seat. That is the
|
|
233
|
+
* silent-unbind class the resolver already warns about, and it is not this
|
|
234
|
+
* verb's call to make — the situation is reported instead.
|
|
235
|
+
*/
|
|
236
|
+
function mergeHostAgents(repoRoot, seatId) {
|
|
237
|
+
const configPath = configPathFor(repoRoot);
|
|
238
|
+
if (!fs.existsSync(configPath))
|
|
239
|
+
return { status: 'absent', configPath };
|
|
240
|
+
const raw = fs.readFileSync(configPath, 'utf-8');
|
|
241
|
+
const parsed = JSON.parse(raw);
|
|
242
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
243
|
+
throw new TotemError('CONFIG_INVALID', `${render(configPath)} is not a JSON object`, 'Repair the file by hand (it must be a JSON object with an optional `host_agents` array), then re-run.');
|
|
244
|
+
}
|
|
245
|
+
const record = parsed;
|
|
246
|
+
const existing = record[HOST_AGENTS_KEY];
|
|
247
|
+
if (existing === undefined)
|
|
248
|
+
return { status: 'no-host-agents-key', configPath };
|
|
249
|
+
if (!Array.isArray(existing) || existing.some((entry) => typeof entry !== 'string')) {
|
|
250
|
+
throw new TotemError('CONFIG_INVALID', `${render(configPath)} has a ${HOST_AGENTS_KEY} field that is not an array of strings`, 'Repair `host_agents` by hand (it is `string[]`), then re-run — the verb refuses to rewrite a shape it cannot preserve.');
|
|
251
|
+
}
|
|
252
|
+
if (existing.includes(seatId))
|
|
253
|
+
return { status: 'already-present', configPath };
|
|
254
|
+
// Spread-then-overwrite keeps every unknown key AND the original key order
|
|
255
|
+
// (an existing `host_agents` stays in place rather than moving to the end).
|
|
256
|
+
const merged = {
|
|
257
|
+
...record,
|
|
258
|
+
[HOST_AGENTS_KEY]: [...existing, seatId],
|
|
259
|
+
};
|
|
260
|
+
writeFileAtomicSync(configPath, JSON.stringify(merged, null, 2) + '\n');
|
|
261
|
+
return { status: 'appended', configPath };
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
render,
|
|
265
|
+
requireSafeSeatId,
|
|
266
|
+
repoRootFor,
|
|
267
|
+
seatDirFor,
|
|
268
|
+
configPathFor,
|
|
269
|
+
knownSeatLabels,
|
|
270
|
+
requireKnownSeat,
|
|
271
|
+
buildMarker,
|
|
272
|
+
readState,
|
|
273
|
+
mergeHostAgents,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
// ─── Emitted checklists (never applied) ─────────────────
|
|
277
|
+
/**
|
|
278
|
+
* The birth checklist. Every line names a surface this binary deliberately
|
|
279
|
+
* does not touch, and says who must. The poll-wiring block is first because a
|
|
280
|
+
* seat that is registered but never polls looks alive and answers nothing —
|
|
281
|
+
* the cohort-roles §6.6 poll-asymmetry class this issue exists for.
|
|
282
|
+
*/
|
|
283
|
+
function birthChecklist(seatId) {
|
|
284
|
+
return [
|
|
285
|
+
rule(),
|
|
286
|
+
` seat add: ${seatId} — birth checklist (EMITTED, NOT APPLIED)`,
|
|
287
|
+
rule(),
|
|
288
|
+
'Nothing below was written for you. These verbs never edit a vendor hook',
|
|
289
|
+
'file or a foreign surface (mmnto-ai/totem#2612 is the live tracked-file',
|
|
290
|
+
'mutation bug class) — each line is a surface a human or the owning seat',
|
|
291
|
+
'must reach.',
|
|
292
|
+
'',
|
|
293
|
+
'1. POLL WIRING — the seat must actually receive mail.',
|
|
294
|
+
' · claude seats: a SessionStart hook surface exists — install it with',
|
|
295
|
+
' `totem hooks` (legacy alias: `totem install-hooks`) in the seat’s own',
|
|
296
|
+
' harness config; the session briefing carries the `totem mail` poll.',
|
|
297
|
+
' · gemini seats: same install path, the `.gemini/` SessionStart surface.',
|
|
298
|
+
' · codex / kimi / agy seats: NO hook surface exists at all. Wire the poll',
|
|
299
|
+
' into the seat’s prompt or runbook instead — run `totem mail` from the',
|
|
300
|
+
` repo ROOT with ${SELF_AGENT_ENV_VAR}=${seatId} in the environment.`,
|
|
301
|
+
' A seat that is registered but never polls is the poll-asymmetry failure',
|
|
302
|
+
' (cohort-roles §6.6): it looks alive and answers nothing.',
|
|
303
|
+
'',
|
|
304
|
+
'2. totem-status `orchestration.go` — add the seat to `agentHomeMap` (the',
|
|
305
|
+
' projection’s seat → home-repo binding). Not reachable from this repo.',
|
|
306
|
+
'',
|
|
307
|
+
'3. totem-status `eclCohortRepos` — the set is FROZEN by',
|
|
308
|
+
' mmnto-ai/totem-strategy#611. EMITTED, NEVER WRITTEN: do not edit the',
|
|
309
|
+
' frozen set here or anywhere; route any change through the #611 ruling.',
|
|
310
|
+
'',
|
|
311
|
+
'4. The four signoff seat-table copies (prose rows, not derived state):',
|
|
312
|
+
' · .claude/skills/signoff/SKILL.md',
|
|
313
|
+
' · .agents/skills/signoff/SKILL.md',
|
|
314
|
+
' · .gemini/skills/signoff.md',
|
|
315
|
+
' · packages/cli/src/commands/init-templates.ts — SIGNOFF_SKILL_CONTENT,',
|
|
316
|
+
' the PUBLISHED template every `totem init` consumer receives.',
|
|
317
|
+
' A parity test locks three of the four against each other (CI fails on',
|
|
318
|
+
' drift); the .gemini copy has NO mechanical lock — check it explicitly.',
|
|
319
|
+
'',
|
|
320
|
+
'5. Strategy lane — parity-manifest row candidate for this seat. The',
|
|
321
|
+
' manifest is strategy-owned; this verb never adds a row. File it there.',
|
|
322
|
+
rule(),
|
|
323
|
+
];
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* The retirement counterpart: the same foreign surfaces, in the removal
|
|
327
|
+
* direction. Emitted only — a retired seat's rows are removed by the people
|
|
328
|
+
* who own those files.
|
|
329
|
+
*/
|
|
330
|
+
function deregistrationChecklist(seatId, configLine) {
|
|
331
|
+
return [
|
|
332
|
+
rule(),
|
|
333
|
+
` seat remove: ${seatId} — deregistration checklist (EMITTED, NOT APPLIED)`,
|
|
334
|
+
rule(),
|
|
335
|
+
'1. totem-status `orchestration.go` — remove the seat from `agentHomeMap`.',
|
|
336
|
+
'',
|
|
337
|
+
'2. The four signoff seat-table copies (update them together):',
|
|
338
|
+
' · .claude/skills/signoff/SKILL.md',
|
|
339
|
+
' · .agents/skills/signoff/SKILL.md',
|
|
340
|
+
' · .gemini/skills/signoff.md',
|
|
341
|
+
' · packages/cli/src/commands/init-templates.ts — SIGNOFF_SKILL_CONTENT',
|
|
342
|
+
'',
|
|
343
|
+
`3. ${configLine}`,
|
|
344
|
+
'',
|
|
345
|
+
'4. totem-status `eclCohortRepos` is FROZEN (mmnto-ai/totem-strategy#611) —',
|
|
346
|
+
' named here for completeness; route any change through the #611 ruling,',
|
|
347
|
+
' never by editing the frozen set.',
|
|
348
|
+
rule(),
|
|
349
|
+
];
|
|
350
|
+
}
|
|
351
|
+
// ─── add ────────────────────────────────────────────────
|
|
352
|
+
/**
|
|
353
|
+
* Register a seat: three coordination subdirs, an `active` marker, and — only
|
|
354
|
+
* where a `config.json` already exists — the `host_agents` append. Then emit
|
|
355
|
+
* the birth checklist for everything this binary cannot reach.
|
|
356
|
+
*
|
|
357
|
+
* An existing seat is a hard error, not an overwrite: `--reactivate` is the
|
|
358
|
+
* only way back in, and it moves ONLY `suspended → active`. A `retired` seat
|
|
359
|
+
* is never resurrected here — retirement was an operator word, so resurrection
|
|
360
|
+
* must be one too (the Phase-4 gate ruling, OQ1).
|
|
361
|
+
*/
|
|
362
|
+
export async function seatAddCommand(seatId, options = {}) {
|
|
363
|
+
const core = await import('@mmnto/totem');
|
|
364
|
+
const { isPathSafeAgentId, resolveOrchestrationPaths, TotemConfigError, TotemError } = core;
|
|
365
|
+
const { render, requireSafeSeatId, repoRootFor, seatDirFor, buildMarker, readState, mergeHostAgents, } = coreHelpers(core);
|
|
366
|
+
requireSafeSeatId('add', seatId);
|
|
367
|
+
const env = options.envForTest ?? process.env;
|
|
368
|
+
const since = options.nowForTest ?? new Date().toISOString();
|
|
369
|
+
const repoRoot = repoRootFor(options);
|
|
370
|
+
const seatDir = seatDirFor(repoRoot, seatId);
|
|
371
|
+
const current = readState(repoRoot, seatId);
|
|
372
|
+
const markerPresent = fs.existsSync(path.join(seatDir, core.SEAT_LIFECYCLE_FILENAME));
|
|
373
|
+
const acting = resolveActingSeat(env, isPathSafeAgentId);
|
|
374
|
+
const by = acting.by;
|
|
375
|
+
if (directoryExists(seatDir, TotemError) || markerPresent) {
|
|
376
|
+
if (options.reactivate !== true) {
|
|
377
|
+
throw new TotemConfigError(`seat add: "${seatId}" already exists in ${render(repoRoot)} — current state: ${current.state}`, current.state === 'suspended'
|
|
378
|
+
? `Reactivate it with \`totem seat add ${seatId} --reactivate\`, or pick a different seat id.`
|
|
379
|
+
: current.state === 'retired'
|
|
380
|
+
? 'The seat is retired: retirement was an operator word; resurrection needs a fresh ruling — file a new seat or route to the operator.'
|
|
381
|
+
: 'The seat is already active — there is nothing to add. Pick a different seat id.', 'CONFIG_INVALID');
|
|
382
|
+
}
|
|
383
|
+
if (current.state === 'retired') {
|
|
384
|
+
throw new TotemConfigError(`seat add --reactivate: refusing to resurrect retired seat "${seatId}"`, 'Refused: retirement was an operator word; resurrection needs a fresh ruling — file a new seat or route to the operator.', 'CONFIG_INVALID');
|
|
385
|
+
}
|
|
386
|
+
if (current.state !== 'suspended') {
|
|
387
|
+
throw new TotemConfigError(`seat add --reactivate: "${seatId}" is already ${current.state} — --reactivate transitions only suspended → active`, current.warning === undefined
|
|
388
|
+
? 'Nothing to do. Check `totem seat list` for the current declared state of every seat.'
|
|
389
|
+
: `The seat reads as active because its marker could not be used: ${render(current.warning)}`, 'CONFIG_INVALID');
|
|
390
|
+
}
|
|
391
|
+
const marker = buildMarker('active', since, by, undefined);
|
|
392
|
+
const markerPath = core.writeSeatLifecycle(repoRoot, seatId, marker);
|
|
393
|
+
emit([
|
|
394
|
+
`Seat reactivated: ${seatId} (suspended → active)`,
|
|
395
|
+
` marker: ${render(markerPath)}`,
|
|
396
|
+
` ${describeMarker(marker)}`,
|
|
397
|
+
'',
|
|
398
|
+
'Effects',
|
|
399
|
+
' · The seat re-enters broadcast required-sets and round denominators on',
|
|
400
|
+
' the very next poll — markers are read fresh, never cached.',
|
|
401
|
+
' · Its dirs, outbox, processed marks, and journals were untouched.',
|
|
402
|
+
...attributionNote(acting),
|
|
403
|
+
...(current.warning === undefined ? [] : ['', `warning: ${render(current.warning)}`]),
|
|
404
|
+
]);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
// --reactivate on a seat that does not exist must not silently fall through
|
|
408
|
+
// to the birth path — the flag would change meaning (falsification finding
|
|
409
|
+
// 11 on this branch): reactivation is a transition on an EXISTING seat.
|
|
410
|
+
if (options.reactivate === true) {
|
|
411
|
+
throw new TotemConfigError(`seat add --reactivate: unknown seat "${seatId}" — there is nothing to reactivate`, 'The flag transitions suspended → active on an existing seat. Register a new seat by running `totem seat add` without --reactivate.', 'CONFIG_INVALID');
|
|
412
|
+
}
|
|
413
|
+
// ── New seat ──
|
|
414
|
+
const completed = [];
|
|
415
|
+
for (const subdir of SEAT_SUBDIRS) {
|
|
416
|
+
const target = path.join(seatDir, subdir);
|
|
417
|
+
// totem-context: intentional rethrow-as-TotemError — a seat-dir path occupied by a FILE (ENOTDIR) or a permission wall must fail loud with the path (re-arm P-4), never as a raw fs error one call after the directoryExists guard.
|
|
418
|
+
try {
|
|
419
|
+
fs.mkdirSync(target, { recursive: true });
|
|
420
|
+
}
|
|
421
|
+
catch (err) {
|
|
422
|
+
throw new TotemError('CHECK_FAILED', `seat add: cannot create ${render(target)}: ${String(err)}`, 'Check that the .totem/orchestration path is a writable directory tree (a file squatting on the seat path must be moved aside).', err);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// Verify through the resolver rather than trusting the mkdir calls: the
|
|
426
|
+
// resolver is what every consumer reads the tree through, so its answer is
|
|
427
|
+
// the one that counts (`source: 'none'` means the seat is not registered).
|
|
428
|
+
const paths = resolveOrchestrationPaths(repoRoot, seatId);
|
|
429
|
+
if (paths.source !== 'orchestration' ||
|
|
430
|
+
paths.outbox === null ||
|
|
431
|
+
paths.processed === null ||
|
|
432
|
+
paths.journal === null) {
|
|
433
|
+
throw new TotemError('CHECK_FAILED', `seat add: created ${render(seatDir)} but the orchestration resolver does not see a complete tree (outbox/processed/journal)`, 'Check permissions on the .totem/orchestration directory and re-run — directory creation is idempotent.');
|
|
434
|
+
}
|
|
435
|
+
completed.push(`created ${SEAT_SUBDIRS.length} subdir(s) under ${render(seatDir)}`);
|
|
436
|
+
const marker = buildMarker('active', since, by, undefined);
|
|
437
|
+
const markerPath = core.writeSeatLifecycle(repoRoot, seatId, marker);
|
|
438
|
+
completed.push(`wrote ${render(markerPath)}`);
|
|
439
|
+
let merge;
|
|
440
|
+
// totem-context: intentional cleanup — the dirs and the marker already landed, so a config failure must report COMPLETED vs FAILED steps (the #2511 failure table) instead of a bare I/O error; the cause is attached and nothing is swallowed.
|
|
441
|
+
try {
|
|
442
|
+
merge = mergeHostAgents(repoRoot, seatId);
|
|
443
|
+
// totem-context: intentional cleanup — see directive above the try; dual placement so the rule fires on either the catch-keyword line or the catch-body line.
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
throw new TotemError('CHECK_FAILED', `seat add: the seat was created but ${CONFIG_FILENAME} could not be updated: ${render(describe(err))} — completed: ${completed.join('; ')}; FAILED: ${HOST_AGENTS_KEY} merge`, 'Fix the config file by hand (add the seat to `host_agents`) or re-run `totem seat add` — dir creation and the config merge are both idempotent.', err);
|
|
447
|
+
}
|
|
448
|
+
const configLines = [];
|
|
449
|
+
if (merge.status === 'appended') {
|
|
450
|
+
configLines.push(` · ${HOST_AGENTS_KEY} in ${render(merge.configPath)}: appended ${seatId}`);
|
|
451
|
+
}
|
|
452
|
+
else if (merge.status === 'already-present') {
|
|
453
|
+
configLines.push(` · ${HOST_AGENTS_KEY} in ${render(merge.configPath)}: already listed — left unchanged`);
|
|
454
|
+
}
|
|
455
|
+
else if (merge.status === 'no-host-agents-key') {
|
|
456
|
+
configLines.push(` · ${render(merge.configPath)} exists but declares no ${HOST_AGENTS_KEY} — left unchanged.`, ` ${HOST_AGENTS_KEY} REPLACES the derived roster, so minting it with one seat`, ' would suppress every other present seat. The new seat is registered by', ' its dir either way (mmnto-ai/totem#2141).');
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
configLines.push(` · no ${CONFIG_FILENAME} in this repo — none was created`, ` (looked at ${render(merge.configPath)}). Seat registration is`, ' dir-derived (mmnto-ai/totem#2141); the file is an override, not a', ' registry.');
|
|
460
|
+
}
|
|
461
|
+
emit([
|
|
462
|
+
`Seat added: ${seatId}`,
|
|
463
|
+
` dirs: ${render(seatDir)} (${SEAT_SUBDIRS.join(', ')})`,
|
|
464
|
+
` marker: ${render(markerPath)}`,
|
|
465
|
+
` ${describeMarker(marker)}`,
|
|
466
|
+
...configLines,
|
|
467
|
+
// Distinct absence disclosures (finding 7 + re-arm P-3): ambiguous
|
|
468
|
+
// multi-seat env / set-but-unusable env / genuinely unset — each states
|
|
469
|
+
// the true reason, never "not set" for an env that IS set.
|
|
470
|
+
...(acting.ambiguous
|
|
471
|
+
? [...attributionNote(acting), '']
|
|
472
|
+
: acting.setButUnusable
|
|
473
|
+
? [
|
|
474
|
+
` · by omitted: ${SELF_AGENT_ENV_VAR} is set but no entry is a usable seat id —`,
|
|
475
|
+
' the marker records stamped absence rather than a fabricated attribution.',
|
|
476
|
+
'',
|
|
477
|
+
]
|
|
478
|
+
: by === undefined
|
|
479
|
+
? [
|
|
480
|
+
` · ${SELF_AGENT_ENV_VAR} is not set, so the marker records no \`by\` —`,
|
|
481
|
+
' a stamped absence rather than a fabricated attribution.',
|
|
482
|
+
'',
|
|
483
|
+
]
|
|
484
|
+
: ['']),
|
|
485
|
+
...birthChecklist(seatId),
|
|
486
|
+
]);
|
|
487
|
+
}
|
|
488
|
+
// ─── suspend ────────────────────────────────────────────
|
|
489
|
+
/**
|
|
490
|
+
* Declare a seat suspended: it leaves broadcast required-sets and round
|
|
491
|
+
* denominators, while its directed mail keeps surfacing (the gate ruling, OQ4
|
|
492
|
+
* — suspension excludes a seat from the count, it never hides its mail).
|
|
493
|
+
*
|
|
494
|
+
* Already-suspended is a no-op success with no rewrite: re-running the verb
|
|
495
|
+
* must not overwrite the ORIGINAL `since`, which is the audit trail's anchor.
|
|
496
|
+
*/
|
|
497
|
+
export async function seatSuspendCommand(seatId, options = {}) {
|
|
498
|
+
const core = await import('@mmnto/totem');
|
|
499
|
+
const { isPathSafeAgentId, TotemConfigError } = core;
|
|
500
|
+
const { render, requireSafeSeatId, repoRootFor, requireKnownSeat, buildMarker, readState } = coreHelpers(core);
|
|
501
|
+
requireSafeSeatId('suspend', seatId);
|
|
502
|
+
const env = options.envForTest ?? process.env;
|
|
503
|
+
const since = options.nowForTest ?? new Date().toISOString();
|
|
504
|
+
const repoRoot = repoRootFor(options);
|
|
505
|
+
requireKnownSeat('suspend', repoRoot, seatId);
|
|
506
|
+
const current = readState(repoRoot, seatId);
|
|
507
|
+
if (current.state === 'suspended') {
|
|
508
|
+
emit([
|
|
509
|
+
`Seat ${seatId} is already suspended since ${current.since ?? 'an unrecorded instant'} — nothing was rewritten.`,
|
|
510
|
+
' Re-running the verb would replace the original `since`, which is the',
|
|
511
|
+
' anchor of the recorded-ruling audit trail (cohort-roles §6.6).',
|
|
512
|
+
]);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (current.state === 'retired') {
|
|
516
|
+
throw new TotemConfigError(`seat suspend: "${seatId}" is retired — a retired seat cannot be suspended`, 'Retirement is the terminal declared state; nothing further is owed here. If the seat must come back, that is a fresh operator ruling.', 'CONFIG_INVALID');
|
|
517
|
+
}
|
|
518
|
+
const acting = resolveActingSeat(env, isPathSafeAgentId);
|
|
519
|
+
const marker = buildMarker('suspended', since, acting.by, options.reason);
|
|
520
|
+
const markerPath = core.writeSeatLifecycle(repoRoot, seatId, marker);
|
|
521
|
+
emit([
|
|
522
|
+
`Seat suspended: ${seatId}`,
|
|
523
|
+
` marker: ${render(markerPath)}`,
|
|
524
|
+
` ${describeMarker(marker)}`,
|
|
525
|
+
...attributionNote(acting),
|
|
526
|
+
'',
|
|
527
|
+
'Effects',
|
|
528
|
+
` · ${seatId} drops out of broadcast REQUIRED-SETS and round denominators:`,
|
|
529
|
+
' a broadcast no longer waits on this seat’s mark (mmnto-ai/totem#2412).',
|
|
530
|
+
' · Directed mail addressed to this seat still surfaces — suspension',
|
|
531
|
+
' changes the count, it never hides a message.',
|
|
532
|
+
...OBLIGATIONS_NOTE,
|
|
533
|
+
...(options.reason === undefined
|
|
534
|
+
? [
|
|
535
|
+
'',
|
|
536
|
+
' no --reason recorded — this is not a blocker, but `reason` + `since` on',
|
|
537
|
+
' the marker is the recorded-ruling audit trail the cohort-roles §6.6',
|
|
538
|
+
` \`operator-excluded\` synthesis reads. Consider re-running with`,
|
|
539
|
+
` \`totem seat suspend ${seatId} --reason "<the ruling>"\`.`,
|
|
540
|
+
]
|
|
541
|
+
: []),
|
|
542
|
+
...(current.warning === undefined ? [] : ['', `warning: ${render(current.warning)}`]),
|
|
543
|
+
]);
|
|
544
|
+
}
|
|
545
|
+
// ─── remove ─────────────────────────────────────────────
|
|
546
|
+
/**
|
|
547
|
+
* Declare a seat retired. Nothing is deleted — the marker is a durable
|
|
548
|
+
* tombstone and retention policy is mmnto-ai/totem#2518's lane — and no
|
|
549
|
+
* obligation is discharged. The residual foreign surfaces are emitted as a
|
|
550
|
+
* deregistration checklist, never applied.
|
|
551
|
+
*/
|
|
552
|
+
export async function seatRemoveCommand(seatId, options = {}) {
|
|
553
|
+
const core = await import('@mmnto/totem');
|
|
554
|
+
const { isPathSafeAgentId } = core;
|
|
555
|
+
const { render, requireSafeSeatId, repoRootFor, seatDirFor, configPathFor, requireKnownSeat, buildMarker, readState, } = coreHelpers(core);
|
|
556
|
+
requireSafeSeatId('remove', seatId);
|
|
557
|
+
const env = options.envForTest ?? process.env;
|
|
558
|
+
const since = options.nowForTest ?? new Date().toISOString();
|
|
559
|
+
const repoRoot = repoRootFor(options);
|
|
560
|
+
requireKnownSeat('remove', repoRoot, seatId);
|
|
561
|
+
const current = readState(repoRoot, seatId);
|
|
562
|
+
if (current.state === 'retired') {
|
|
563
|
+
emit([
|
|
564
|
+
`Seat ${seatId} is already retired since ${current.since ?? 'an unrecorded instant'} — nothing was rewritten.`,
|
|
565
|
+
' The existing tombstone stands; its `since` is the retirement’s anchor.',
|
|
566
|
+
]);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
const acting = resolveActingSeat(env, isPathSafeAgentId);
|
|
570
|
+
const marker = buildMarker('retired', since, acting.by, options.reason);
|
|
571
|
+
const markerPath = core.writeSeatLifecycle(repoRoot, seatId, marker);
|
|
572
|
+
const configPath = configPathFor(repoRoot);
|
|
573
|
+
const configLine = fs.existsSync(configPath)
|
|
574
|
+
? `${render(configPath)} — remove the seat from ${HOST_AGENTS_KEY} if it is listed there.`
|
|
575
|
+
: `no ${CONFIG_FILENAME} in this repo — nothing to deregister there.`;
|
|
576
|
+
emit([
|
|
577
|
+
`Seat retired: ${seatId}`,
|
|
578
|
+
` marker: ${render(markerPath)}`,
|
|
579
|
+
` ${describeMarker(marker)}`,
|
|
580
|
+
...attributionNote(acting),
|
|
581
|
+
'',
|
|
582
|
+
'Retention',
|
|
583
|
+
` · Nothing was deleted. ${render(seatDirFor(repoRoot, seatId))} keeps its`,
|
|
584
|
+
' outbox, processed marks, and journals exactly as they are — the marker',
|
|
585
|
+
' is a durable tombstone, and retention policy is mmnto-ai/totem#2518’s',
|
|
586
|
+
' lane, not this verb’s.',
|
|
587
|
+
'',
|
|
588
|
+
'Effects',
|
|
589
|
+
` · ${seatId} leaves broadcast required-sets and round denominators.`,
|
|
590
|
+
...OBLIGATIONS_NOTE,
|
|
591
|
+
...(current.warning === undefined ? [] : ['', `warning: ${render(current.warning)}`]),
|
|
592
|
+
'',
|
|
593
|
+
...deregistrationChecklist(seatId, configLine),
|
|
594
|
+
]);
|
|
595
|
+
}
|
|
596
|
+
// ─── list ───────────────────────────────────────────────
|
|
597
|
+
function padCell(value, width) {
|
|
598
|
+
return value.length >= width ? value : value + ' '.repeat(width - value.length);
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Read-only accounting: every registered seat (the dirs ARE the roster) with
|
|
602
|
+
* its declared lifecycle, derived at read time and never cached. Degraded
|
|
603
|
+
* marker reads appear as warnings BELOW the table — a seat with an unusable
|
|
604
|
+
* marker still renders as `active`, because fail-open is the read contract.
|
|
605
|
+
*/
|
|
606
|
+
export async function seatListCommand(options = {}) {
|
|
607
|
+
const core = await import('@mmnto/totem');
|
|
608
|
+
const { deriveSeatStatuses } = core;
|
|
609
|
+
const { render, repoRootFor } = coreHelpers(core);
|
|
610
|
+
const repoRoot = repoRootFor(options);
|
|
611
|
+
const statuses = deriveSeatStatuses(repoRoot);
|
|
612
|
+
if (options.json === true) {
|
|
613
|
+
process.stdout.write(JSON.stringify(statuses, null, 2) + '\n');
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (statuses.length === 0) {
|
|
617
|
+
emit([
|
|
618
|
+
`no seats registered in ${render(path.join(repoRoot, TOTEM_DIR, ORCHESTRATION_DIR))}`,
|
|
619
|
+
' The dirs ARE the roster (mmnto-ai/totem#2141) — a seat registers itself',
|
|
620
|
+
' on its first write, or explicitly via `totem seat add <seat-id>`.',
|
|
621
|
+
]);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
const rows = statuses.map((status) => ({
|
|
625
|
+
seat: status.seat,
|
|
626
|
+
state: status.state,
|
|
627
|
+
since: status.since ?? EMPTY_CELL,
|
|
628
|
+
source: status.source,
|
|
629
|
+
}));
|
|
630
|
+
const widths = {
|
|
631
|
+
seat: Math.max(LIST_COLUMN_MIN_WIDTHS.seat, ...rows.map((row) => row.seat.length)),
|
|
632
|
+
state: Math.max(LIST_COLUMN_MIN_WIDTHS.state, ...rows.map((row) => row.state.length)),
|
|
633
|
+
since: Math.max(LIST_COLUMN_MIN_WIDTHS.since, ...rows.map((row) => row.since.length)),
|
|
634
|
+
source: Math.max(LIST_COLUMN_MIN_WIDTHS.source, ...rows.map((row) => row.source.length)),
|
|
635
|
+
};
|
|
636
|
+
const warnings = statuses
|
|
637
|
+
.filter((status) => status.warning !== undefined)
|
|
638
|
+
.map((status) => render(status.warning ?? ''));
|
|
639
|
+
emit([
|
|
640
|
+
`${padCell('seat', widths.seat)} ${padCell('state', widths.state)} ${padCell('since', widths.since)} source`,
|
|
641
|
+
`${'-'.repeat(widths.seat)} ${'-'.repeat(widths.state)} ${'-'.repeat(widths.since)} ${'-'.repeat(widths.source)}`,
|
|
642
|
+
...rows.map((row) => `${padCell(row.seat, widths.seat)} ${padCell(row.state, widths.state)} ${padCell(row.since, widths.since)} ${row.source}`),
|
|
643
|
+
`${statuses.length} seat(s) in ${render(repoRoot)} · derived at read time, never cached`,
|
|
644
|
+
...(warnings.length === 0
|
|
645
|
+
? []
|
|
646
|
+
: ['', `warnings (${warnings.length}):`, ...warnings.map((warning) => ` · ${warning}`)]),
|
|
647
|
+
]);
|
|
648
|
+
}
|
|
649
|
+
//# sourceMappingURL=seat.js.map
|