@cohortapp/agent-sdk 2.11.15 → 2.13.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/.env.example +37 -22
- package/README.md +2 -0
- package/bin/maestro.mjs +117 -39
- package/bin/maestro.test.mjs +175 -5
- package/docs/guides/front-door-session.md +313 -0
- package/docs/guides/mac-mini.md +100 -28
- package/docs/guides/org-onboarding.md +1 -1
- package/docs/guides/setup-wizard.md +9 -5
- package/docs/runbooks/cohort-cutover.md +11 -1
- package/docs/runbooks/mac-mini-bootstrap.md +38 -63
- package/lib/cadence-bus-requeue.test.mjs +83 -0
- package/lib/cadence-bus.mjs +43 -7
- package/lib/channels/inbox-item.mjs +59 -2
- package/lib/cli/board.mjs +285 -0
- package/lib/cli/board.test.mjs +227 -0
- package/lib/cli/design.mjs +185 -0
- package/lib/cli/design.test.mjs +270 -0
- package/lib/cli/doctor-checks.mjs +441 -0
- package/lib/cli/doctor-checks.test.mjs +336 -0
- package/lib/cli/global-setup-extras.mjs +454 -0
- package/lib/cli/global-setup-extras.test.mjs +462 -0
- package/lib/cli/inbox.mjs +304 -0
- package/lib/cli/inbox.test.mjs +230 -0
- package/lib/cli/session-ack.mjs +63 -0
- package/lib/cli/session-ack.test.mjs +63 -0
- package/lib/cli/session.mjs +760 -0
- package/lib/cli/session.test.mjs +613 -0
- package/lib/collective/global-config.mjs +209 -6
- package/lib/collective/global-config.test.mjs +145 -0
- package/lib/collective/global-skills.mjs +145 -0
- package/lib/collective/global-skills.test.mjs +126 -0
- package/lib/collective/presence.mjs +4 -3
- package/lib/collective/vendor-skills.mjs +305 -0
- package/lib/collective/vendor-skills.test.mjs +306 -0
- package/lib/comms/send-gate.mjs +115 -0
- package/lib/comms/send-gate.test.mjs +113 -0
- package/lib/design/design-md.mjs +793 -0
- package/lib/design/design-md.test.mjs +318 -0
- package/lib/design/fixtures/DESIGN.golden.md +238 -0
- package/lib/design/fixtures/PRODUCT.golden.md +67 -0
- package/lib/design/fixtures/foundation.json +133 -0
- package/lib/design/refresh-gate.mjs +154 -0
- package/lib/design/refresh-gate.test.mjs +144 -0
- package/lib/design/write.mjs +275 -0
- package/lib/design/write.test.mjs +241 -0
- package/lib/feature-init.mjs +2 -2
- package/lib/mcp/server.test.mjs +9 -4
- package/lib/model-router/spawn.test.mjs +21 -0
- package/lib/org/board-mine-cache.mjs +99 -0
- package/lib/org/board-mine-cache.test.mjs +53 -0
- package/lib/org/board.mjs +11 -0
- package/lib/org/board.test.mjs +11 -1
- package/lib/org/client.mjs +36 -0
- package/lib/org/client.test.mjs +46 -0
- package/lib/org/inbound/directedness.mjs +18 -2
- package/lib/org/inbound/directedness.test.mjs +58 -0
- package/lib/org/inbound/index.mjs +8 -1
- package/lib/org/inbound/index.test.mjs +22 -0
- package/lib/org/mesh-directives.test.mjs +110 -0
- package/lib/org/mesh.mjs +61 -1
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +52 -0
- package/lib/org/protocol.test.mjs +12 -1
- package/lib/org/registry.mjs +3 -2
- package/lib/org/tool-surface.mjs +120 -0
- package/lib/org/tool-surface.test.mjs +118 -5
- package/lib/prompts/parallelism.mjs +79 -0
- package/lib/prompts/parallelism.test.mjs +177 -0
- package/lib/security/external-content.mjs +1 -1
- package/lib/security/external-content.test.mjs +17 -0
- package/lib/session/config.mjs +137 -0
- package/lib/session/config.test.mjs +92 -0
- package/lib/session/feed-core.mjs +229 -0
- package/lib/session/feed-core.test.mjs +198 -0
- package/lib/session/first-run.mjs +126 -0
- package/lib/session/first-run.test.mjs +121 -0
- package/lib/session/frontdoor.mjs +266 -0
- package/lib/session/frontdoor.test.mjs +205 -0
- package/lib/session/handoffs.mjs +295 -0
- package/lib/session/handoffs.test.mjs +183 -0
- package/lib/session/identity.mjs +220 -0
- package/lib/session/identity.test.mjs +180 -0
- package/lib/session/inbox-claims.mjs +434 -0
- package/lib/session/inbox-claims.test.mjs +286 -0
- package/lib/session/launch-args.mjs +161 -0
- package/lib/session/launch-args.test.mjs +157 -0
- package/lib/session/liveness.mjs +174 -0
- package/lib/session/liveness.test.mjs +100 -0
- package/lib/session/status-summary.mjs +172 -0
- package/lib/session/status-summary.test.mjs +118 -0
- package/lib/session-permissions.mjs +39 -3
- package/lib/session-permissions.test.mjs +20 -0
- package/lib/setup/claude-probe.mjs +161 -24
- package/lib/setup/claude-probe.test.mjs +187 -0
- package/lib/setup/sections/learning.mjs +2 -1
- package/lib/setup/sections/model.mjs +104 -24
- package/lib/setup/sections/model.test.mjs +240 -0
- package/lib/setup/sections/org.mjs +27 -2
- package/lib/setup/sections/org.test.mjs +35 -2
- package/lib/setup/sections/verify.mjs +5 -0
- package/lib/setup/state.mjs +30 -10
- package/lib/setup/state.test.mjs +24 -1
- package/lib/singleton.js +11 -3
- package/lib/singleton.test.mjs +16 -0
- package/lib/subagents/lock.mjs +1 -1
- package/lib/telemetry/collect.mjs +270 -6
- package/lib/telemetry/collect.test.mjs +196 -1
- package/lib/upgrade/global-refresh.mjs +108 -0
- package/lib/upgrade/global-refresh.test.mjs +65 -0
- package/lib/upgrade/launchd-reconcile.mjs +327 -0
- package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
- package/lib/upgrade/post-steps.mjs +151 -0
- package/lib/upgrade/post-steps.test.mjs +200 -0
- package/lib/upgrade/verify.mjs +215 -0
- package/lib/upgrade/verify.test.mjs +164 -0
- package/lib/voice/outbound.mjs +3 -2
- package/lib/voice/post-call-brief.mjs +2 -1
- package/lib/voice/session-rotation.mjs +6 -1
- package/lib/voice/session-rotation.test.mjs +114 -0
- package/package.json +3 -3
- package/plugins/maestro-skills/plugin.json +25 -1
- package/plugins/maestro-skills/skills/board-work.md +63 -0
- package/plugins/maestro-skills/skills/cohort-design.md +153 -0
- package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
- package/plugins/maestro-skills/skills/main-session.md +102 -0
- package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
- package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
- package/plugins/maestro-skills/vendor/emilkowalski/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/emilkowalski/UPSTREAM.json +70 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/RECIPES.md +324 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/SKILL.md +199 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animation-vocabulary/SKILL.md +173 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/apple-design/SKILL.md +282 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/emil-design-eng/SKILL.md +674 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/find-animation-opportunities/SKILL.md +132 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/AUDIT.md +115 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/PLAN-TEMPLATE.md +73 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/SKILL.md +101 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/PICKER.md +197 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/SKILL.md +90 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/SKILL.md +112 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/STANDARDS.md +187 -0
- package/plugins/maestro-skills/vendor/impeccable/LICENSE +191 -0
- package/plugins/maestro-skills/vendor/impeccable/NOTICE.md +11 -0
- package/plugins/maestro-skills/vendor/impeccable/SKILL.md +86 -0
- package/plugins/maestro-skills/vendor/impeccable/UPSTREAM.json +201 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-asset-producer.md +42 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-documenter.md +29 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-finish-reviewer.md +43 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-manual-edit-applier.md +97 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/adapt.md +312 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/adapt.native.md +58 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/android.md +46 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/animate.md +89 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/audit.md +136 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/audit.native.md +139 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/bolder.md +33 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/clarify.md +94 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/colorize.md +86 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/craft-floor.md +44 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/craft.md +5 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/critique.md +806 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/asset-producer.md +37 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/documenter.md +24 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/finish-reviewer.md +38 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/manual-edit-applier.md +92 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/delight.md +70 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/distill.md +111 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/doctor.md +54 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/document.md +416 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/extract.md +69 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/harden.md +336 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/hooks.md +111 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/init.md +131 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/ios.md +51 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/layout.md +84 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/live-setup.md +104 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/live.md +325 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/new-work.md +147 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/onboard.md +234 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/operate.md +61 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/optimize.md +258 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/overdrive.md +127 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/polish.md +105 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/quieter.md +99 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/routing.md +24 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/shape.md +59 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/typeset.md +80 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/visualize.md +46 -0
- package/plugins/maestro-skills/vendor/taste-skill/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/taste-skill/UPSTREAM.json +37 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/minimalist-skill/SKILL.md +85 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/redesign-skill/SKILL.md +178 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/soft-skill/SKILL.md +98 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/taste-skill/SKILL.md +1206 -0
- package/plugins/maestro-skills/vendor/unlazy/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/unlazy/SECURITY.md +72 -0
- package/plugins/maestro-skills/vendor/unlazy/SKILL.md +104 -0
- package/plugins/maestro-skills/vendor/unlazy/UPSTREAM.json +94 -0
- package/plugins/maestro-skills/vendor/unlazy/references/dispatch.md +82 -0
- package/plugins/maestro-skills/vendor/unlazy/references/gates.md +149 -0
- package/plugins/maestro-skills/vendor/unlazy/references/method.md +49 -0
- package/plugins/maestro-skills/vendor/unlazy/references/orchestration.md +107 -0
- package/plugins/maestro-skills/vendor/unlazy/references/parallel.md +133 -0
- package/plugins/maestro-skills/vendor/unlazy/references/token-economy.md +48 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/dispatch-check.mjs +139 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/gate-check.mjs +960 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/gate-lint.mjs +245 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/check-supervisor.mjs +46 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/dispatch.mjs +293 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/gates.mjs +953 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/process-tree.mjs +161 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/regex-worker.mjs +9 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/PLAN.md +116 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/gates-leaf.md +51 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/gates-node.md +51 -0
- package/scaffold/CLAUDE.md +24 -0
- package/scripts/ci/check-durable-write-seam.mjs +147 -0
- package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
- package/scripts/ci/check-skill-packs.mjs +388 -0
- package/scripts/ci/check-skill-packs.test.mjs +495 -0
- package/scripts/ci/check.mjs +6 -0
- package/scripts/collective/hook-runner.mjs +39 -4
- package/scripts/collective/hook-runner.test.mjs +85 -2
- package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
- package/scripts/daemon/agent-daemon-design.test.mjs +238 -0
- package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
- package/scripts/daemon/agent-daemon.mjs +249 -10
- package/scripts/daemon/agent-daemon.test.mjs +73 -0
- package/scripts/daemon/assurance-e2e.test.mjs +141 -6
- package/scripts/daemon/assurance.mjs +461 -37
- package/scripts/daemon/assurance.test.mjs +408 -43
- package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +393 -0
- package/scripts/daemon/cadence-consumer.mjs +289 -89
- package/scripts/daemon/cadence-handlers.mjs +53 -0
- package/scripts/daemon/classifier.mjs +1 -1
- package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
- package/scripts/daemon/dispatcher.mjs +127 -19
- package/scripts/daemon/health.mjs +12 -1
- package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
- package/scripts/daemon/inbox-deferral.mjs +6 -0
- package/scripts/daemon/lib/self-echo.mjs +201 -0
- package/scripts/daemon/lib/self-echo.test.mjs +153 -0
- package/scripts/daemon/maestro-daemon.mjs +3 -0
- package/scripts/daemon/prompt-builder.mjs +19 -3
- package/scripts/daemon/responder.mjs +51 -40
- package/scripts/daemon/sdk-version.mjs +51 -0
- package/scripts/daemon/sdk-version.test.mjs +31 -0
- package/scripts/hooks/pre-send-audit.sh +97 -4
- package/scripts/hooks/pre-send-audit.test.mjs +140 -1
- package/scripts/local-triggers/autoupdate.sh +243 -19
- package/scripts/local-triggers/autoupdate.test.mjs +518 -0
- package/scripts/local-triggers/generate-plists.sh +24 -1
- package/scripts/local-triggers/generate-plists.test.mjs +49 -11
- package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
- package/scripts/org/send-orgmail.mjs +27 -3
- package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
- package/scripts/poller/slack-poller.mjs +13 -1
- package/scripts/poller/utils.mjs +46 -1
- package/scripts/poller-launchd/install.sh +19 -11
- package/scripts/poller-launchd/install.test.mjs +243 -0
- package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
- package/scripts/poller-launchd/migrate.sh +66 -0
- package/scripts/poller-launchd/poller.plist.template +4 -2
- package/scripts/session/feed.mjs +237 -0
- package/scripts/session/feed.test.mjs +196 -0
- package/scripts/session/supervisor-sh.test.mjs +218 -0
- package/scripts/session/supervisor.mjs +328 -0
- package/scripts/session/supervisor.sh +141 -0
- package/scripts/session/supervisor.test.mjs +482 -0
- package/scripts/setup/configure-macos.sh +250 -55
- package/scripts/setup/configure-macos.test.mjs +306 -0
- package/scripts/setup/init-agent.sh +112 -7
- package/scripts/setup/init-agent.test.mjs +220 -1
- package/scripts/vendor/skill-packs.mjs +354 -0
- package/scripts/vendor/sync-skill-packs.mjs +242 -0
- package/scripts/vendor/sync-skill-packs.test.mjs +103 -0
- package/scripts/watchdog/memory-watchdog.sh +37 -1
- package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
- package/scripts/setup/boot-claude-session.sh +0 -94
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Best-effort process-tree cleanup shared by the gate runner and tests.
|
|
2
|
+
// Zero dependencies. Node 16+.
|
|
3
|
+
|
|
4
|
+
import { spawnSync as nodeSpawnSync } from "node:child_process";
|
|
5
|
+
import { win32 } from "node:path";
|
|
6
|
+
|
|
7
|
+
export const WINDOWS_TASKKILL_TIMEOUT_MS = 1000;
|
|
8
|
+
|
|
9
|
+
export function windowsTaskkillPath(env = process.env) {
|
|
10
|
+
const normalizeRoot = (value) => String(value || "").replace(/\//g, "\\").replace(/\\+$/, "");
|
|
11
|
+
const systemRoot = normalizeRoot(env.SystemRoot);
|
|
12
|
+
const windir = normalizeRoot(env.WINDIR);
|
|
13
|
+
const systemDrive = String(env.SystemDrive || "").replace(/[\\/]+$/, "").toUpperCase();
|
|
14
|
+
const driveRoot = /^[A-Za-z]:\\Windows$/i;
|
|
15
|
+
// Require the three standard Windows launcher values to identify the same
|
|
16
|
+
// drive-root directory. A single arbitrary absolute variable must not select
|
|
17
|
+
// an executable, and disagreement fails closed to the ChildProcess handle.
|
|
18
|
+
if (driveRoot.test(systemRoot) && driveRoot.test(windir) &&
|
|
19
|
+
systemRoot.toLowerCase() === windir.toLowerCase() &&
|
|
20
|
+
systemDrive === systemRoot.slice(0, 2).toUpperCase()) {
|
|
21
|
+
return win32.join(systemRoot, "System32", "taskkill.exe");
|
|
22
|
+
}
|
|
23
|
+
// A bare executable name consults cwd/PATH, which are controlled by the
|
|
24
|
+
// CHECK environment. If neither trusted system root exists, skip the helper
|
|
25
|
+
// and use the already-held ChildProcess handle instead.
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function syncFailure(result) {
|
|
30
|
+
if (!result) return "returned no result";
|
|
31
|
+
if (result.error) return result.error.code || result.error.message || "spawn error";
|
|
32
|
+
if (result.signal) return "signal " + result.signal;
|
|
33
|
+
if (result.status !== 0) return "exit " + String(result.status);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const childExited = (child) => child.exitCode !== null && child.exitCode !== undefined ||
|
|
38
|
+
child.signalCode !== null && child.signalCode !== undefined;
|
|
39
|
+
|
|
40
|
+
export function terminateProcessTree(child, options = {}) {
|
|
41
|
+
const platform = options.platform || process.platform;
|
|
42
|
+
const spawnSyncImpl = options.spawnSyncImpl || nodeSpawnSync;
|
|
43
|
+
const killGroup = options.killGroup || process.kill;
|
|
44
|
+
const pid = child && child.pid;
|
|
45
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
46
|
+
return { ok: false, fallback: false, diagnostic: "child PID is unavailable" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (platform !== "win32") {
|
|
50
|
+
// The gate runner launches a detached Node supervisor as the group leader
|
|
51
|
+
// and keeps it alive until the shell and inherited stdout/stderr close. If
|
|
52
|
+
// Node has already observed that supervisor exit, the numeric PGID no
|
|
53
|
+
// longer carries identity and may have been reused; never signal it.
|
|
54
|
+
if (childExited(child)) {
|
|
55
|
+
return { ok: true, fallback: false, diagnostic: "process supervisor already exited" };
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
killGroup(-pid, "SIGKILL");
|
|
59
|
+
return { ok: true, fallback: false, diagnostic: null };
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (childExited(child)) {
|
|
62
|
+
return {
|
|
63
|
+
ok: true,
|
|
64
|
+
fallback: true,
|
|
65
|
+
diagnostic: "process-group kill failed (" + (error.code || error.message) + "); supervisor already exited",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const requested = child.kill("SIGKILL");
|
|
70
|
+
if (requested === false) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
fallback: true,
|
|
74
|
+
diagnostic: "process-group kill failed (" + (error.code || error.message) +
|
|
75
|
+
"); child fallback returned false",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
ok: true,
|
|
80
|
+
fallback: true,
|
|
81
|
+
diagnostic: "process-group kill failed (" + (error.code || error.message) + "); child fallback requested",
|
|
82
|
+
};
|
|
83
|
+
} catch (fallbackError) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
fallback: true,
|
|
87
|
+
diagnostic: "process-group kill failed (" + (error.code || error.message) +
|
|
88
|
+
"); child fallback failed (" + (fallbackError.code || fallbackError.message) + ")",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// taskkill addresses the stored leader PID, unlike a POSIX process-group
|
|
95
|
+
// request. Do not target it after Node reports exit because the PID may have
|
|
96
|
+
// been reused while an inherited pipe remains open.
|
|
97
|
+
if (childExited(child)) {
|
|
98
|
+
return { ok: true, fallback: false, diagnostic: "child already exited" };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const command = windowsTaskkillPath(options.env || process.env);
|
|
102
|
+
const requestedTimeout = Number(options.taskkillTimeoutMs);
|
|
103
|
+
const taskkillTimeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout > 0
|
|
104
|
+
? Math.floor(requestedTimeout)
|
|
105
|
+
: WINDOWS_TASKKILL_TIMEOUT_MS;
|
|
106
|
+
let result;
|
|
107
|
+
let failure;
|
|
108
|
+
if (!command) {
|
|
109
|
+
failure = "trusted system taskkill path unavailable";
|
|
110
|
+
} else {
|
|
111
|
+
try {
|
|
112
|
+
result = spawnSyncImpl(command, ["/pid", String(pid), "/f", "/t"], {
|
|
113
|
+
stdio: "ignore",
|
|
114
|
+
windowsHide: true,
|
|
115
|
+
// Cleanup runs inside the gate timeout path. A broken helper must not
|
|
116
|
+
// replace a bounded CHECK with an unbounded synchronous wait.
|
|
117
|
+
timeout: taskkillTimeoutMs,
|
|
118
|
+
killSignal: "SIGKILL",
|
|
119
|
+
});
|
|
120
|
+
failure = syncFailure(result);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
failure = error.code || error.message || "spawn threw";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (!failure) return { ok: true, fallback: false, diagnostic: null, command };
|
|
126
|
+
|
|
127
|
+
if (childExited(child)) {
|
|
128
|
+
return {
|
|
129
|
+
ok: true,
|
|
130
|
+
fallback: true,
|
|
131
|
+
command,
|
|
132
|
+
diagnostic: "taskkill failed (" + failure + "); child already exited",
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const requested = child.kill("SIGKILL");
|
|
138
|
+
if (requested === false) {
|
|
139
|
+
return {
|
|
140
|
+
ok: false,
|
|
141
|
+
fallback: true,
|
|
142
|
+
command,
|
|
143
|
+
diagnostic: "taskkill failed (" + failure + "); child fallback returned false",
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
ok: true,
|
|
148
|
+
fallback: true,
|
|
149
|
+
command,
|
|
150
|
+
diagnostic: "taskkill failed (" + failure + "); child fallback requested",
|
|
151
|
+
};
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
fallback: true,
|
|
156
|
+
command,
|
|
157
|
+
diagnostic: "taskkill failed (" + failure + "); child fallback failed (" +
|
|
158
|
+
(error.code || error.message) + ")",
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { parentPort } from "node:worker_threads";
|
|
2
|
+
|
|
3
|
+
parentPort.once("message", ({ source, flags, output }) => {
|
|
4
|
+
try {
|
|
5
|
+
parentPort.postMessage({ matched: new RegExp(source, flags).test(output) });
|
|
6
|
+
} catch (error) {
|
|
7
|
+
parentPort.postMessage({ error: error.message });
|
|
8
|
+
}
|
|
9
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Plan: <task>
|
|
2
|
+
|
|
3
|
+
Scope: <validated pipeline id; store this file at .unlazy/<scope>/PLAN.md>
|
|
4
|
+
Depth: tree <N>
|
|
5
|
+
Mode: orchestrated
|
|
6
|
+
|
|
7
|
+
## Contract
|
|
8
|
+
|
|
9
|
+
Decide before fan-out:
|
|
10
|
+
|
|
11
|
+
- Interfaces: <signatures, schemas, formats, integration points>
|
|
12
|
+
- Ownership: <one complete set of repository-relative paths per leaf; no absolute paths, traversal, or concurrent overlap>
|
|
13
|
+
- Dependencies: <leaf ids that must be VERIFIED first>
|
|
14
|
+
- Host launch mode: <Codex native subagents | Claude background Agents | Claude Dynamic Workflow | sequential fallback>
|
|
15
|
+
- Wave policy: <which independent READY leaves launch together and the maximum host concurrency>
|
|
16
|
+
- Toolchain: <runtime versions, shell, working-directory rules, test commands>
|
|
17
|
+
- Conventions: <naming, errors, compatibility, formatting>
|
|
18
|
+
- Manual review: <owner and evidence standard for consequential manual gates>
|
|
19
|
+
|
|
20
|
+
## Current contract inventory
|
|
21
|
+
|
|
22
|
+
Contract revision: 1. Before fan-out, reread the original request and current amendments. Record every independently omittable required outcome and every constraint that changes acceptance; do not copy credentials, private text, or unrelated context.
|
|
23
|
+
|
|
24
|
+
| ID | Required outcome or constraint | Owner | Observing gate or manual review | Disposition | Revision |
|
|
25
|
+
|---|---|---|---|---|---|
|
|
26
|
+
| C1 | <concise paraphrase> | <leaf/node> | <qualified gate/reviewer> | ACTIVE | 1 |
|
|
27
|
+
|
|
28
|
+
Use stable ids. On an amendment, increment the revision and reconcile every affected row before dispatch or completion credit. `ACTIVE` is complete only with a current owner and observation. `ABANDONED`, `DEFERRED`, and `OWNER_DECISION` are honest non-completion; only explicit user authority may use `REMOVED_BY_USER`.
|
|
29
|
+
|
|
30
|
+
## State vocabulary
|
|
31
|
+
|
|
32
|
+
Leaf state is exactly one of:
|
|
33
|
+
|
|
34
|
+
- WAITING: at least one id in Needs is not VERIFIED
|
|
35
|
+
- READY: dependencies are VERIFIED and ownership can be claimed
|
|
36
|
+
- IN-FLIGHT: dispatched, not yet parent-verified
|
|
37
|
+
- VERIFIED: parent --reverify passed and manual gates were reviewed
|
|
38
|
+
- ABANDONED: a required gate has a visible handoff
|
|
39
|
+
|
|
40
|
+
Branch state is exactly one of OPEN, VERIFIED, or ABANDONED. Derive root and
|
|
41
|
+
branch state from their ledgers; do not copy it into the topology tree.
|
|
42
|
+
|
|
43
|
+
## Tree
|
|
44
|
+
|
|
45
|
+
Use this tree only for parent-child topology and ledger paths. Use `leaf-` paths
|
|
46
|
+
for work leaves and `node-` paths for branch integration. Keep leaf operational
|
|
47
|
+
fields only in the dispatch table below; do not repeat `Owns`, `Needs`, `Tier`,
|
|
48
|
+
`Planned wave`, or `State` here and do not create a separate schedule.
|
|
49
|
+
|
|
50
|
+
- 1 <task> .............. GATES.md
|
|
51
|
+
- 1.1 <branch> ........ gates/node-1.1.md
|
|
52
|
+
- 1.1.1 <leaf> ...... gates/leaf-1.1.1.md
|
|
53
|
+
- 1.1.2 <leaf> ...... gates/leaf-1.1.2.md
|
|
54
|
+
- 1.2 <branch> ........ gates/node-1.2.md
|
|
55
|
+
- 1.2.1 <leaf> ...... gates/leaf-1.2.1.md
|
|
56
|
+
- 1.2.2 <leaf> ...... gates/leaf-1.2.2.md
|
|
57
|
+
|
|
58
|
+
## Leaf dispatch table
|
|
59
|
+
|
|
60
|
+
This is the single authoritative PLAN table for leaf operation. It is
|
|
61
|
+
authoritative for `Needs`, `Tier`, `Planned wave`, and `State`; `Owns` remains
|
|
62
|
+
visible here as the derived planning mirror described below. Keep exactly one
|
|
63
|
+
row per tree leaf and do not duplicate these fields in the tree or a schedule.
|
|
64
|
+
|
|
65
|
+
| Leaf | Owns | Needs | Tier | Planned wave | State |
|
|
66
|
+
|---|---|---|---|---|---|
|
|
67
|
+
| 1.1.1 | src/<a>/**, tests/<a>/** | - | mechanical | 1 | READY |
|
|
68
|
+
| 1.1.2 | src/<b>/**, tests/<b>/** | - | judgment | 1 | READY |
|
|
69
|
+
| 1.2.1 | src/<c>/**, tests/<c>/** | - | mechanical | 1 | READY |
|
|
70
|
+
| 1.2.2 | src/<d>/**, tests/<d>/** | 1.2.1 | judgment | 2 | WAITING |
|
|
71
|
+
|
|
72
|
+
`Owns` is a derived planning mirror of the complete glob set in the leaf ledger.
|
|
73
|
+
The ledger's `OWNS:` header is the command-time authority read by `--claim`.
|
|
74
|
+
Normalize both into sets and require equality before marking the row `READY` and
|
|
75
|
+
again before each claim. On disagreement, fail closed, correct and log the plan
|
|
76
|
+
or ledger, and recheck; never guess ownership. A successful claim does not prove
|
|
77
|
+
the mirror agreed with the ledger.
|
|
78
|
+
|
|
79
|
+
`Tier` is planner metadata for execution leaves. Use `judgment` when a leaf's own
|
|
80
|
+
artifact needs design or review, and `mechanical` only when its pattern and gates
|
|
81
|
+
are fixed. Map a tier through documented host-specific model or reasoning
|
|
82
|
+
controls only when the host exposes them. Otherwise retain the tier as a briefing
|
|
83
|
+
and review requirement without claiming a model choice. Driver and branch duties,
|
|
84
|
+
including planning, dispatch, parent verification, integration, and final audit,
|
|
85
|
+
remain judgment responsibilities outside this leaf field. Tier never weakens a
|
|
86
|
+
leaf's gates, parent re-verification, or integration standard.
|
|
87
|
+
|
|
88
|
+
`Planned wave` is the earliest intended launch group under the current contract.
|
|
89
|
+
Use a positive integer, put every dependency in an earlier planned wave, and let
|
|
90
|
+
the wave policy cap concurrency. It is a plan, not a barrier: rolling dispatch
|
|
91
|
+
may start a later planned wave as soon as that row's dependencies are verified,
|
|
92
|
+
without waiting for unrelated work. Actual starts belong in `dispatch.json` and
|
|
93
|
+
`status.log`; do not add a second schedule to this file.
|
|
94
|
+
|
|
95
|
+
Change `Needs`, `Owns`, `Tier`, or `Planned wave` only through a recorded plan
|
|
96
|
+
amendment before that row launches; do not erase a dependency when it becomes
|
|
97
|
+
satisfied. Update `State` in this table as work progresses. After parent
|
|
98
|
+
re-verification and manual review, mark a leaf `VERIFIED`, release that exact
|
|
99
|
+
leaf lease, and record the release. Do not promote a dependent until that exact
|
|
100
|
+
release is recorded. For an abandoned leaf, record the handoff and confirm its
|
|
101
|
+
worker has settled before releasing only that leaf; never call it
|
|
102
|
+
parent-verified. Release the whole scope only after every leaf is settled and
|
|
103
|
+
final scope verification has run. If final verification reports a handoff,
|
|
104
|
+
record it before release and never describe the scope as complete.
|
|
105
|
+
|
|
106
|
+
## Status log
|
|
107
|
+
|
|
108
|
+
Append events to `.unlazy/<scope>/status.log`; do not copy the event history into this file:
|
|
109
|
+
|
|
110
|
+
```text
|
|
111
|
+
node <skill-dir>/scripts/gate-check.mjs --scope <scope> --log "leaf-1.1.1 dispatched"
|
|
112
|
+
node <skill-dir>/scripts/gate-check.mjs --scope <scope> --log "leaf-1.1.1 verified"
|
|
113
|
+
node <skill-dir>/scripts/gate-check.mjs --scope <scope> --log "leaf-1.1.1 lease released"
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Record contract amendments, plan changes, dispatch, parent verification, abandonment, branch integration, and lease release. Apply logged plan amendments and live State updates only in the dispatch table; keep the log append-only. Before root completion, reread the current request and review every current inventory row against its owner and observing gate or manual review.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Gates: <leaf or task name>
|
|
2
|
+
|
|
3
|
+
OWNS: <repository-relative globs this leaf may write, for example src/api/**, tests/api/**>
|
|
4
|
+
|
|
5
|
+
Scope: <one sentence describing the complete deliverable>
|
|
6
|
+
|
|
7
|
+
- [ ] G1: <observable outcome measured directly from the artifact>
|
|
8
|
+
CHECK: node scripts/verify-outcome.mjs
|
|
9
|
+
EXPECT: outcome verification passed
|
|
10
|
+
EVIDENCE: pending
|
|
11
|
+
|
|
12
|
+
- [ ] G2: <integration outcome in a subproject>
|
|
13
|
+
CHECK: node scripts/verify-integration.mjs
|
|
14
|
+
EXPECT: integration verification passed
|
|
15
|
+
CWD: packages/example
|
|
16
|
+
EVIDENCE: pending
|
|
17
|
+
|
|
18
|
+
- [ ] G3: <manual outcome that no command can decide>
|
|
19
|
+
EVIDENCE: pending
|
|
20
|
+
|
|
21
|
+
<!--
|
|
22
|
+
Replace every placeholder before running the checker.
|
|
23
|
+
|
|
24
|
+
Strict format:
|
|
25
|
+
- Use a unique explicit id for every gate.
|
|
26
|
+
- Indent CHECK, EXPECT, CWD, and EVIDENCE.
|
|
27
|
+
- Give a runnable gate both CHECK and EXPECT; give a manual gate neither.
|
|
28
|
+
- Success requires process exit 0 and EXPECT.
|
|
29
|
+
- Make EXPECT a success-only marker produced after every assertion passes.
|
|
30
|
+
- For an absence or negative assertion, test the same checker against a known
|
|
31
|
+
positive fixture and record that control in the gate's manual review.
|
|
32
|
+
- Measure supplied figures from source. Do not copy a supplied number into
|
|
33
|
+
EXPECT as its own proof.
|
|
34
|
+
- Use repository-owned Node scripts for portable examples. Declare any
|
|
35
|
+
non-default shell or external tool requirement explicitly.
|
|
36
|
+
- Scoped and legacy discovery anchor checks at the repository root. When this
|
|
37
|
+
ledger is named explicitly from `.unlazy/`, pass an explicit repository
|
|
38
|
+
`--root` and `--cwd` so repository-relative commands keep the same base.
|
|
39
|
+
- Record exact manual evidence and review consequential manual gates by risk.
|
|
40
|
+
- OWNS paths must be repository-relative, complete, and disjoint from every
|
|
41
|
+
concurrently dispatched leaf. Claims coordinate writers; they do not sandbox.
|
|
42
|
+
|
|
43
|
+
If a gate becomes genuinely impossible, keep the gate and add:
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
ABANDON: G<n> <non-empty reason and handoff>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Surface every abandonment as a non-successful handoff in the final report; an
|
|
50
|
+
abandoned leaf is not complete. See references/gates.md.
|
|
51
|
+
-->
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Gates: <branch name> integration
|
|
2
|
+
|
|
3
|
+
Scope: integrate children <explicit child ids> into one verified result
|
|
4
|
+
|
|
5
|
+
- [ ] N1: every named direct child is reverified from its exact ledger
|
|
6
|
+
CHECK: node <skill-dir>/scripts/gate-check.mjs --root . --cwd . --reverify --jobs 1 .unlazy/<scope>/gates/leaf-<a>.md .unlazy/<scope>/gates/node-<b>.md
|
|
7
|
+
EXPECT: ALL MET
|
|
8
|
+
EVIDENCE: pending
|
|
9
|
+
|
|
10
|
+
- [ ] N2: child interfaces match the contract in PLAN.md
|
|
11
|
+
CHECK: node scripts/verify-interfaces.mjs
|
|
12
|
+
EXPECT: interface verification passed
|
|
13
|
+
EVIDENCE: pending
|
|
14
|
+
|
|
15
|
+
- [ ] N3: cross-child behavior works end to end
|
|
16
|
+
CHECK: node scripts/verify-integration.mjs
|
|
17
|
+
EXPECT: integration verification passed
|
|
18
|
+
EVIDENCE: pending
|
|
19
|
+
|
|
20
|
+
- [ ] N4: affected sibling behavior has not regressed
|
|
21
|
+
CHECK: node scripts/verify-regressions.mjs
|
|
22
|
+
EXPECT: regression verification passed
|
|
23
|
+
EVIDENCE: pending
|
|
24
|
+
|
|
25
|
+
- [ ] N5: every direct leaf child's ownership lease was released after parent verification
|
|
26
|
+
EVIDENCE: pending
|
|
27
|
+
|
|
28
|
+
- [ ] N6: consequential manual outcomes from the children were reviewed at branch level
|
|
29
|
+
EVIDENCE: pending
|
|
30
|
+
|
|
31
|
+
<!--
|
|
32
|
+
Replace every placeholder before running the checker.
|
|
33
|
+
|
|
34
|
+
N1 must name every direct child ledger explicitly, whether that child is a leaf
|
|
35
|
+
or another branch, and use --reverify, not --status. Status validates the stored
|
|
36
|
+
definition binding without executing or inspecting current artifacts. Keep --jobs 1 unless the child checks are independent and
|
|
37
|
+
deterministic parallel execution is intentional.
|
|
38
|
+
If a child reports an abandonment, N1 exits 1 with `HANDOFF REQUIRED`. Mark the
|
|
39
|
+
branch ABANDONED and surface the handoff; never rewrite that result as completion.
|
|
40
|
+
|
|
41
|
+
Branch paths use node-<id>.md. Leaf paths use leaf-<id>.md. Branch completion
|
|
42
|
+
requires integration evidence; a set of locally complete leaves is not enough.
|
|
43
|
+
|
|
44
|
+
For N5, run this once for each direct leaf child after verification and record
|
|
45
|
+
the outputs as manual evidence. Branch children do not own leaf leases:
|
|
46
|
+
|
|
47
|
+
node <skill-dir>/scripts/gate-check.mjs --scope <scope> --leaf leaf-<id> --release
|
|
48
|
+
|
|
49
|
+
Drop N5 only when no child claimed ownership. See references/orchestration.md
|
|
50
|
+
and references/parallel.md.
|
|
51
|
+
-->
|
package/scaffold/CLAUDE.md
CHANGED
|
@@ -351,6 +351,30 @@ This agent is powered by the `@cohortapp/agent-sdk` framework. Update framework:
|
|
|
351
351
|
- All configs must use environment variables for secrets
|
|
352
352
|
- All actions must be auditable via logs/
|
|
353
353
|
|
|
354
|
+
### Writing code that survives unattended operation
|
|
355
|
+
|
|
356
|
+
You run unattended. Nobody is watching when something goes wrong, and your
|
|
357
|
+
daemon, pollers and cadence consumers share one state directory. Code you cannot
|
|
358
|
+
reason about in isolation is code whose failure surfaces days later, in a log.
|
|
359
|
+
So:
|
|
360
|
+
|
|
361
|
+
- **Decisions are pure functions; I/O happens around them.** Work out *what*
|
|
362
|
+
should happen from the arguments you were given, then read and write at the
|
|
363
|
+
edges. A rule you can call without a filesystem is a rule you can test.
|
|
364
|
+
- **`now` is an argument** wherever it changes the answer:
|
|
365
|
+
`fn(args, now = new Date())`. A schedule that reads the wall clock internally
|
|
366
|
+
cannot be checked until the moment it fires.
|
|
367
|
+
- **Expected failure is a returned value,** `{ok:false, error}` — not a throw.
|
|
368
|
+
Throw only when something is genuinely broken.
|
|
369
|
+
- **Every `catch {}` gets a comment** saying why continuing is correct. A silent
|
|
370
|
+
swallow is a bug you will meet later without a clue where it came from.
|
|
371
|
+
- **Durable JSON is written atomically** — via maestro's `lib/fs-atomic.mjs`
|
|
372
|
+
(`writeJsonAtomic`) where available. A bare write can leave a half-written
|
|
373
|
+
file for a concurrent reader to parse.
|
|
374
|
+
- **Prefer adding a file to a registry over editing a dispatcher.**
|
|
375
|
+
- Full doctrine, if maestro is checked out locally:
|
|
376
|
+
`~/maestro/docs/engineering/functional-architecture.md`.
|
|
377
|
+
|
|
354
378
|
## Three Operating Modes
|
|
355
379
|
|
|
356
380
|
### Mode 1: Reactive — Respond to Events
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-durable-write-seam.mjs — the durable-write effect boundary, enforced.
|
|
3
|
+
*
|
|
4
|
+
* ── WHAT THIS DEFENDS ────────────────────────────────────────────────────────
|
|
5
|
+
* `lib/fs-atomic.mjs` is the ONE home for the write-temp → fsync → rename
|
|
6
|
+
* primitive. Its own docstring names the hazard: a bare `writeFileSync` can
|
|
7
|
+
* leave a zero-length or half-written file if the process dies mid-write, and a
|
|
8
|
+
* reader then parses corruption. Because maestro runs daemons, pollers and
|
|
9
|
+
* cadence consumers concurrently on the same state directory, a torn write is
|
|
10
|
+
* not a theoretical failure here — it is the observable one.
|
|
11
|
+
*
|
|
12
|
+
* ── WHAT THIS RULE CAN SEE ───────────────────────────────────────────────────
|
|
13
|
+
* A `writeFileSync(` call, in a non-test file under `lib/`, whose argument list
|
|
14
|
+
* contains `JSON.stringify` — i.e. a write of DURABLE STRUCTURED STATE. That is
|
|
15
|
+
* the shape worth defending: JSON is what gets read back and parsed, so JSON is
|
|
16
|
+
* what a torn write corrupts.
|
|
17
|
+
*
|
|
18
|
+
* ── WHAT IT DELIBERATELY DOES NOT SEE, and why each is correct ───────────────
|
|
19
|
+
* · `writeFileSync(tmp, …)` — a write to a temp sibling that a `renameSync`
|
|
20
|
+
* then publishes. That IS the atomic pattern, hand-rolled. It is safe. Some
|
|
21
|
+
* 30-odd files in `lib/` do exactly this; failing them would make this guard
|
|
22
|
+
* noise, and a guard that cries wolf gets suppressed rather than obeyed.
|
|
23
|
+
* · `deps.writeFileSync || writeFileSync` — the explicit-dependency form
|
|
24
|
+
* (`capability/probe.mjs`, `mandate/cache.mjs`, `plan/emit.mjs`,
|
|
25
|
+
* `capability/inventory.mjs`). Injecting the effect is BETTER than routing
|
|
26
|
+
* it through a shared helper, not worse. Never flag it.
|
|
27
|
+
* · Non-JSON writes — audio buffers, YAML config, `.md` briefs, `.env` stubs,
|
|
28
|
+
* marker files like `.emergency-stop`. Torn-write risk is real but the blast
|
|
29
|
+
* radius is a re-run, not a parse error in a consumer.
|
|
30
|
+
*
|
|
31
|
+
* So this is the cheap first net over one specific, high-value shape. It is not
|
|
32
|
+
* a proof that `lib/` has no unsafe write.
|
|
33
|
+
*
|
|
34
|
+
* ── WHY A REGISTER AND NOT A ZERO ────────────────────────────────────────────
|
|
35
|
+
* One legitimate spelling survives: acquiring a lock via `O_EXCL`
|
|
36
|
+
* (`{ flag: "wx" }`). A lock MUST be created exclusively and in place — publish
|
|
37
|
+
* it by rename and two processes can both believe they hold it, which is the
|
|
38
|
+
* exact bug the lock exists to prevent. So the check asserts SET EQUALITY
|
|
39
|
+
* against an explicit register with a reason per entry: a NEW site fails, and a
|
|
40
|
+
* site that goes away ALSO fails (its register entry is now stale). The register
|
|
41
|
+
* cannot become a place stale names hide. This is the idiom
|
|
42
|
+
* `eslint.config.mjs`/`authz-drift.test.ts` use in the Cohort repo.
|
|
43
|
+
*
|
|
44
|
+
* Set equality also makes this guard self-canarying: the register is non-empty,
|
|
45
|
+
* so a regex broken by a refactor yields zero matches and fails LOUDLY as three
|
|
46
|
+
* stale entries, rather than passing vacuously.
|
|
47
|
+
*
|
|
48
|
+
* Pure, dependency-light: Node builtins only. ESM.
|
|
49
|
+
*
|
|
50
|
+
* Usage: `node scripts/ci/check-durable-write-seam.mjs` (exit 0 ok / 1 found)
|
|
51
|
+
* @module scripts/ci/check-durable-write-seam
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
"use strict";
|
|
55
|
+
|
|
56
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
57
|
+
import path from "node:path";
|
|
58
|
+
import { fileURLToPath } from "node:url";
|
|
59
|
+
|
|
60
|
+
const REPO_ROOT = path.resolve(fileURLToPath(new URL(".", import.meta.url)), "..", "..");
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The sanctioned direct writers of durable JSON, and why each one must be.
|
|
64
|
+
* `site` is `<repo-relative path>:<line>`; a reason is mandatory.
|
|
65
|
+
* @type {Record<string, string>}
|
|
66
|
+
*/
|
|
67
|
+
export const SANCTIONED_DIRECT_WRITES = {
|
|
68
|
+
"lib/cadence-bus.mjs:315":
|
|
69
|
+
"acquireScheduleLock: O_EXCL create. A lock published by rename is not a lock — two processes could both succeed.",
|
|
70
|
+
"lib/cadence-bus.mjs:323":
|
|
71
|
+
"acquireScheduleLock: reclaim of a lock already proven stale by mtime; must land in place under the same name.",
|
|
72
|
+
"lib/cadence-bus.mjs:328":
|
|
73
|
+
"acquireScheduleLock: second O_EXCL attempt after the stale holder vanished.",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Argument spellings that are already safe and must never be flagged. */
|
|
77
|
+
const TEMP_TARGET_RE = /writeFileSync\(\s*(tmp|temp|tmpPath|tmpFile|fd)\b/;
|
|
78
|
+
const INJECTED_RE = /deps\.writeFileSync/;
|
|
79
|
+
|
|
80
|
+
/** @param {string} dir @param {string[]} [out] @returns {string[]} */
|
|
81
|
+
function walk(dir, out = []) {
|
|
82
|
+
for (const entry of readdirSync(dir)) {
|
|
83
|
+
if (entry === "node_modules") continue;
|
|
84
|
+
const p = path.join(dir, entry);
|
|
85
|
+
if (statSync(p).isDirectory()) walk(p, out);
|
|
86
|
+
else if (/\.(mjs|js)$/.test(entry) && !/\.test\.(mjs|js)$/.test(entry)) out.push(p);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Every direct durable-JSON write under `lib/`, as `path:line` → source line.
|
|
93
|
+
* @param {object} [opts] @param {string} [opts.cwd]
|
|
94
|
+
* @returns {Array<{ site: string, line: string }>}
|
|
95
|
+
*/
|
|
96
|
+
export function findDirectDurableWrites(opts = {}) {
|
|
97
|
+
const cwd = opts.cwd || REPO_ROOT;
|
|
98
|
+
const libDir = path.join(cwd, "lib");
|
|
99
|
+
const hits = [];
|
|
100
|
+
for (const file of walk(libDir)) {
|
|
101
|
+
const rel = path.relative(cwd, file);
|
|
102
|
+
const lines = readFileSync(file, "utf8").split("\n");
|
|
103
|
+
lines.forEach((line, i) => {
|
|
104
|
+
if (!line.includes("writeFileSync(")) return;
|
|
105
|
+
if (!line.includes("JSON.stringify")) return;
|
|
106
|
+
if (TEMP_TARGET_RE.test(line)) return;
|
|
107
|
+
if (INJECTED_RE.test(line)) return;
|
|
108
|
+
hits.push({ site: `${rel}:${i + 1}`, line: line.trim() });
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return hits;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** @param {string} [cwd=REPO_ROOT] @returns {Promise<number>} */
|
|
115
|
+
export async function run(cwd = REPO_ROOT) {
|
|
116
|
+
let hits;
|
|
117
|
+
try {
|
|
118
|
+
hits = findDirectDurableWrites({ cwd });
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.error(`check-durable-write-seam: ERROR — ${err && err.message ? err.message : err}`);
|
|
121
|
+
return 2;
|
|
122
|
+
}
|
|
123
|
+
const found = new Set(hits.map((h) => h.site));
|
|
124
|
+
const registered = new Set(Object.keys(SANCTIONED_DIRECT_WRITES));
|
|
125
|
+
|
|
126
|
+
const unregistered = hits.filter((h) => !registered.has(h.site));
|
|
127
|
+
const stale = [...registered].filter((s) => !found.has(s));
|
|
128
|
+
|
|
129
|
+
if (unregistered.length === 0 && stale.length === 0) {
|
|
130
|
+
console.log(`check-durable-write-seam: OK (${registered.size} sanctioned direct writes, all accounted for)`);
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
console.error("check-durable-write-seam: FAIL — the durable-write seam has drifted:");
|
|
134
|
+
for (const h of unregistered) {
|
|
135
|
+
console.error(` NEW ${h.site}`);
|
|
136
|
+
console.error(` ${h.line}`);
|
|
137
|
+
console.error(" Use writeJsonAtomic() from lib/fs-atomic.mjs, or add a reasoned entry to SANCTIONED_DIRECT_WRITES.");
|
|
138
|
+
}
|
|
139
|
+
for (const s of stale) {
|
|
140
|
+
console.error(` STALE ${s} no longer matches — delete its SANCTIONED_DIRECT_WRITES entry (reason: ${SANCTIONED_DIRECT_WRITES[s]})`);
|
|
141
|
+
}
|
|
142
|
+
return 1;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
146
|
+
run().then((c) => process.exit(c)).catch((e) => { console.error("check-durable-write-seam: ERROR", e && e.message ? e.message : e); process.exit(2); });
|
|
147
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for check-durable-write-seam.mjs — the durable-write effect boundary.
|
|
3
|
+
*
|
|
4
|
+
* Two things are worth testing and they are not the same thing:
|
|
5
|
+
* 1. The MATCHER's discrimination — that it fires on a direct durable-JSON
|
|
6
|
+
* write and stays silent on the three shapes that are already safe
|
|
7
|
+
* (temp-then-rename, injected `deps.writeFileSync`, non-JSON payloads).
|
|
8
|
+
* A guard that flags safe code gets suppressed, so this is the property
|
|
9
|
+
* that decides whether the guard survives contact with contributors.
|
|
10
|
+
* 2. The REGISTER's honesty against the real tree — that every sanctioned
|
|
11
|
+
* site still exists at the line it claims. This is what stops the register
|
|
12
|
+
* becoming a place stale names hide.
|
|
13
|
+
*
|
|
14
|
+
* @module scripts/ci/check-durable-write-seam.test
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it } from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import {
|
|
23
|
+
findDirectDurableWrites,
|
|
24
|
+
SANCTIONED_DIRECT_WRITES,
|
|
25
|
+
} from "./check-durable-write-seam.mjs";
|
|
26
|
+
|
|
27
|
+
const REPO_ROOT = path.resolve(fileURLToPath(new URL(".", import.meta.url)), "..", "..");
|
|
28
|
+
|
|
29
|
+
/** The matcher, lifted verbatim from the guard so a line can be tested alone. */
|
|
30
|
+
function flags(line) {
|
|
31
|
+
if (!line.includes("writeFileSync(")) return false;
|
|
32
|
+
if (!line.includes("JSON.stringify")) return false;
|
|
33
|
+
if (/writeFileSync\(\s*(tmp|temp|tmpPath|tmpFile|fd)\b/.test(line)) return false;
|
|
34
|
+
if (/deps\.writeFileSync/.test(line)) return false;
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("check-durable-write-seam: what the matcher sees", () => {
|
|
39
|
+
it("flags a direct write of durable JSON to a final path", () => {
|
|
40
|
+
assert.equal(flags('writeFileSync(join(dir, "self.json"), JSON.stringify(entry, null, 2));'), true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("does NOT flag temp-then-rename — that IS the atomic pattern, hand-rolled", () => {
|
|
44
|
+
assert.equal(flags('writeFileSync(tmp, JSON.stringify(state, null, 2) + "\\n");'), false);
|
|
45
|
+
assert.equal(flags("writeFileSync(tmpPath, JSON.stringify(o));"), false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("does NOT flag the injected-dependency form — that is better than a shared helper, not worse", () => {
|
|
49
|
+
assert.equal(flags("const wr = deps.writeFileSync || writeFileSync; wr(p, JSON.stringify(o));"), false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("does NOT flag non-JSON payloads — audio, YAML, markdown, marker files", () => {
|
|
53
|
+
assert.equal(flags("writeFileSync(join(d, f), audio);"), false);
|
|
54
|
+
assert.equal(flags("writeFileSync(p, yaml.dump(doc));"), false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("check-durable-write-seam: the register is honest about the real tree", () => {
|
|
59
|
+
const hits = findDirectDurableWrites();
|
|
60
|
+
const found = new Set(hits.map((h) => h.site));
|
|
61
|
+
|
|
62
|
+
it("finds at least one site — a matcher that matches nothing passes vacuously", () => {
|
|
63
|
+
assert.ok(hits.length > 0, "matcher found nothing; it is probably broken");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("every sanctioned site still exists at the line it claims", () => {
|
|
67
|
+
for (const site of Object.keys(SANCTIONED_DIRECT_WRITES)) {
|
|
68
|
+
assert.ok(found.has(site), `stale register entry: ${site}`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("no unregistered direct durable write exists under lib/", () => {
|
|
73
|
+
const unregistered = [...found].filter((s) => !(s in SANCTIONED_DIRECT_WRITES)).sort();
|
|
74
|
+
assert.deepEqual(unregistered, []);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("every sanctioned entry carries a non-empty reason", () => {
|
|
78
|
+
for (const [site, reason] of Object.entries(SANCTIONED_DIRECT_WRITES)) {
|
|
79
|
+
assert.ok(reason && reason.length > 20, `register entry ${site} needs a real reason`);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("the sanctioned sites are all O_EXCL lock acquisition — the one shape rename would break", () => {
|
|
84
|
+
for (const site of Object.keys(SANCTIONED_DIRECT_WRITES)) {
|
|
85
|
+
const [rel, lineNo] = site.split(":");
|
|
86
|
+
const line = readFileSync(path.join(REPO_ROOT, rel), "utf8").split("\n")[Number(lineNo) - 1];
|
|
87
|
+
assert.match(line, /scheduleLock/, `${site} is not a lock write; it should not be exempt`);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|