@starci/hfs 4.7.47 → 4.9.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/CHANGELOG.md +18 -0
- package/README.md +4 -9
- package/emit/operation-schemas.mjs +3 -0
- package/lint/bound-sys.cjs +2 -2
- package/package.json +11 -5
- package/runtime/engine/admission.mjs +1 -1
- package/runtime/engine/config.mjs +2 -2
- package/runtime/engine/db/machine.mjs +2 -3
- package/runtime/engine/invalid-config.mjs +1 -1
- package/runtime/knowledge/grammars/{starci → core}/playbook.yaml +3 -3
- package/runtime/knowledge/grammars/index.yaml +14 -23
- package/runtime/knowledge/grammars/offset-pop/playbook.yaml +2 -2
- package/runtime/knowledge/hfs/canon-pins.yaml +23 -23
- package/runtime/knowledge/hfs/fe-stack.yaml +1 -1
- package/runtime/knowledge/hfs/rules.yaml +83 -60
- package/runtime/modules/kernel/failure-codes.yaml +23 -13
- package/runtime/scripts/guards/git-policy.mjs +2 -3
- package/runtime/scripts/hfs/rule-catalog.mjs +2 -2
- package/runtime/scripts/hfs/rules/pipeline.mjs +5 -5
- package/runtime/scripts/lib/example-refs.mjs +10 -3
- package/runtime/scripts/lib/grammar-lint.mjs +16 -3
- package/runtime/scripts/lib/package-at.mjs +5 -6
- package/runtime/scripts/lib/roots.mjs +2 -2
- package/runtime/scripts/work/draw/draw-dna-findings.mjs +23 -12
- package/runtime/scripts/work/draw/draw-dna.mjs +14 -8
- package/scaffold/app.mjs +6 -6
- package/src/main.mjs +3 -3
- package/sync/hygiene.mjs +1 -1
- package/sync/index.mjs +15 -2
- package/sync/managed.mjs +3 -2
- package/templates/app/codegen-graphql.mjs.partial +4 -4
- package/templates/app/quality-config/sonar-project.properties +4 -2
- package/runtime/engine/canonical-json.mjs +0 -14
- package/runtime/engine/db/ledger-open.mjs +0 -38
- package/runtime/engine/db/ledger-paths.mjs +0 -87
- package/runtime/engine/db/ledger.mjs +0 -1068
- package/runtime/engine/db/provision-authority.mjs +0 -76
- package/runtime/engine/db/provision-operations.mjs +0 -146
- package/runtime/packages/grammar/scripts/build-stamp.mjs +0 -98
- package/runtime/scripts/agent/codex-app-server.mjs +0 -79
- package/runtime/scripts/agent/codex-mirror-source.mjs +0 -34
- package/runtime/scripts/agent/host-agents.mjs +0 -80
- package/runtime/scripts/agent/launch-trust-policy.mjs +0 -53
- package/runtime/scripts/agent/trust-launch.mjs +0 -191
- package/runtime/scripts/agent/trust.mjs +0 -523
- package/runtime/scripts/api/fs/temp-path.mjs +0 -8
- package/runtime/scripts/api/git/diff-names.mjs +0 -8
- package/runtime/scripts/api/git/head-time.mjs +0 -10
- package/runtime/scripts/api/git/index-lock.mjs +0 -78
- package/runtime/scripts/api/git/log.mjs +0 -5
- package/runtime/scripts/api/git/merge-tree.mjs +0 -5
- package/runtime/scripts/api/git/rev-list.mjs +0 -5
- package/runtime/scripts/api/git/rev-parse.mjs +0 -13
- package/runtime/scripts/api/git/status-query.mjs +0 -5
- package/runtime/scripts/api/git/worktree-list-query.mjs +0 -5
- package/runtime/scripts/api/node/spawn-node.mjs +0 -6
- package/runtime/scripts/api/npm/dist-tag.mjs +0 -8
- package/runtime/scripts/api/npm/lib.mjs +0 -32
- package/runtime/scripts/api/npm/pack-dry-run.mjs +0 -9
- package/runtime/scripts/api/npm/run-npm.mjs +0 -6
- package/runtime/scripts/api/npm/view.mjs +0 -30
- package/runtime/scripts/api/npm/whoami.mjs +0 -8
- package/runtime/scripts/api/process/hide-child-windows.mjs +0 -31
- package/runtime/scripts/api/process/kill-tree.mjs +0 -22
- package/runtime/scripts/api/process/lib.mjs +0 -125
- package/runtime/scripts/api/process/probe-guard-command.mjs +0 -74
- package/runtime/scripts/api/process/process-list.mjs +0 -28
- package/runtime/scripts/api/process/system-tool.mjs +0 -37
- package/runtime/scripts/gates/canon-digest.mjs +0 -81
- package/runtime/scripts/gates/canon-pins.mjs +0 -119
- package/runtime/scripts/gates/gate-input.mjs +0 -98
- package/runtime/scripts/gates/gate.mjs +0 -636
- package/runtime/scripts/gates/grammar-dist.mjs +0 -206
- package/runtime/scripts/gates/output.mjs +0 -40
- package/runtime/scripts/gates/runtime-package-selection.mjs +0 -27
- package/runtime/scripts/gates/test-world-run.mjs +0 -252
- package/runtime/scripts/gates/type-impact.mjs +0 -277
- package/runtime/scripts/gates/type-program.mjs +0 -113
- package/runtime/scripts/guards/assigned-command.mjs +0 -12
- package/runtime/scripts/guards/call-rights.mjs +0 -47
- package/runtime/scripts/guards/command-guard.mjs +0 -468
- package/runtime/scripts/guards/command-policy.mjs +0 -342
- package/runtime/scripts/guards/critic-reach.mjs +0 -82
- package/runtime/scripts/guards/deps-guard.mjs +0 -150
- package/runtime/scripts/guards/env-dump-verdict.mjs +0 -89
- package/runtime/scripts/guards/guards-root.mjs +0 -27
- package/runtime/scripts/guards/hook-install.mjs +0 -343
- package/runtime/scripts/guards/hook-verdict.mjs +0 -12
- package/runtime/scripts/guards/install-verdict.mjs +0 -41
- package/runtime/scripts/guards/kernel-seat.mjs +0 -63
- package/runtime/scripts/guards/launch-verdict.mjs +0 -74
- package/runtime/scripts/guards/op-starci.mjs +0 -16
- package/runtime/scripts/guards/op-write-scope.mjs +0 -27
- package/runtime/scripts/guards/orca-self-lifecycle.mjs +0 -39
- package/runtime/scripts/guards/process-kill-verdict.mjs +0 -106
- package/runtime/scripts/guards/protected-zone.mjs +0 -56
- package/runtime/scripts/guards/refusals.mjs +0 -27
- package/runtime/scripts/guards/rights.mjs +0 -268
- package/runtime/scripts/guards/seat-tools.mjs +0 -47
- package/runtime/scripts/guards/shell-commands.mjs +0 -328
- package/runtime/scripts/guards/supervisor-seat.mjs +0 -80
- package/runtime/scripts/guards/verdict-process.mjs +0 -81
- package/runtime/scripts/lib/app-data.mjs +0 -11
- package/runtime/scripts/lib/canon-binding-text.mjs +0 -129
- package/runtime/scripts/lib/check-format.mjs +0 -7
- package/runtime/scripts/lib/failure-code-reads.mjs +0 -100
- package/runtime/scripts/lib/failure-code-template-errors.mjs +0 -161
- package/runtime/scripts/lib/git-dir.mjs +0 -120
- package/runtime/scripts/lib/guard-command.mjs +0 -24
- package/runtime/scripts/lib/interrupt-cleanup.mjs +0 -38
- package/runtime/scripts/lib/json-formats.mjs +0 -34
- package/runtime/scripts/lib/managed-json.mjs +0 -51
- package/runtime/scripts/lib/menu-parts.mjs +0 -11
- package/runtime/scripts/lib/op-gate.mjs +0 -15
- package/runtime/scripts/lib/provision-control-failure.mjs +0 -32
- package/runtime/scripts/lib/release-version.mjs +0 -134
- package/runtime/scripts/lib/runtime-location.mjs +0 -21
- package/runtime/scripts/lib/runtime-package-inventory.mjs +0 -21
- package/runtime/scripts/lib/runtime-package-manifests.mjs +0 -84
- package/runtime/scripts/lib/runtime-publication-tags.mjs +0 -56
- package/runtime/scripts/lib/seat-env.mjs +0 -43
- package/runtime/scripts/lib/sonar-new-code.mjs +0 -58
- package/runtime/scripts/lib/source-package-imports.mjs +0 -111
- package/runtime/scripts/lib/tar-files.mjs +0 -39
- package/runtime/scripts/lib/uat-run-folder.mjs +0 -118
- package/runtime/scripts/lib/worktree-record.mjs +0 -14
- package/runtime/scripts/machine/ask-receipts.mjs +0 -113
- package/runtime/scripts/machine/evidence-store.mjs +0 -223
- package/runtime/scripts/machine/home.mjs +0 -216
- package/runtime/scripts/machine/host-lock.mjs +0 -184
- package/runtime/scripts/machine/lock-recovery.mjs +0 -55
- package/runtime/scripts/machine/roles-contract.mjs +0 -90
- package/runtime/scripts/machine/runtime-change.mjs +0 -20
- package/runtime/scripts/machine/workflow-tree.mjs +0 -122
- package/runtime/scripts/reconciler/checklist-items.mjs +0 -7
- package/runtime/scripts/reconciler/guard-row.mjs +0 -25
- package/runtime/scripts/work/brand/brand-colour.mjs +0 -125
- package/runtime/scripts/work/brand/brand-tokens.mjs +0 -167
- package/runtime/scripts/work/brand/brand-value-source.mjs +0 -106
- package/runtime/scripts/work/brand/brand.mjs +0 -892
- package/runtime/scripts/work/draw/draw-layer.mjs +0 -306
- package/runtime/scripts/work/draw/draw-rationale-page.mjs +0 -237
- package/runtime/scripts/work/draw/draw-rationale-ref.mjs +0 -14
- package/runtime/scripts/work/draw/draw-rationale.mjs +0 -353
- package/runtime/scripts/work/draw/draw-source.mjs +0 -412
- package/runtime/scripts/work/draw/draw-taste.mjs +0 -278
- package/runtime/scripts/work/draw/draw-typecheck.mjs +0 -72
- package/runtime/scripts/work/draw/rationale-findings.mjs +0 -205
- package/runtime/scripts/work/draw-grammar.mjs +0 -175
- package/runtime/scripts/work/draw-render-page.mjs +0 -188
- package/runtime/scripts/work/draw-render.mjs +0 -626
- package/runtime/scripts/work/png.mjs +0 -237
- package/runtime/scripts/work/render-tools.mjs +0 -64
|
@@ -1,216 +0,0 @@
|
|
|
1
|
-
// home.mjs — the Supervisor's durable state, in machine.sqlite (engine/db/machine.mjs, DBTREE.sql B1; decision Q3).
|
|
2
|
-
//
|
|
3
|
-
// There is no supervisor ledger and no supervisor home directory: every part is a machine.sqlite row.
|
|
4
|
-
// Where each part lives:
|
|
5
|
-
// seat seats row 'supervisor' (role supervisor): state booting while a launcher holds the startup reservation
|
|
6
|
-
// (detail_json.expiresAt), live once spawned; terminal_handle/agent/model/pid; detail_json {token, value}
|
|
7
|
-
// enabled sup_signals scope 'supervisor-enabled' key 'main' {enabled, by, at}
|
|
8
|
-
// jobs sup_jobs (runtime.fix [Worker] jobs), sup_leases (file leases), sup_attempts, sup_reports
|
|
9
|
-
// audit sup_events (the digest-chained audit trail; kinds as before: supervisor-*, worker-*, owed-*, ...)
|
|
10
|
-
// learning sup_learning; owed items and their acks sup_owed; owner rulings sup_owner_rulings; messages sup_messages
|
|
11
|
-
// logs machine_logs actor 'supervisor' (supervisorLog; the Supervisor writes no text logs)
|
|
12
|
-
// Product ledgers are only ever read from here.
|
|
13
|
-
import os from 'node:os';
|
|
14
|
-
import path from 'node:path';
|
|
15
|
-
import { fileURLToPath } from 'node:url';
|
|
16
|
-
import { DEFAULT_OWNER_LANGUAGE, loadConfig } from '../../engine/config.mjs';
|
|
17
|
-
import { LOCAL_ROOT_ENV, machineLog, readMachine, starciLocalRoot, withMachine } from '../../engine/db/machine.mjs';
|
|
18
|
-
import { headTime } from '../api/git/head-time.mjs';
|
|
19
|
-
import { readEnv } from '../lib/env.mjs';
|
|
20
|
-
|
|
21
|
-
export const SKILL_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* The rev of the runtime this process runs: '<HEAD committer time, ms, 13 digits>:<short sha>' of the .claude checkout
|
|
25
|
-
* (STARCI_RUNTIME_REV overrides), 'unknown' outside a checkout. The machine db never spawns git: a writer that stamps a
|
|
26
|
-
* store row passes this in. Computed once per process.
|
|
27
|
-
*/
|
|
28
|
-
let cachedRev = null;
|
|
29
|
-
export function runtimeRevOf() {
|
|
30
|
-
if (cachedRev) return cachedRev;
|
|
31
|
-
if (readEnv('STARCI_RUNTIME_REV')) {
|
|
32
|
-
cachedRev = String(readEnv('STARCI_RUNTIME_REV'));
|
|
33
|
-
return cachedRev;
|
|
34
|
-
}
|
|
35
|
-
const head = headTime(SKILL_ROOT);
|
|
36
|
-
cachedRev = head ? `${String(head.time * 1000).padStart(13, '0')}:${head.sha}` : 'unknown';
|
|
37
|
-
return cachedRev;
|
|
38
|
-
}
|
|
39
|
-
export const SUPERVISOR_ID = 'main';
|
|
40
|
-
/** The seats row of the one Supervisor seat. */
|
|
41
|
-
export const SEAT_ID = 'supervisor';
|
|
42
|
-
const ENABLED_SCOPE = 'supervisor-enabled';
|
|
43
|
-
export const SUPERVISOR_TITLE = `[Supervisor] ${SUPERVISOR_ID}`;
|
|
44
|
-
export const SUPERVISOR_SEAT = Object.freeze({ id: SUPERVISOR_ID, seatId: SEAT_ID, enabledScope: ENABLED_SCOPE,
|
|
45
|
-
eventPrefix: 'supervisor', title: SUPERVISOR_TITLE });
|
|
46
|
-
export const WORKER_TITLE_PREFIX = '[Worker]';
|
|
47
|
-
export const FIX_KIND = 'runtime.fix';
|
|
48
|
-
export const STARTUP_RESERVATION_MS = 180_000;
|
|
49
|
-
|
|
50
|
-
export const DEFAULTS = Object.freeze({
|
|
51
|
-
workers: Object.freeze({ base: 0, max: 0, hardMax: 10 }),
|
|
52
|
-
landGate: Object.freeze({ mode: 'shared', push: true }),
|
|
53
|
-
frozenMinutes: 10,
|
|
54
|
-
pollIntervalMs: 600_000,
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
// The land scratch trees live under the one lanes root (lanesRoot below), never on C: unless the owner puts it there. A [Worker]
|
|
58
|
-
// staging checkout is an Orca worktree (workers.mjs createStaging): Orca places it, the job records where.
|
|
59
|
-
|
|
60
|
-
/** The host-state sub-root `archive` defaults to: <starciLocalRoot>/archive, i.e. <runtime root>/.runtime/archive. */
|
|
61
|
-
const stateRootChild = (name, env) => path.join(starciLocalRoot(env), name);
|
|
62
|
-
/**
|
|
63
|
-
* Lane worktrees are git worktrees, so their default stays OUT of the runtime's own checkout (and its .runtime): the per-user
|
|
64
|
-
* profile directory (<LOCALAPPDATA>/StarCi/lanes, ~/.local/state/StarCi/lanes), or <STARCI_LOCAL_ROOT>/lanes when that seam is set.
|
|
65
|
-
* The owner moves it with roots.lanes or STARCI_LANES_ROOT.
|
|
66
|
-
*/
|
|
67
|
-
const lanesDefault = (env) => (readEnv(LOCAL_ROOT_ENV, env) ? stateRootChild('lanes', env)
|
|
68
|
-
: path.join(readEnv('LOCALAPPDATA', env) || path.join(os.homedir(), '.local', 'state'), 'StarCi', 'lanes'));
|
|
69
|
-
/** The owner's relocation of a host root (config.yaml `roots.<key>`, validated by engine/config.mjs), or null. `config` is the owner config (default: loadConfig()). */
|
|
70
|
-
const ownerRoot = (key, config) => {
|
|
71
|
-
try { return (config === undefined ? loadConfig() : config)?.roots?.[key] ?? null; } catch { return null; }
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* The one lane-worktree root: env STARCI_LANES_ROOT (specs, a one-off run), then the owner config `roots.lanes`
|
|
76
|
-
* (config.yaml, gitignored), else lanesDefault (outside the checkout). No host location is written in a tracked file.
|
|
77
|
-
*/
|
|
78
|
-
export function lanesRoot({ env = process.env, config = undefined } = {}) {
|
|
79
|
-
return path.resolve(String(env?.STARCI_LANES_ROOT || ownerRoot('lanes', config) || lanesDefault(env)));
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** The one archive root (session files, blob retention, ledger backups): env STARCI_ARCHIVE_ROOT, then the owner config `roots.archive`, else <starciLocalRoot>/archive. */
|
|
83
|
-
export function archiveRoot({ env = process.env, config = undefined } = {}) {
|
|
84
|
-
return path.resolve(String(env?.STARCI_ARCHIVE_ROOT || ownerRoot('archive', config) || stateRootChild('archive', env)));
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export const landRoot = (env = process.env) => path.join(lanesRoot({ env }), 'land');
|
|
88
|
-
|
|
89
|
-
/** fn(machine handle) over a writer, closed afterwards. */
|
|
90
|
-
export const withSupervisor = (fn, { env = process.env } = {}) => withMachine(fn, { env });
|
|
91
|
-
/** fn(machine handle) over a read-only handle; `fallback` when machine.sqlite does not exist or cannot be read. */
|
|
92
|
-
export const readSupervisor = (fn, fallback = null, { env = process.env } = {}) => readMachine(fn, fallback, { env });
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* The `{env}`-bound reader of a supervisor-store projection `read`: `fresh` yields the fallback when the
|
|
96
|
-
* store is absent (a thunk, so the value is never shared between calls).
|
|
97
|
-
*/
|
|
98
|
-
export const supervisorRead = (read, fresh) => ({ env = process.env } = {}) => readSupervisor(read, fresh(), { env });
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* The seat: {token, value, at, expiresAt, pid, expired, starting} or null (no seat, or an empty one). `value` is what
|
|
102
|
-
* the launcher wrote ({terminal, agent, model, ...} or {state:'starting', attempt}).
|
|
103
|
-
*/
|
|
104
|
-
export function seatOf(m, now = Date.now(), profile = SUPERVISOR_SEAT) {
|
|
105
|
-
const row = m.seatOf(profile.seatId);
|
|
106
|
-
if (!row || row.state === 'empty') return null;
|
|
107
|
-
const detail = row.detail ?? {};
|
|
108
|
-
const expiresAt = detail.expiresAt ?? null;
|
|
109
|
-
const expired = expiresAt != null && expiresAt <= now;
|
|
110
|
-
const value = detail.value ?? {};
|
|
111
|
-
return { token: detail.token ?? null, value, at: row.booted_at ?? row.last_seen_at ?? null, expiresAt, pid: row.pid ?? null, expired, starting: value.state === 'starting' && !expired, state: row.state };
|
|
112
|
-
}
|
|
113
|
-
/** Write the seat (replacing it): a {state:'starting'} value is the startup reservation (state booting, with expiry). A new seat starts with no refused input: the run of failures belongs to the seat it replaced. */
|
|
114
|
-
export function writeSeat(m, { token, value, expiresAt = null, now = Date.now(), profile = SUPERVISOR_SEAT }) {
|
|
115
|
-
const starting = value?.state === 'starting';
|
|
116
|
-
return m.upsertSeat({ seatId: profile.seatId, role: 'supervisor', state: starting ? 'booting' : 'live', parkedReason: null,
|
|
117
|
-
terminalHandle: value?.terminal ?? null, agent: value?.agent ?? null, model: value?.model ?? null, pid: process.pid,
|
|
118
|
-
bootedAt: now, lastSeenAt: now, inputFailuresConsecutive: 0, lastInputFailureAt: null, detailJson: { token, value, expiresAt } });
|
|
119
|
-
}
|
|
120
|
-
/** Empty the seat (only the holder of `token` when given). True when it was cleared. */
|
|
121
|
-
export function clearSeat(m, { token = null, profile = SUPERVISOR_SEAT } = {}) {
|
|
122
|
-
const cur = seatOf(m, Date.now(), profile);
|
|
123
|
-
if (!cur || (token && cur.token !== token)) return false;
|
|
124
|
-
m.upsertSeat({ seatId: profile.seatId, role: 'supervisor', state: 'empty', terminalHandle: null, pid: null, detailJson: null });
|
|
125
|
-
return true;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Whether the owner enabled the seat: true / false, or null when never set. */
|
|
129
|
-
export function enabledOf(m, profile = SUPERVISOR_SEAT) {
|
|
130
|
-
const row = m.supSignal(profile.enabledScope, profile.id);
|
|
131
|
-
return row ? row.value?.enabled === true : null;
|
|
132
|
-
}
|
|
133
|
-
export function setEnabled(m, enabled, { by = 'cli', now = Date.now(), profile = SUPERVISOR_SEAT } = {}) {
|
|
134
|
-
m.transaction(() => {
|
|
135
|
-
m.setSupSignal({ scope: profile.enabledScope, key: profile.id, value: { enabled, by, at: new Date(now).toISOString() } });
|
|
136
|
-
supervisorEvent(m, { entityId: profile.id, kind: `${profile.eventPrefix}-${enabled ? 'enabled' : 'disabled'}`, payload: { by }, now });
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** Native supervised seats retain their terminals until their owning launcher proves closure. */
|
|
141
|
-
export function supervisedSeatHandles(m) {
|
|
142
|
-
return new Set(m.seats().filter(row => row.role === 'supervisor' && row.state !== 'empty')
|
|
143
|
-
.map(row => row.terminal_handle ?? row.detail?.value?.terminal).filter(Boolean));
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Append one audit event (sup_events). */
|
|
147
|
-
export function supervisorEvent(m, { entityType = 'supervisor', entityId = SUPERVISOR_ID, kind, payload = null, now = Date.now() }) {
|
|
148
|
-
return m.supEvent({ entityType, entityId, kind, payload, at: now });
|
|
149
|
-
}
|
|
150
|
-
/** The newest event of `kind` as {at, ...payload}, or null. */
|
|
151
|
-
export function newestEvent(m, kind) {
|
|
152
|
-
const row = m.newestSupEvent(kind);
|
|
153
|
-
return row ? { at: row.created_at, ...row.payload } : null;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Where the Supervisor role runs (config.yaml supervisor.mode, default 'chat'). Owner, 2026-09-25: "move the
|
|
158
|
-
* supervisor back into chat so it stays persistent" - the role lives in the owner's desktop chat session again: it owns channel 'main'
|
|
159
|
-
* (registers and drains it with no Orca terminal), runs the 10-minute tick itself and lands its Opus lanes through
|
|
160
|
-
* land.mjs --lane. 'kernel' is the optional [Supervisor] Orca kernel (start-supervisor.mjs + supervisor-watchdog.mjs); in chat
|
|
161
|
-
* mode nothing (resume-all, restart-all, /start, the watchdog) starts one. STARCI_SUPERVISOR_MODE overrides the
|
|
162
|
-
* config (specs, a one-off CLI run).
|
|
163
|
-
*/
|
|
164
|
-
const SUPERVISOR_MODES = Object.freeze(['chat', 'kernel']);
|
|
165
|
-
export const DEFAULT_SUPERVISOR_MODE = 'chat';
|
|
166
|
-
/** engine/config.mjs owns the owner-language default; home re-exports it so base (scripts/lib/i18n.mjs) never imports machine. */
|
|
167
|
-
export { DEFAULT_OWNER_LANGUAGE };
|
|
168
|
-
export const DEFAULT_AGENT = 'claude';
|
|
169
|
-
export function supervisorMode({ env = process.env, config = undefined } = {}) {
|
|
170
|
-
const fromEnv = String(env?.STARCI_SUPERVISOR_MODE ?? '').trim();
|
|
171
|
-
if (SUPERVISOR_MODES.includes(fromEnv)) return fromEnv;
|
|
172
|
-
let cfg = config;
|
|
173
|
-
if (cfg === undefined) { try { cfg = loadConfig(); } catch { cfg = null; } }
|
|
174
|
-
const mode = cfg?.supervisor?.mode;
|
|
175
|
-
return SUPERVISOR_MODES.includes(mode) ? mode : DEFAULT_SUPERVISOR_MODE;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* config.yaml supervisor block with defaults: {mode, agent, model, effort, repos, pollIntervalMs, workers, landGate, frozenMinutes}.
|
|
180
|
-
* `agent` and `model` are the owner's pin of the seat (an `only` bias over the supervisor tier chain); both are null when unpinned.
|
|
181
|
-
*/
|
|
182
|
-
export function supervisorSettings({ config = undefined } = {}) {
|
|
183
|
-
let cfg = config;
|
|
184
|
-
if (cfg === undefined) cfg = loadConfig();
|
|
185
|
-
const sup = cfg?.supervisor ?? {};
|
|
186
|
-
const seat = sup.kernel ?? {};
|
|
187
|
-
const pick = (...values) => values.find((v) => typeof v === 'string' && v.trim())?.trim() ?? null;
|
|
188
|
-
return {
|
|
189
|
-
mode: SUPERVISOR_MODES.includes(sup.mode) ? sup.mode : DEFAULT_SUPERVISOR_MODE,
|
|
190
|
-
agent: pick(seat.agent),
|
|
191
|
-
model: pick(seat.model),
|
|
192
|
-
effort: pick(seat.effort, cfg?.effort),
|
|
193
|
-
repos: Array.isArray(sup.repos) ? sup.repos : [],
|
|
194
|
-
pollIntervalMs: Number.isInteger(sup.pollIntervalMs) ? sup.pollIntervalMs : DEFAULTS.pollIntervalMs,
|
|
195
|
-
workers: { base: Number.isInteger(sup.workers?.base) ? sup.workers.base : DEFAULTS.workers.base,
|
|
196
|
-
max: Math.min(DEFAULTS.workers.hardMax, Number.isInteger(sup.workers?.max) ? sup.workers.max : DEFAULTS.workers.max) },
|
|
197
|
-
landGate: { mode: sup.landGate?.mode === 'exclusive' ? 'exclusive' : DEFAULTS.landGate.mode, push: typeof sup.landGate?.push === 'boolean' ? sup.landGate.push : DEFAULTS.landGate.push },
|
|
198
|
-
// supervisor-watchdog.mjs: a busy seat frame with no turn progress for this long is frozen, not busy.
|
|
199
|
-
frozenMinutes: Number.isInteger(sup.frozenMinutes) && sup.frozenMinutes > 0 ? sup.frozenMinutes : DEFAULTS.frozenMinutes,
|
|
200
|
-
language: typeof cfg?.language === 'string' ? cfg.language : DEFAULT_OWNER_LANGUAGE,
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** The product repositories the supervisor watches and pushes: config supervisor.repos resolved against the source root. */
|
|
205
|
-
export const productRepos = (settings = supervisorSettings(), { sourceRoot = path.dirname(SKILL_ROOT) } = {}) =>
|
|
206
|
-
settings.repos.map((repo) => path.resolve(sourceRoot, repo));
|
|
207
|
-
|
|
208
|
-
/** A db-shaped adapter that answers wakeKernel's one signal read with `terminal` (scripts/kernel/wake-delivery.mjs wakeKernel). */
|
|
209
|
-
export const terminalSignalDb = (terminal) => ({
|
|
210
|
-
prepare: () => ({ get: () => ({ value_json: JSON.stringify({ terminal }) }) }),
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
/** One Supervisor log line in machine_logs (actor supervisor, kind supervisor.<name>). Never throws. */
|
|
214
|
-
export function supervisorLog(name, line, { env = process.env, now = new Date(), level = 'info', data = null } = {}) {
|
|
215
|
-
return machineLog({ actor: 'supervisor', kind: `supervisor.${name}`, msg: String(line), level, data, at: now.getTime() }, { env });
|
|
216
|
-
}
|
|
@@ -1,184 +0,0 @@
|
|
|
1
|
-
// host-lock.mjs — the ONE host lock of the heavy runtime work (a land's gate run, a release cut): one holder at a time on this host.
|
|
2
|
-
//
|
|
3
|
-
// The primitive is the atomic mkdir of a single lock directory. Its owner file is JSON (schema starci/host-lock@1) naming
|
|
4
|
-
// who holds it: token, pid, role, purpose, the Orca terminal handle, the host and the start time. That makes three things possible:
|
|
5
|
-
// - a dead holder is detected (its pid is gone) and the lock is taken over atomically instead of blocking everyone;
|
|
6
|
-
// - a release is refused for a stranger (the token must match), so one actor never drops another's lock;
|
|
7
|
-
// - the rights guard (scripts/guards/rights.mjs) reads the owner (hostLockOwner) and decides who may do what meanwhile.
|
|
8
|
-
// A lock directory without a readable owner file (no pid to test) is held, never stale, until it is older than
|
|
9
|
-
// ownerlessStaleMs. The release cut (scripts/supervisor/release-cut.mjs, GOVERNANCE lane) runs under
|
|
10
|
-
// withHostLock({role: 'release', purpose: 'release-cut'}); the land gate under withHostLock({role: 'coordinator', purpose: 'land'}).
|
|
11
|
-
//
|
|
12
|
-
// Every function is synchronous and pure fs; the seams {fs, now, isAlive, pid, host, env, dir, newToken, remove} are injectable.
|
|
13
|
-
import nodeFs from 'node:fs';
|
|
14
|
-
import os from 'node:os';
|
|
15
|
-
import path from 'node:path';
|
|
16
|
-
import { randomBytes } from 'node:crypto';
|
|
17
|
-
import { pidAlive, starciLocalRoot } from '../../engine/db/machine.mjs';
|
|
18
|
-
import { readEnv } from '../lib/env.mjs';
|
|
19
|
-
import { safeRemove } from '../api/fs/safe-remove.mjs';
|
|
20
|
-
import { onInterrupt } from '../lib/interrupt-cleanup.mjs';
|
|
21
|
-
|
|
22
|
-
const HOST_LOCK_SCHEMA = 'starci/host-lock@1';
|
|
23
|
-
export const ROLES = Object.freeze(['release', 'coordinator', 'lead', 'worker']);
|
|
24
|
-
const OWNERLESS_STALE_MS = 6 * 60 * 60 * 1000;
|
|
25
|
-
const OWNER_FILE = 'owner';
|
|
26
|
-
const OWNERLESS_TEXT_CLIP = 500;
|
|
27
|
-
|
|
28
|
-
/** The lock directory: STARCI_HOST_LOCK_DIR, else <starci local root>/host-lock. */
|
|
29
|
-
export function hostLockDir({ env = process.env } = {}) {
|
|
30
|
-
const set = readEnv('STARCI_HOST_LOCK_DIR', env);
|
|
31
|
-
return set ? path.resolve(set) : path.join(starciLocalRoot(env), 'host-lock');
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// The lock directory as found on disk: {kind: 'free'} | {kind: 'json', owner} | {kind: 'ownerless', text, mtimeMs}.
|
|
35
|
-
// A directory without a readable owner file (a holder between its mkdir and its write, or a crash there) is 'ownerless' with
|
|
36
|
-
// the directory's own age, so it too expires through ownerlessStaleMs.
|
|
37
|
-
function readRaw(dir, fs) {
|
|
38
|
-
let st;
|
|
39
|
-
try { st = fs.statSync(dir); } catch (error) {
|
|
40
|
-
if (error?.code === 'ENOENT') return { kind: 'free' };
|
|
41
|
-
throw error;
|
|
42
|
-
}
|
|
43
|
-
let text = '';
|
|
44
|
-
try { text = String(fs.readFileSync(path.join(dir, OWNER_FILE), 'utf8')); } catch { /* no owner file yet */ }
|
|
45
|
-
try {
|
|
46
|
-
const owner = JSON.parse(text);
|
|
47
|
-
if (owner && typeof owner === 'object' && owner.schema === HOST_LOCK_SCHEMA && typeof owner.token === 'string') return { kind: 'json', owner };
|
|
48
|
-
} catch { /* not an owner document */ }
|
|
49
|
-
return { kind: 'ownerless', text, mtimeMs: st.mtimeMs };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// The owner object a raw lock stands for, with `stale` set: a JSON owner is stale when its ttl ran out, or when it is on this
|
|
53
|
-
// host and its pid is gone; an ownerless lock is stale only past ownerlessStaleMs.
|
|
54
|
-
function viewOf(raw, { now, isAlive, host, ownerlessStaleMs }) {
|
|
55
|
-
if (raw.kind === 'free') return null;
|
|
56
|
-
const at = now();
|
|
57
|
-
if (raw.kind === 'ownerless') {
|
|
58
|
-
return { schema: 'ownerless', ownerless: true, text: raw.text.slice(0, OWNERLESS_TEXT_CLIP), token: null, pid: null, role: null, purpose: null, handle: null, host: null,
|
|
59
|
-
since: new Date(raw.mtimeMs).toISOString(), ttlMs: null, stale: at - raw.mtimeMs >= ownerlessStaleMs };
|
|
60
|
-
}
|
|
61
|
-
const owner = raw.owner;
|
|
62
|
-
const since = Date.parse(owner.since);
|
|
63
|
-
const expired = Number.isFinite(owner.ttlMs) && owner.ttlMs > 0 && Number.isFinite(since) && at - since >= owner.ttlMs;
|
|
64
|
-
const dead = owner.host === host && !isAlive(owner.pid);
|
|
65
|
-
return { ...owner, stale: expired || dead };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const seamsOf = ({ fs = nodeFs, now = Date.now, isAlive = pidAlive, host = os.hostname(), ownerlessStaleMs = OWNERLESS_STALE_MS } = {}) => ({ fs, now, isAlive, host, ownerlessStaleMs });
|
|
69
|
-
const sameLock = (raw, view) => (raw.kind === 'json' ? view.token === raw.owner.token : raw.kind === 'ownerless' && view.ownerless === true && view.text === raw.text.slice(0, OWNERLESS_TEXT_CLIP));
|
|
70
|
-
const removeTree = (target, remove) => { try { const r = remove(target); return r !== false && r?.ok !== false; } catch { return false; } };
|
|
71
|
-
const defaultRemove = (target) => safeRemove(target, { hold: () => null });
|
|
72
|
-
|
|
73
|
-
// Move the lock directory aside (an atomic rename, so exactly one contender wins), then check that what moved is the lock
|
|
74
|
-
// the caller judged (`expected`, a view). When it is not - another actor re-took the lock between the judgement and the
|
|
75
|
-
// rename - the directory is put back if its place is still free. Returns {moved: true, aside} | {moved: false, gone} where
|
|
76
|
-
// `gone` says the lock was already absent.
|
|
77
|
-
function moveAside(dir, expected, { fs, newToken }) {
|
|
78
|
-
const aside = `${dir}.aside-${newToken().slice(0, 12)}`;
|
|
79
|
-
try { fs.renameSync(dir, aside); } catch (error) {
|
|
80
|
-
if (error?.code === 'ENOENT') return { moved: false, gone: true };
|
|
81
|
-
throw error;
|
|
82
|
-
}
|
|
83
|
-
const raw = readRaw(aside, fs);
|
|
84
|
-
if (!sameLock(raw, expected)) {
|
|
85
|
-
try { fs.renameSync(aside, dir); } catch { /* the place was re-taken: the moved lock stays aside, never deleted */ }
|
|
86
|
-
return { moved: false, gone: false };
|
|
87
|
-
}
|
|
88
|
-
return { moved: true, aside };
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* The owner of the lock: null when it is free, else {schema, token, pid, role, purpose, handle, host, since, ttlMs, stale}.
|
|
93
|
-
* `stale` is true for a holder whose process is gone (or whose ttl ran out): callers report it, acquireHostLock takes it over.
|
|
94
|
-
* A lock directory without a readable owner reads {ownerless: true, text, since: <file time>, stale} with null token/pid/role.
|
|
95
|
-
*/
|
|
96
|
-
export function hostLockOwner({ dir, env = process.env, ...seams } = {}) {
|
|
97
|
-
const s = seamsOf(seams);
|
|
98
|
-
return viewOf(readRaw(dir ?? hostLockDir({ env }), s.fs), s);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// One attempt to create the lock directory: `{ created }`; a lock a live holder keeps is `{ held }`; a vanished lock or a stale one
|
|
102
|
-
// moved aside is `{ retry, seen, tookOver }` (tookOver: the stale owner whose lock was replaced).
|
|
103
|
-
function createLockDir(lockDir, s, { newToken, remove }) {
|
|
104
|
-
try {
|
|
105
|
-
s.fs.mkdirSync(lockDir);
|
|
106
|
-
return { created: true };
|
|
107
|
-
} catch (error) {
|
|
108
|
-
if (error?.code !== 'EEXIST') throw error;
|
|
109
|
-
const seen = viewOf(readRaw(lockDir, s.fs), s);
|
|
110
|
-
if (!seen) return { retry: true, seen: null };
|
|
111
|
-
if (!seen.stale) return { held: seen };
|
|
112
|
-
const moved = moveAside(lockDir, seen, { fs: s.fs, newToken });
|
|
113
|
-
if (moved.moved) removeTree(moved.aside, remove);
|
|
114
|
-
return { retry: true, seen, tookOver: moved.moved ? seen : null };
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function writeOwnerFile(lockDir, owner, s, remove) {
|
|
119
|
-
try { s.fs.writeFileSync(path.join(lockDir, OWNER_FILE), `${JSON.stringify(owner, null, 2)}\n`, { flag: 'wx' }); } catch (error) {
|
|
120
|
-
try { remove(lockDir); } catch { /* a directory without an owner expires through ownerlessStaleMs */ }
|
|
121
|
-
throw error;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Take the lock. {ok: true, token, owner} (plus tookOverFrom: <the stale owner> when a dead holder's lock was replaced) or
|
|
127
|
-
* {ok: false, reason: 'held', owner} or {ok: false, reason: 'bad-role', role}. A role outside ROLES is refused.
|
|
128
|
-
*/
|
|
129
|
-
export function acquireHostLock({ role, purpose = null, handle, pid = process.pid, ttlMs = null, env = process.env, dir, newToken = () => randomBytes(16).toString('hex'), remove = defaultRemove, ...seams } = {}) {
|
|
130
|
-
if (!ROLES.includes(role)) return { ok: false, reason: 'bad-role', role: role ?? null, roles: ROLES };
|
|
131
|
-
const s = seamsOf(seams);
|
|
132
|
-
const lockDir = dir ?? hostLockDir({ env });
|
|
133
|
-
const token = newToken();
|
|
134
|
-
const owner = { schema: HOST_LOCK_SCHEMA, token, pid, role, purpose: purpose ?? null, handle: handle ?? readEnv('ORCA_TERMINAL_HANDLE', env) ?? null,
|
|
135
|
-
host: s.host, since: new Date(s.now()).toISOString(), ttlMs: ttlMs ?? null };
|
|
136
|
-
s.fs.mkdirSync(path.dirname(lockDir), { recursive: true });
|
|
137
|
-
let tookOverFrom = null;
|
|
138
|
-
let seen = null;
|
|
139
|
-
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
140
|
-
const step = createLockDir(lockDir, s, { newToken, remove });
|
|
141
|
-
if (step.held) return { ok: false, reason: 'held', owner: step.held };
|
|
142
|
-
if (step.retry) { seen = step.seen; tookOverFrom = step.tookOver ?? tookOverFrom; continue; }
|
|
143
|
-
writeOwnerFile(lockDir, owner, s, remove);
|
|
144
|
-
return { ok: true, token, owner, ...(tookOverFrom ? { tookOverFrom } : {}) };
|
|
145
|
-
}
|
|
146
|
-
return { ok: false, reason: 'held', owner: seen };
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Release the lock `token` holds: {ok: true, released: true}; {ok: true, released: false} when there is no lock;
|
|
151
|
-
* {ok: false, reason: 'not-owner', owner} when someone else (or an ownerless owner) holds it - a stranger's release never removes it.
|
|
152
|
-
*/
|
|
153
|
-
export function releaseHostLock({ token, env = process.env, dir, newToken = () => randomBytes(16).toString('hex'), remove = defaultRemove, ...seams } = {}) {
|
|
154
|
-
const s = seamsOf(seams);
|
|
155
|
-
const lockDir = dir ?? hostLockDir({ env });
|
|
156
|
-
const raw = readRaw(lockDir, s.fs);
|
|
157
|
-
if (raw.kind === 'free') return { ok: true, released: false };
|
|
158
|
-
const view = viewOf(raw, s);
|
|
159
|
-
if (!token || raw.kind !== 'json' || raw.owner.token !== token) return { ok: false, reason: 'not-owner', owner: view };
|
|
160
|
-
const moved = moveAside(lockDir, view, { fs: s.fs, newToken });
|
|
161
|
-
if (!moved.moved) {
|
|
162
|
-
if (moved.gone) return { ok: true, released: false };
|
|
163
|
-
return { ok: false, reason: 'not-owner', owner: hostLockOwner({ dir: lockDir, ...seams }) };
|
|
164
|
-
}
|
|
165
|
-
const left = !removeTree(moved.aside, remove);
|
|
166
|
-
return { ok: true, released: true, ...(left ? { leftover: moved.aside } : {}) };
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Run `fn({token, owner})` under the lock and always release it. Returns fn's result, or the refusal of acquireHostLock
|
|
171
|
-
* ({ok: false, reason: 'held', owner}) without running fn (`options.onInterrupt` is the registration seam). An async fn holds the lock until its promise settles; a signal or an exit meanwhile releases it too (scripts/lib/interrupt-cleanup.mjs).
|
|
172
|
-
*/
|
|
173
|
-
export function withHostLock(options, fn) {
|
|
174
|
-
const got = acquireHostLock(options);
|
|
175
|
-
if (!got.ok) return got;
|
|
176
|
-
const release = () => releaseHostLock({ token: got.token, env: options?.env, dir: options?.dir, fs: options?.fs, now: options?.now, isAlive: options?.isAlive, host: options?.host, remove: options?.remove });
|
|
177
|
-
const dispose = (options?.onInterrupt ?? onInterrupt)(release); // an interrupted holder drops its lock before the process ends
|
|
178
|
-
const done = () => { dispose(); release(); };
|
|
179
|
-
let out;
|
|
180
|
-
try { out = fn(got); } catch (error) { done(); throw error; }
|
|
181
|
-
if (typeof out?.then === 'function') return out.finally(done);
|
|
182
|
-
done();
|
|
183
|
-
return out;
|
|
184
|
-
}
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
// lock-recovery.mjs — the compositions around the ONE stale `.git/index.lock` recovery (scripts/api/git/index-lock.mjs
|
|
2
|
-
// indexLock): the git-process probe over the host's process table (scripts/api/process/process-list.mjs), the Supervisor
|
|
3
|
-
// audit event a removal records, and the command guard's pre-flight (scripts/guards/command-guard.mjs). The housekeeping
|
|
4
|
-
// area `gitlocks` (scripts/housekeeping/hk-git-locks.mjs) uses the probe and the recorder too.
|
|
5
|
-
import path from 'node:path';
|
|
6
|
-
import { checkoutOf, gitDirOf, isGitImage, lockStat } from '../lib/git-dir.mjs';
|
|
7
|
-
import { indexLock } from '../api/git/index-lock.mjs';
|
|
8
|
-
import { processList } from '../api/process/process-list.mjs';
|
|
9
|
-
import { isSpecRun } from '../lib/env.mjs';
|
|
10
|
-
|
|
11
|
-
const LOCK_EVENT = 'git-index-lock-removed';
|
|
12
|
-
|
|
13
|
-
/** Git-family processes on this host: [{pid, name, commandLine}], or null when the probe failed. A spec run (NODE_TEST_CONTEXT) never reads the host process table. */
|
|
14
|
-
export function listGitProcesses({ platform = process.platform, run, env = process.env } = {}) {
|
|
15
|
-
if (isSpecRun(env)) return null;
|
|
16
|
-
const rows = processList({ where: "Name LIKE 'git%'", run, platform, timeoutMs: 30000 });
|
|
17
|
-
return rows ? rows.map((p) => ({ pid: p.pid, name: String(p.name ?? ''), commandLine: p.cmd })).filter((p) => isGitImage(p.name)) : null;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** `record` for api/git/index-lock.mjs indexLock: one Supervisor audit event (machine.sqlite sup_events) per removal, naming who removed it. */
|
|
21
|
-
export async function supervisorLockRecorder({ env = process.env, by, jobId = null, workflowId = null } = {}) {
|
|
22
|
-
const { withSupervisor, supervisorEvent } = await import('./home.mjs');
|
|
23
|
-
return (result) => withSupervisor((m) => supervisorEvent(m, { entityType: 'repo', entityId: result.repo, kind: LOCK_EVENT,
|
|
24
|
-
payload: { lock: result.lock, ageMs: Math.round(result.ageMs), bytes: result.bytes, mtime: result.mtime, by, jobId, workflowId } }), { env });
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* The command guard's git pre-flight (scripts/guards/command-guard.mjs): when the checkout `cwd` is in holds an index
|
|
29
|
-
* lock older than the declared window, try the recovery once and say what happened on stderr. Costs one stat when no
|
|
30
|
-
* lock stands. Never throws: the command runs either way.
|
|
31
|
-
*/
|
|
32
|
-
export async function preflightIndexLock({ cwd = process.cwd(), guard = null, env = process.env, say = () => {}, now = Date.now(), list = listGitProcesses } = {}) {
|
|
33
|
-
try {
|
|
34
|
-
const repo = checkoutOf(cwd);
|
|
35
|
-
const gitDir = repo ? gitDirOf(repo) : null;
|
|
36
|
-
const st = gitDir ? lockStat(path.join(gitDir, 'index.lock')) : null;
|
|
37
|
-
if (!st) return null;
|
|
38
|
-
const { allocationMs } = await import('../../engine/config.mjs');
|
|
39
|
-
const staleMs = allocationMs('housekeeping.gitIndexLockStaleMs');
|
|
40
|
-
if (now - st.mtimeMs < staleMs) return null;
|
|
41
|
-
const record = await supervisorLockRecorder({ env, by: 'command-guard', jobId: guard?.jobId ?? null, workflowId: guard?.workflowId ?? null });
|
|
42
|
-
const result = indexLock({ repo, staleMs, now, list, record });
|
|
43
|
-
const age = `${Math.round((result.ageMs ?? 0) / 60000)} min`;
|
|
44
|
-
if (result.state === 'removed') say(`starci guard: removed a stale ${result.lock} (${age} old, no git process on this repository); recorded as ${LOCK_EVENT}.`);
|
|
45
|
-
else if (result.state === 'held') say(`starci guard: ${result.lock} is ${age} old but a git process may still hold it (${result.holders.map((h) => h.pid).join(', ')}); left in place - retry shortly, never delete it by hand.`);
|
|
46
|
-
else if (result.state !== 'fresh' && result.state !== 'absent') {
|
|
47
|
-
const error = result.error ? `: ${result.error}` : '';
|
|
48
|
-
say(`starci guard: ${result.lock} is ${age} old and was left in place (${result.state}${error}).`);
|
|
49
|
-
}
|
|
50
|
-
return result;
|
|
51
|
-
} catch (error) {
|
|
52
|
-
say(`starci guard: index-lock check error (${error?.message ?? error}); running git anyway`);
|
|
53
|
-
return null;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
// roles-contract.mjs — reads the one roles contract (modules/kernel/roles.yaml) and renders a role's block: the Markdown a surface
|
|
2
|
-
// carries between its markers, and the compact lines the op prompt prints at launch. The roles-contract self-check compares
|
|
3
|
-
// every surface with this rendering.
|
|
4
|
-
import fs from 'node:fs';
|
|
5
|
-
import path from 'node:path';
|
|
6
|
-
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import { parseYaml } from '../../engine/yaml.mjs';
|
|
8
|
-
|
|
9
|
-
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
10
|
-
export const ROLES_FILE = 'modules/kernel/roles.yaml';
|
|
11
|
-
|
|
12
|
-
/** The roles contract document of the tree at `root`. */
|
|
13
|
-
export const rolesContract = (root = ROOT) => parseYaml(fs.readFileSync(path.join(root, ROLES_FILE), 'utf8'));
|
|
14
|
-
|
|
15
|
-
export const markerOf = (id) => ({ begin: `<!-- roles:begin ${id} -->`, end: `<!-- roles:end ${id} -->` });
|
|
16
|
-
|
|
17
|
-
const bullets = (label, items) => (items?.length ? [`- ${label}:`, ...items.map((item) => ` - ${item}`)] : []);
|
|
18
|
-
const nameOf = (doc, id) => (id === 'runtime' ? 'the runtime' : doc.roles.find((role) => role.id === id)?.label ?? id);
|
|
19
|
-
|
|
20
|
-
/** The lines that state who a role reports to, who oversees it and what it owns. */
|
|
21
|
-
function chainLines(doc, role) {
|
|
22
|
-
const up = role.reportsTo ? `${nameOf(doc, role.reportsTo)} (${role.reportsUpWhen})` : 'no one';
|
|
23
|
-
return [`- Owns: ${role.owns}. Decides alone: ${role.decidesAlone}.`,
|
|
24
|
-
`- Reports to: ${up}. Overseen by: ${role.overseenBy.map((id) => nameOf(doc, id)).join(', ') || 'no one'}.`,
|
|
25
|
-
`- Measure: ${role.measure}.`];
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** The token budget line of a role that declares one. */
|
|
29
|
-
function budgetLines(role) {
|
|
30
|
-
const b = role.tokenBudget;
|
|
31
|
-
const wake = role.wakeBudget;
|
|
32
|
-
if (b) return [`- Token budget (${b.status}): ${b.perAttempt.default} per ${b.unit ?? 'attempt'}; over it, ${b.onExceed}.`];
|
|
33
|
-
if (wake) {
|
|
34
|
-
const boot = wake.perBoot ? `, ${wake.perBoot.turns} turns and ${wake.perBoot.tokens} tokens for its boot (the contract files and the rev-ack manifest)` : '';
|
|
35
|
-
return [`- Wake budget (${wake.status}): ${wake.perWake.turns} turns and ${wake.perWake.tokens} tokens per wake${boot}; over it, ${wake.onExceed}.`];
|
|
36
|
-
}
|
|
37
|
-
return role.noBudget ? [`- No budget: ${role.noBudget}.`] : [];
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** The line that states how a role learns that the runtime tree changed (its revisionAck declaration). */
|
|
41
|
-
function revisionLines(role) {
|
|
42
|
-
const decl = role.revisionAck;
|
|
43
|
-
if (!decl) return [];
|
|
44
|
-
if (decl.noAck) return [`- Runtime changes: ${decl.noAck}.`];
|
|
45
|
-
if (decl.admission) return [`- Runtime changes: ${decl.reason}.`];
|
|
46
|
-
return [`- Runtime changes (modules/kernel/revision-scope.yaml): woken once with exactly the changed files that concern it, it reads them and attests with ${decl.verb}; a change that concerns it nothing costs it nothing; it is replaced by a fresh seat, at its next yield, only when a rule of its contract was removed or reversed or its boot prompt changed.`];
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** The guard binding, the happy errors and the bug surface of a role that declares them (the owner's two classes of error). */
|
|
50
|
-
function standardLines(role) {
|
|
51
|
-
const unbound = role.noSeat ? [`- Guard: none by design; ${role.noSeatReason}.`] : [];
|
|
52
|
-
const guard = role.guard ? [`- Guard: its terminals are bound as the "${role.guard.role}" role of modules/kernel/command-policy.yaml.`] : unbound;
|
|
53
|
-
const happy = role.happyErrors?.map((error) => `${error.id} (policy row ${error.row}): ${error.what}`);
|
|
54
|
-
const bugs = role.bugSurface?.map((entry) => `${entry.bug}: ${entry.signal}`);
|
|
55
|
-
return [...guard, ...bullets('Happy errors it handles (the system working as designed, handled inside the chain through the policy)', happy),
|
|
56
|
-
...bullets('A bug in this role (the chain neither fixes nor works around it; Debug removes it with a change to .claude) is detected by', bugs)];
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** The Debug lines that state whom it audits and when it retires. */
|
|
60
|
-
function debugLines(doc, role) {
|
|
61
|
-
if (!role.oversees) return [];
|
|
62
|
-
const criteria = role.endCondition.criteria.map((c) => `${c.id}: ${c.rule}`);
|
|
63
|
-
return [`- Audits: ${role.oversees.map((id) => nameOf(doc, id)).join(', ')}.`, ...bullets('Retires when', criteria)];
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** The Markdown block of one role (without its markers). */
|
|
67
|
-
export function renderRoleBlock(doc, id) {
|
|
68
|
-
const role = doc.roles.find((entry) => entry.id === id);
|
|
69
|
-
const lines = [`**${role.label}** (${ROLES_FILE}#${id}): ${role.scope}`, ...bullets('Does', role.does), ...bullets('Must clean up', role.cleanup),
|
|
70
|
-
...bullets('Never', role.never), ...chainLines(doc, role), ...budgetLines(role), ...revisionLines(role), ...standardLines(role), ...debugLines(doc, role), `- Principles: ${role.binds.join(' ')} (${ROLES_FILE}, principles).`];
|
|
71
|
-
return lines.join('\n');
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** The compact lines of a role for a launch prompt. */
|
|
75
|
-
export function renderRoleLines(id, root = ROOT) {
|
|
76
|
-
const doc = rolesContract(root);
|
|
77
|
-
const role = doc.roles.find((entry) => entry.id === id);
|
|
78
|
-
return [`role: ${role.label} (${ROLES_FILE}#${id}) - ${role.scope}`, ...role.never.map((item) => ` never: ${item}`),
|
|
79
|
-
...role.cleanup.map((item) => ` clean up: ${item}`), ` reports to: ${nameOf(doc, role.reportsTo)} only`];
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** The text with the role's block replaced between its markers, or appended with markers when it has none. */
|
|
83
|
-
export function withRoleBlock(text, doc, id) {
|
|
84
|
-
const { begin, end } = markerOf(id);
|
|
85
|
-
const block = `${begin}\n${renderRoleBlock(doc, id)}\n${end}`;
|
|
86
|
-
const from = text.indexOf(begin);
|
|
87
|
-
const to = text.indexOf(end);
|
|
88
|
-
if (from < 0 || to < from) return `${text.trimEnd()}\n\n${block}\n`;
|
|
89
|
-
return text.slice(0, from) + block + text.slice(to + end.length);
|
|
90
|
-
}
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
// runtime-change.mjs — the typed refusal of a runtime change by the Supervisor. Changing .claude belongs to Debug and the owner's
|
|
2
|
-
// release flow (modules/kernel/roles.yaml chain.runtimeChange); the verbs and the code are read from there, so the command guard,
|
|
3
|
-
// the file-write guard and the CLI dispatcher refuse with one voice.
|
|
4
|
-
import { rolesContract } from './roles-contract.mjs';
|
|
5
|
-
|
|
6
|
-
export const RUNTIME_CHANGE_CODE = 'RUNTIME_CHANGE_OWNED_BY_DEBUG';
|
|
7
|
-
|
|
8
|
-
/** The refusal fields {code, reason, remedy} for `words` (the group, verb and subcommand a caller runs), or null when they change nothing. */
|
|
9
|
-
export function runtimeChangeRefusal(words) {
|
|
10
|
-
const { runtimeChange } = rolesContract().chain;
|
|
11
|
-
const hit = runtimeChange.verbs.find((verb) => verb.every((word, index) => words[index] === word));
|
|
12
|
-
if (!hit) return null;
|
|
13
|
-
return { code: RUNTIME_CHANGE_CODE, reason: `the Supervisor changes no runtime code: starci ${hit.join(' ')} belongs to Debug`, remedy: runtimeChange.use };
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** The refusal fields for a Supervisor write inside the runtime checkout. */
|
|
17
|
-
export function runtimeWriteRefusal() {
|
|
18
|
-
const { runtimeChange } = rolesContract().chain;
|
|
19
|
-
return { code: RUNTIME_CHANGE_CODE, reason: 'the Supervisor changes no file of the runtime checkout: .claude belongs to Debug and the owner\'s release flow', remedy: runtimeChange.use };
|
|
20
|
-
}
|