@sabaiway/agent-workflow-kit 14.29.1 → 14.31.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.
@@ -0,0 +1,284 @@
1
+ #!/usr/bin/env node
2
+ // state-block-hook.mjs — the writer behind the `state-block` advisor item: places the bundled
3
+ // closing-state-block detector (references/hooks/state-block-guard.mjs) to the project's
4
+ // .claude/hooks/state-block-guard.mjs and merges ONE `Stop` entry into .claude/settings.json by the
5
+ // three cases of references/modes/state-block-guard.md: no `hooks` key, a `hooks` key without
6
+ // `Stop`, and a `hooks.Stop` array, which gains one element.
7
+ //
8
+ // The symlink, target and settings-shape checks and the hook-file write (writeHookFile) are IMPORTED
9
+ // from gate-hook.mjs, with the stamp reader and the settings formatter; the unsafe-mode and stamp
10
+ // refusals, the place-then-wire order and the no-follow re-read of the placed file before the
11
+ // settings write follow gate-hook's, with its error codes. What is the Stop hook's own:
12
+ // • preview by default; `--apply` writes;
13
+ // • the runtime is created when absent, kept when byte-equal, replaced when it differs (the
14
+ // preview says so; the danger check prints the replaced bytes' digest);
15
+ // • the entry is merged only while no Stop entry of either settings file runs the runtime
16
+ // (isStopHookWired — the one rule; the advisor's convergence imports it);
17
+ // • a `hooks.Stop` that is present and not an array refuses on the preview and the apply alike;
18
+ // • the offered command carries no `--require-block` (the kit does not mandate the block);
19
+ // • never writes settings.local.json; never commits.
20
+ //
21
+ // Exit codes: 0 done / preview; 1 precondition STOP; 2 usage. Dependency-free beyond the kit's
22
+ // own internal exports, Node >= 22. No side effects on import.
23
+
24
+ import { dirname, join, resolve } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ import {
27
+ CLAUDE_DIR,
28
+ EXPECTED_WORKFLOW_VERSION,
29
+ SAFE_DEFAULT_MODES,
30
+ SETTINGS_FILE,
31
+ SETTINGS_LOCAL_FILE,
32
+ UNSAFE_BYPASS_MODE,
33
+ WORKFLOW_STAMP,
34
+ readSettingsFile,
35
+ resolveEffectiveMode,
36
+ } from './velocity-profile.mjs';
37
+ import {
38
+ GATE_HOOK_DIVERGED,
39
+ GATE_HOOK_STAMP,
40
+ GATE_HOOK_UNSAFE_MODE,
41
+ HOOKS_DIR,
42
+ assertDirSafe,
43
+ assertHooksShape,
44
+ assertSettingsWritable,
45
+ formatJson,
46
+ fsDeps,
47
+ makeGateHookError,
48
+ planTarget,
49
+ readBundledHook,
50
+ readStamp,
51
+ writeHookFile,
52
+ } from './gate-hook.mjs';
53
+ import { isDirectRun } from './direct-run.mjs';
54
+
55
+ const HERE = dirname(fileURLToPath(import.meta.url));
56
+
57
+ export const STATE_BLOCK_HOOK_RUNTIME = 'state-block-guard.mjs';
58
+ export const STATE_BLOCK_HOOK_FILE_REL = `${HOOKS_DIR}/${STATE_BLOCK_HOOK_RUNTIME}`;
59
+ const BUNDLED_STATE_BLOCK_HOOK_PATH = resolve(HERE, '..', 'references', 'hooks', STATE_BLOCK_HOOK_RUNTIME);
60
+ // NO `--require-block` in the offered command. That flag turns on the absent-block report, and this
61
+ // kit does not mandate the three-part closing block — offering it to every project would hand them a
62
+ // hook that warns after nearly every turn. The mode doc explains when to add it.
63
+ export const STATE_BLOCK_HOOK_COMMAND = `node "$CLAUDE_PROJECT_DIR/${STATE_BLOCK_HOOK_FILE_REL}"`;
64
+ const STATE_BLOCK_HOOK_TIMEOUT_SECONDS = 10;
65
+ export const STOP_EVENT = 'Stop';
66
+
67
+ const EXIT_OK = 0;
68
+ const EXIT_PRECONDITION = 1;
69
+ const EXIT_USAGE = 2;
70
+ const UTF8 = 'utf8';
71
+ const LF = '\n';
72
+
73
+ const PLACEMENT_CREATE = 'create';
74
+ const PLACEMENT_KEEP = 'keep';
75
+ const PLACEMENT_REPLACE = 'replace';
76
+ const PLACEMENT_OF = Object.freeze({ place: PLACEMENT_CREATE, 'already-current': PLACEMENT_KEEP, diverged: PLACEMENT_REPLACE });
77
+
78
+ const MERGE_NO_HOOKS = 'no-hooks';
79
+ const MERGE_NO_STOP = 'no-stop';
80
+ const MERGE_APPEND = 'append';
81
+
82
+ const USAGE = `usage: state-block-hook [--apply] [--cwd <dir>] [--help]
83
+
84
+ Places the bundled closing-state-block detector to ${STATE_BLOCK_HOOK_FILE_REL} (created when
85
+ absent, kept when byte-equal, replaced when it differs) and merges ONE ${STOP_EVENT} entry running
86
+ ${STATE_BLOCK_HOOK_COMMAND} into ${SETTINGS_FILE}, by the three cases of
87
+ references/modes/state-block-guard.md. Default is a preview that writes nothing; --apply writes.
88
+ The entry is merged only while no ${STOP_EVENT} entry of either settings file runs the runtime.
89
+
90
+ Never writes ${SETTINGS_LOCAL_FILE}; never commits.`;
91
+
92
+ const usageError = (message) => Object.assign(new Error(message), { exitCode: EXIT_USAGE });
93
+ const isJsonObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
94
+
95
+ // ── the Stop entry ────────────────────────────────────────────────────────────────────
96
+
97
+ export const buildStopHookEntry = () => ({
98
+ hooks: [{ type: 'command', command: STATE_BLOCK_HOOK_COMMAND, timeout: STATE_BLOCK_HOOK_TIMEOUT_SECONDS }],
99
+ });
100
+
101
+ // Matched on the RUNTIME FILE NAME inside the command, not on an exact string: a hand-wired copy
102
+ // may run a kit-source path or carry `--require-block`. Any Stop entry that runs this runtime counts
103
+ // as wired — the honest question is "is it watching?". Both entry shapes count: the matcher-group
104
+ // form (an object carrying its own `hooks` array) this family ships, and a flat entry.
105
+ const runsStateBlockGuard = (hook) => typeof hook?.command === 'string' && hook.command.includes(STATE_BLOCK_HOOK_RUNTIME);
106
+ export const isStopHookWired = (data) => {
107
+ const entries = data?.hooks?.[STOP_EVENT];
108
+ if (!Array.isArray(entries)) return false;
109
+ return entries.some((entry) => (Array.isArray(entry?.hooks)
110
+ ? entry.hooks.some(runsStateBlockGuard)
111
+ : runsStateBlockGuard(entry)));
112
+ };
113
+
114
+ const mergeCaseOf = (data) => {
115
+ if (!isJsonObject(data) || data.hooks === undefined) return MERGE_NO_HOOKS;
116
+ return data.hooks[STOP_EVENT] === undefined ? MERGE_NO_STOP : MERGE_APPEND;
117
+ };
118
+
119
+ // Append our ONE entry; every foreign hook, matcher and key is carried over untouched. Only called
120
+ // when the entry is absent (idempotence lives in the caller's plan, not in dedup-on-write).
121
+ export const mergeStopEntry = (base) => {
122
+ const root = isJsonObject(base) ? base : {};
123
+ const hooks = isJsonObject(root.hooks) ? root.hooks : {};
124
+ const entries = Array.isArray(hooks[STOP_EVENT]) ? hooks[STOP_EVENT] : [];
125
+ return { ...root, hooks: { ...hooks, [STOP_EVENT]: [...entries, buildStopHookEntry()] } };
126
+ };
127
+
128
+ // ── the writer ────────────────────────────────────────────────────────────────────────
129
+
130
+ const preflight = (projectDir, deps) => {
131
+ const fs = fsDeps(deps);
132
+ const bundleContent = readBundledHook({ ...deps, bundlePath: deps.bundlePath ?? BUNDLED_STATE_BLOCK_HOOK_PATH });
133
+ const stamp = readStamp(join(projectDir, WORKFLOW_STAMP), fs);
134
+ assertDirSafe(join(projectDir, CLAUDE_DIR), CLAUDE_DIR, fs);
135
+ assertDirSafe(join(projectDir, HOOKS_DIR), HOOKS_DIR, fs);
136
+ const target = planTarget(join(projectDir, STATE_BLOCK_HOOK_FILE_REL), STATE_BLOCK_HOOK_FILE_REL, bundleContent, fs);
137
+ assertSettingsWritable(join(projectDir, SETTINGS_FILE), fs);
138
+ const projectSettings = readSettingsFile(join(projectDir, SETTINGS_FILE), { ...deps, cwd: projectDir });
139
+ const localSettings = readSettingsFile(join(projectDir, SETTINGS_LOCAL_FILE), { ...deps, cwd: projectDir });
140
+ const { bypassPermissionsPresent, unsafeModePresent } = resolveEffectiveMode(projectSettings.data, localSettings.data);
141
+ if (bypassPermissionsPresent) {
142
+ throw makeGateHookError(GATE_HOOK_UNSAFE_MODE, `${UNSAFE_BYPASS_MODE} appears in Claude settings - refusing to wire a hook beside it`);
143
+ }
144
+ if (unsafeModePresent) {
145
+ throw makeGateHookError(
146
+ GATE_HOOK_UNSAFE_MODE,
147
+ `an unsafe or unknown permissions.defaultMode is present in Claude settings - accepted modes: ${SAFE_DEFAULT_MODES.join(', ')}`,
148
+ );
149
+ }
150
+ assertHooksShape(projectSettings.data, SETTINGS_FILE, STOP_EVENT);
151
+ return {
152
+ projectDir,
153
+ bundleContent,
154
+ stamp,
155
+ stampOk: stamp === EXPECTED_WORKFLOW_VERSION,
156
+ placement: PLACEMENT_OF[target.action],
157
+ projectSettings,
158
+ localSettings,
159
+ wiredInProject: isStopHookWired(projectSettings.data),
160
+ wiredInLocal: isStopHookWired(localSettings.data),
161
+ mergeCase: mergeCaseOf(projectSettings.data),
162
+ };
163
+ };
164
+
165
+ export const writeStateBlockHook = ({ cwd, dryRun = true } = {}, deps = {}) => {
166
+ const fs = fsDeps(deps);
167
+ const plan = preflight(resolve(cwd ?? deps.cwd ?? process.cwd()), deps);
168
+ const placePlanned = plan.placement !== PLACEMENT_KEEP;
169
+ const mergePlanned = !plan.wiredInProject && !plan.wiredInLocal;
170
+ const base = { placePlanned, mergePlanned, ...plan };
171
+ if (dryRun) return { wrote: false, dryRun: true, ...base };
172
+
173
+ if (!plan.stampOk) {
174
+ throw makeGateHookError(
175
+ GATE_HOOK_STAMP,
176
+ `not a deployed agent-workflow project at lineage ${EXPECTED_WORKFLOW_VERSION} (found ${plan.stamp ?? 'none'}) — run init/upgrade first`,
177
+ );
178
+ }
179
+ // Place FIRST, then wire — a wired-but-missing hook would error on every turn end.
180
+ const hookAbs = join(plan.projectDir, STATE_BLOCK_HOOK_FILE_REL);
181
+ if (plan.placement === PLACEMENT_CREATE) fs.mkdir(join(plan.projectDir, HOOKS_DIR), { recursive: true });
182
+ if (placePlanned) writeHookFile(hookAbs, STATE_BLOCK_HOOK_FILE_REL, plan.bundleContent, plan.placement === PLACEMENT_CREATE, fs);
183
+ if (mergePlanned) {
184
+ // Re-judge the placed file no-follow immediately BEFORE wiring: a runtime swapped, or turned
185
+ // into a link, between the placement and now must never be wired — zero settings mutation.
186
+ const placed = planTarget(hookAbs, STATE_BLOCK_HOOK_FILE_REL, plan.bundleContent, fs);
187
+ if (placed.action !== 'already-current') {
188
+ throw makeGateHookError(
189
+ GATE_HOOK_DIVERGED,
190
+ `${STATE_BLOCK_HOOK_FILE_REL} changed after placement — refusing to wire an unknown script as a ${STOP_EVENT} hook (no settings change made); re-run`,
191
+ );
192
+ }
193
+ fs.writeFile(
194
+ join(plan.projectDir, SETTINGS_FILE),
195
+ formatJson(mergeStopEntry(plan.projectSettings.data ?? {}), plan.projectSettings.eol ?? LF),
196
+ UTF8,
197
+ );
198
+ }
199
+ return { wrote: placePlanned || mergePlanned, dryRun: false, ...base };
200
+ };
201
+
202
+ // ── report ────────────────────────────────────────────────────────────────────────────
203
+
204
+ const STATE_BLOCK_CURRENT_LINE = 'nothing to change: the state-block hook is placed, current and wired';
205
+
206
+ const TRUST_POSTURE_LINE =
207
+ 'trust posture: the guard reads the closing assistant message of each turn and writes one warning line; it runs a Node process on every turn end, reads nothing else, makes no network call and approves nothing.';
208
+
209
+ const PLACEMENT_LINES = Object.freeze({
210
+ [PLACEMENT_CREATE]: ['would place', 'placed'],
211
+ [PLACEMENT_KEEP]: ['already current', 'already current'],
212
+ [PLACEMENT_REPLACE]: ['would replace with the bundle (it differs from the bundled runtime)', 'replaced with the bundle'],
213
+ });
214
+
215
+ const MERGE_LINES = Object.freeze({
216
+ [MERGE_NO_HOOKS]: 'the hooks key with the Stop entry',
217
+ [MERGE_NO_STOP]: 'the Stop key beside the existing hooks',
218
+ [MERGE_APPEND]: 'one element to the existing hooks.Stop array',
219
+ });
220
+
221
+ const formatSettingsLine = (result) => {
222
+ if (result.wiredInProject) return ` - ${SETTINGS_FILE}: already wired`;
223
+ if (result.wiredInLocal) return ` - ${SETTINGS_FILE}: already wired via ${SETTINGS_LOCAL_FILE} — not duplicated`;
224
+ return ` - ${SETTINGS_FILE}: ${result.dryRun ? 'would add' : 'added'} ${MERGE_LINES[result.mergeCase]}`;
225
+ };
226
+
227
+ export const formatResult = (result) => {
228
+ if (!result.placePlanned && !result.mergePlanned) return STATE_BLOCK_CURRENT_LINE;
229
+ const lines = [
230
+ result.dryRun
231
+ ? 'agent-workflow state-block hook — DRY RUN (no changes; re-run with --apply)'
232
+ : 'agent-workflow state-block hook — APPLY',
233
+ ` - ${STATE_BLOCK_HOOK_FILE_REL}: ${PLACEMENT_LINES[result.placement][result.dryRun ? 0 : 1]}`,
234
+ formatSettingsLine(result),
235
+ ` command: ${STATE_BLOCK_HOOK_COMMAND} (no --require-block; references/modes/state-block-guard.md says when to add it)`,
236
+ TRUST_POSTURE_LINE,
237
+ ];
238
+ if (!result.stampOk) {
239
+ lines.push(`note: no current deployment stamp found (${result.stamp ?? 'none'}) — --apply will refuse until init/upgrade runs.`);
240
+ }
241
+ if (!result.dryRun && result.mergePlanned) {
242
+ lines.push('settings hot-reload: the Stop hook runs from the next turn end — no session restart needed.');
243
+ }
244
+ return lines.join(LF);
245
+ };
246
+
247
+ // ── CLI ───────────────────────────────────────────────────────────────────────────────
248
+
249
+ export const parseArgs = (argv) => {
250
+ const opts = { apply: false, cwd: undefined, help: false };
251
+ for (let i = 0; i < argv.length; i += 1) {
252
+ const arg = argv[i];
253
+ if (arg === '--help' || arg === '-h') opts.help = true;
254
+ else if (arg === '--apply') opts.apply = true;
255
+ else if (arg === '--cwd') {
256
+ i += 1;
257
+ if (argv[i] === undefined || argv[i].startsWith('-')) throw usageError('--cwd needs a directory argument');
258
+ opts.cwd = argv[i];
259
+ } else {
260
+ throw usageError(`unknown argument: ${arg}`);
261
+ }
262
+ }
263
+ return { help: opts.help, dryRun: !opts.apply, cwd: opts.cwd };
264
+ };
265
+
266
+ export const main = (argv = process.argv.slice(2), deps = {}) => {
267
+ const log = deps.log ?? console.log;
268
+ const errlog = deps.errlog ?? console.error;
269
+ try {
270
+ const args = parseArgs(argv);
271
+ if (args.help) {
272
+ log(USAGE);
273
+ return EXIT_OK;
274
+ }
275
+ log(formatResult(writeStateBlockHook({ cwd: args.cwd ?? deps.cwd ?? process.cwd(), dryRun: args.dryRun }, deps)));
276
+ return EXIT_OK;
277
+ } catch (err) {
278
+ errlog(err?.message ?? String(err));
279
+ if (err?.exitCode === EXIT_USAGE) errlog(USAGE);
280
+ return err?.exitCode ?? EXIT_PRECONDITION;
281
+ }
282
+ };
283
+
284
+ if (isDirectRun(import.meta.url)) process.exitCode = main(process.argv.slice(2));
@@ -34,7 +34,7 @@ export const UPGRADE_RUNLIST = Object.freeze([
34
34
  entry(
35
35
  'configs',
36
36
  'node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --cwd <project>',
37
- null,
37
+ 'a specs replace line → ONE yes naming every file the custom lines name and saying their edits are overwritten, asked even on a non-zero exit; on the yes run the printed line, then STOP if that run fails or an op it does not re-run failed; on a no STOP on any failed op. The printed line: node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --replace-custom <digest> --cwd <project>',
38
38
  [
39
39
  'seeded',
40
40
  'note-refreshed',
@@ -26,13 +26,13 @@ export const VARIANT_LANES = Object.freeze({
26
26
  'source-size.adopted-elsewhere': LANE_CHAT,
27
27
  'source-size.id-squatter': LANE_CHAT,
28
28
  'gate-hook': LANE_HARNESS,
29
- 'gate-hook.marker-stale': LANE_USER,
29
+ 'gate-hook.marker-stale': LANE_HARNESS,
30
30
  'commit-guard': LANE_USER,
31
31
  enforcement: LANE_USER,
32
32
  'read-lane': LANE_CHAT,
33
- 'read-lane.stale': LANE_USER,
33
+ 'read-lane.stale': LANE_HARNESS,
34
34
  'read-lane.missing': LANE_HARNESS,
35
- 'state-block': LANE_USER,
35
+ 'state-block': LANE_HARNESS,
36
36
  'mcp-channel': LANE_HARNESS,
37
37
  'mcp-channel.differing': LANE_HARNESS,
38
38
  agents: LANE_HARNESS,
@@ -80,6 +80,7 @@ export const isProtectedPath = (abs, { root, home, hooksDir }) => {
80
80
 
81
81
  const SETTINGS_WRITES = [SETTINGS_PATH];
82
82
  const HOOK_WRITES = [join('.claude', 'hooks', 'agent-workflow-gates.mjs'), ...SETTINGS_WRITES];
83
+ const STOP_HOOK_WRITES = [join('.claude', 'hooks', 'state-block-guard.mjs'), ...SETTINGS_WRITES];
83
84
  const MCP_WRITES = ['.mcp.json', ...SETTINGS_WRITES];
84
85
  const AGENT_WRITES = [join('.claude', 'agents')];
85
86
 
@@ -110,6 +111,7 @@ const createForm = (file, preview, apply, writes, envDependent = false, userStep
110
111
 
111
112
  const CORE_FORM = createForm('velocity-profile.mjs', ['--cwd', '<root>'], ['--apply', '--cwd', '<root>'], SETTINGS_WRITES);
112
113
  const HOOK_FORM = createForm('gate-hook.mjs', ['--cwd', '<root>'], ['--apply', '--cwd', '<root>'], HOOK_WRITES);
114
+ const RESEED_FORM = createForm('gate-hook.mjs', ['--reseed', '--cwd', '<root>'], ['--apply', '--reseed', '--cwd', '<root>'], HOOK_WRITES);
113
115
  const AGENT_FORM = createForm('cheap-agents.mjs', ['--cwd', '<root>'], ['--apply', '--cwd', '<root>'], AGENT_WRITES);
114
116
  const SKILL_FORM = createForm('jev-skill.mjs', ['--claude-dir', '<dir>'], ['--apply', '--claude-dir', '<dir>'], getSkillPaths, true);
115
117
 
@@ -123,6 +125,9 @@ const FORMS = Object.freeze({
123
125
  ['--autonomy', '--cwd', '<root>'], ['--autonomy', '--apply', '--cwd', '<root>'], SETTINGS_WRITES),
124
126
  'gate-hook': HOOK_FORM,
125
127
  'read-lane.missing': HOOK_FORM,
128
+ 'gate-hook.marker-stale': RESEED_FORM,
129
+ 'read-lane.stale': RESEED_FORM,
130
+ 'state-block': createForm('state-block-hook.mjs', ['--cwd', '<root>'], ['--apply', '--cwd', '<root>'], STOP_HOOK_WRITES),
126
131
  'mcp-channel': createForm('mcp.mjs', ['--cwd', '<root>'], ['--apply', '--cwd', '<root>'], MCP_WRITES),
127
132
  'mcp-channel.differing': createForm('mcp.mjs',
128
133
  ['--replace', '--cwd', '<root>'], ['--replace', '--apply', '--cwd', '<root>'], MCP_WRITES),
@@ -139,11 +144,11 @@ const FORMS = Object.freeze({
139
144
  'family-freshness': Object.freeze({
140
145
  userStep: () => `your own step — ${TERMINAL_STEP}${formatCommand(['npx', '@sabaiway/agent-workflow-kit@latest', 'init'])}`,
141
146
  }),
142
- 'sandbox-provision': null,
147
+ 'sandbox-provision': Object.freeze({
148
+ userStep: (facts) => 'your own step — the doctor has no install it can run for you here (the reason is this item\'s text) — '
149
+ + `${TERMINAL_STEP}cd ${quoteArg(facts.root)} && ${formatCommand(['node', join(facts.toolsDir, 'autonomy-doctor.mjs')])}`,
150
+ }),
143
151
  'commit-guard': null,
144
- 'gate-hook.marker-stale': null,
145
- 'read-lane.stale': null,
146
- 'state-block': null,
147
152
  enforcement: null,
148
153
  });
149
154