moflo 4.12.6 → 4.12.8

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/bin/gate.cjs CHANGED
@@ -13,7 +13,7 @@ var STATE_FILE = path.join(PROJECT_DIR, '.claude', 'workflow-state.json');
13
13
  // the code it describes, so a change made outside Write/Edit/MultiEdit (a Bash
14
14
  // write, a branch switch, the next issue in the same session) invalidates it.
15
15
  // See creditFingerprint() for why the boolean flags alone cannot.
16
- var STATE_DEFAULTS = { tasksCreated: false, taskCount: 0, memorySearched: false, memorySearchedBy: {}, memoryRequired: true, learningsStored: false, testsRun: false, testsFingerprint: null, simplifyRun: false, simplifySnapshotSha: null, simplifyFingerprint: null, verifyRun: false, verifyOutcome: null, verifyFingerprint: null, interactionCount: 0, sessionStart: null, lastBlockedAt: null, lastNamespaceHint: '', lastNamespaceHintEmittedBy: {}, flMode: null, swarmInitialized: false, hiveInitialized: false, sddMode: false, activeSddSlug: null };
16
+ var STATE_DEFAULTS = { tasksCreated: false, taskCount: 0, tasksAcknowledged: false, memorySearched: false, memorySearchedBy: {}, memoryRequired: true, learningsStored: false, testsRun: false, testsFingerprint: null, simplifyRun: false, simplifySnapshotSha: null, simplifyFingerprint: null, verifyRun: false, verifyOutcome: null, verifyFingerprint: null, interactionCount: 0, sessionStart: null, lastBlockedAt: null, lastNamespaceHint: '', lastNamespaceHintEmittedBy: {}, flMode: null, swarmInitialized: false, hiveInitialized: false, sddMode: false, activeSddSlug: null };
17
17
 
18
18
  // Per-actor memory-search tracking (#838). The legacy `memorySearched` boolean
19
19
  // is session-wide, so once the parent searches memory, every spawned subagent
@@ -70,9 +70,24 @@ function loadGateConfig() {
70
70
  // ships a real /verify skill and has /flo delegate to it, so leaving it off by
71
71
  // default would make the default /flo run silently skip the acceptance check.
72
72
  // Disable per-project with `verify_before_done: false` or per-run `--no-verify`.
73
- var defaults = { memory_first: true, task_create_first: true, context_tracking: true, testing_gate: true, simplify_gate: true, learnings_gate: true, swarm_invocation_gate: true, verify_before_done: true, sdd_gate: true };
73
+ // task_status_gate is a MODE, not a boolean: 'block' | 'warn' | 'off' (#1435).
74
+ // #1374 shipped the open-task count as a warn-only stdout line, and a consumer
75
+ // still shipped a PR over four untouched tasks — a reminder that survived ten
76
+ // consecutive ignores in one session is not a control. Blocking is the default
77
+ // because the honest "these stay open on purpose" outcome is one command away
78
+ // (record-tasks-acknowledged), so nothing here can deadlock a run.
79
+ var defaults = { memory_first: true, task_create_first: true, context_tracking: true, testing_gate: true, simplify_gate: true, learnings_gate: true, swarm_invocation_gate: true, verify_before_done: true, sdd_gate: true, task_status_gate: 'block' };
74
80
  var content = MOFLO_YAML;
75
81
  if (content) {
82
+ // Boolean forms are accepted so this key reads like every other gate in the
83
+ // block: `false` is the same opt-out `testing_gate: false` is, `true` means
84
+ // enforce. Anything unrecognised falls through to the default rather than
85
+ // silently disabling the gate — a typo must not be a stealth opt-out.
86
+ var tsg = /task_status_gate:\s*['"]?(block|warn|off|false|true)['"]?/i.exec(content);
87
+ if (tsg) {
88
+ var mode = tsg[1].toLowerCase();
89
+ defaults.task_status_gate = mode === 'false' ? 'off' : mode === 'true' ? 'block' : mode;
90
+ }
76
91
  if (/memory_first:\s*false/i.test(content)) defaults.memory_first = false;
77
92
  if (/task_create_first:\s*false/i.test(content)) defaults.task_create_first = false;
78
93
  if (/context_tracking:\s*false/i.test(content)) defaults.context_tracking = false;
@@ -172,6 +187,25 @@ var GATE_ORIGIN_NOTE = 'This is a moflo hook, not a Claude Code permission rule
172
187
  // /verify's Step 5 memory_store carries the verdict AND stamps learnings, which
173
188
  // is why learnings has no separate step here.
174
189
  var ORDER_HINT = 'Order that satisfies all of them: tests green -> /flo-simplify (re-run tests if it edits) -> /verify -> its memory_store verdict -> gh pr create\n';
190
+ // #1434 — the old text ('learnings have not been stored (call memory_store)')
191
+ // named the mechanism but no quality bar, so the cheapest way past it was a
192
+ // summary of the run — audit exhaust that displaces reusable lessons from every
193
+ // future bounded search. Name the bar AND the no-write path here: an escape
194
+ // hatch nobody can find is not an escape hatch (see #1332's gate deadlock).
195
+ //
196
+ // The escape command is built from __filename, not written as a relative path.
197
+ // The caller is the model typing into a Bash tool, NOT a hook: $CLAUDE_PROJECT_DIR
198
+ // is unset there (so the settings.json form would expand to "/.claude/..."), and a
199
+ // bare `.claude/helpers/gate.cjs` breaks from any cwd but the project root. The
200
+ // running script's own absolute path is correct on every OS and from any cwd;
201
+ // double quotes carry Windows separators and spaces through the shell.
202
+ var LEARNINGS_MISSING =
203
+ 'no durable lesson recorded. A lesson qualifies only if it would help a future session ' +
204
+ 'working on a DIFFERENT task — a reusable pattern, a trap, a decision + rationale. ' +
205
+ 'Store one with mcp__moflo__memory_store (namespace "learnings"; use "patterns" for a ' +
206
+ 'reusable code shape). What THIS run changed is git history — it belongs in the PR body, ' +
207
+ 'not in memory. If this run taught nothing new, say so instead of inventing one: ' +
208
+ 'node "' + __filename + '" record-no-durable-lesson';
175
209
  var GATE_DISABLE_NOTE = 'Disable per-gate via moflo.yaml: gates: memory_first: false';
176
210
  // #1338 — Claude Code spawns stdio MCP servers once at session start and never
177
211
  // respawns them, so a session can outlive its moflo MCP connection. Naming only
@@ -1355,6 +1389,41 @@ switch (command) {
1355
1389
  writeState(s);
1356
1390
  break;
1357
1391
  }
1392
+ // #1435 — the escape from the task-status gate, for work deliberately left
1393
+ // open. Session-scoped like `learningsStored`: it lives in STATE_DEFAULTS, so
1394
+ // session-reset clears it, and neither applyPromptStateReset nor
1395
+ // reset-edit-gates touches it — a decision the user made about the task list
1396
+ // is not invalidated by the next prompt or the next source edit.
1397
+ //
1398
+ // A plain flag, not a count. The command is typed by the model into a Bash
1399
+ // tool, where HOOK_TRANSCRIPT_PATH is unset (it is forwarded by gate-hook.mjs
1400
+ // from the hook payload and exists only inside a hook), so this process cannot
1401
+ // read the ledger to record WHICH tasks were acknowledged even if it wanted to.
1402
+ case 'record-tasks-acknowledged': {
1403
+ var s = readState();
1404
+ if (!s.tasksAcknowledged) {
1405
+ s.tasksAcknowledged = true;
1406
+ writeState(s);
1407
+ }
1408
+ // writeState swallows its own errors by design — a gate must never crash the
1409
+ // hook it runs in. That was harmless while every recorder was advisory. This
1410
+ // one is the ONLY escape from a BLOCKING gate, so a lost write would report
1411
+ // "satisfied" and then block the very next `gh pr create` with nothing said
1412
+ // about why: #1332's deadlock shape exactly. Confirm it landed before
1413
+ // claiming it did, and name the file and the way out when it did not.
1414
+ if (!readState().tasksAcknowledged) {
1415
+ process.stderr.write(
1416
+ 'Task-status gate NOT satisfied: the acknowledgement could not be persisted to\n' +
1417
+ STATE_FILE + '\n' +
1418
+ 'Check that the file and its directory are writable, then run this again.\n' +
1419
+ 'To proceed without it: set gates: task_status_gate: off in moflo.yaml.\n');
1420
+ process.exit(1);
1421
+ }
1422
+ process.stdout.write(
1423
+ 'Task-status gate satisfied: open tasks acknowledged as deliberately deferred.\n' +
1424
+ 'They stay visible in the task list — this records the decision, it does not close them.\n');
1425
+ break;
1426
+ }
1358
1427
  case 'record-memory-searched': {
1359
1428
  var s = readState();
1360
1429
  if (markMemorySearched(s)) writeState(s);
@@ -1417,12 +1486,48 @@ switch (command) {
1417
1486
  // Why it does nothing: see applyPromptStateReset().
1418
1487
  break;
1419
1488
  }
1420
- case 'record-learnings-stored': {
1489
+ // #1434 — the gate demanded one memory_store per run whether or not the run
1490
+ // produced a reusable lesson, and a mandatory write with nothing to say
1491
+ // produces filler: a summary of this ticket, this commit, applicable never
1492
+ // again. memory_search returns a bounded set, so each of those displaces a
1493
+ // real lesson from every future search — the cost is retrieval quality, not
1494
+ // disk. Declaring "nothing durable here" is the honest outcome of a run that
1495
+ // learned nothing new, so it has to be reachable without a write; otherwise
1496
+ // the cheapest way past the gate stays the filler write.
1497
+ //
1498
+ // Both credits set the same flag; they differ only in whether the run has
1499
+ // something to say. Sharing the case body keeps that single write in one
1500
+ // place across all three copies of this file.
1501
+ case 'record-learnings-stored':
1502
+ case 'record-no-durable-lesson': {
1421
1503
  var s = readState();
1422
1504
  if (!s.learningsStored) {
1423
1505
  s.learningsStored = true;
1424
1506
  writeState(s);
1425
1507
  }
1508
+ if (command === 'record-no-durable-lesson') {
1509
+ // Same reasoning as record-tasks-acknowledged above: writeState swallows
1510
+ // its own errors so a gate never crashes the hook it runs in, and this is
1511
+ // the ONLY escape from the BLOCKING learnings gate that does not require a
1512
+ // memory_store. A lost write here would print "satisfied" and then block
1513
+ // the next `gh pr create` with nothing said about why — #1332's deadlock.
1514
+ // Verified only on this arm: record-learnings-stored is fired
1515
+ // automatically by the PostToolUse hook on every memory_store, where a
1516
+ // failed write leaves the gate closed but the run still has the ordinary
1517
+ // way through, and a diagnostic on every store would be noise.
1518
+ if (!readState().learningsStored) {
1519
+ process.stderr.write(
1520
+ 'Learnings gate NOT satisfied: the declaration could not be persisted to\n' +
1521
+ STATE_FILE + '\n' +
1522
+ 'Check that the file and its directory are writable, then run this again.\n' +
1523
+ 'To proceed without it: set gates: learnings_gate: false in moflo.yaml.\n');
1524
+ process.exit(1);
1525
+ }
1526
+ process.stdout.write(
1527
+ 'Learnings gate satisfied: no durable lesson declared for this run.\n' +
1528
+ 'What this run did belongs in the PR body, not in memory.\n',
1529
+ );
1530
+ }
1426
1531
  break;
1427
1532
  }
1428
1533
  case 'record-test-run': {
@@ -1645,25 +1750,46 @@ switch (command) {
1645
1750
  // chained, piped, parenthesised, and multi-line shapes (#1410).
1646
1751
  var cmd = process.env.TOOL_INPUT_command || '';
1647
1752
  if (!isPrCreateCommand(cmd)) break;
1648
- // #1374 close the loop the TaskCreate reminder opens. Advisory: stdout,
1649
- // no exit, and worded as a reminder, because a message may only claim to
1650
- // block when it blocks (#1326). An open task list is a reporting failure,
1651
- // not a quality failure — blocking the PR on it would be a new deadlock.
1753
+ // #1374 opened this loop; #1435 closes it. The count itself is unchanged —
1754
+ // what changed is that it now has teeth and, in warn mode, a delivery path.
1652
1755
  //
1653
1756
  // Deliberately ABOVE the no-source exemption below: a docs-only PR can
1654
1757
  // abandon a list exactly like a source PR can, and the exemption is about
1655
1758
  // testing/simplify/learnings, not about whether the run told the user what
1656
- // it did. Gated on the same `task_create_first` flag as the reminder itself
1657
- // so the two halves are always consistent a project that turned the nag
1658
- // off is not then nagged about the other end of it.
1659
- if (config.task_create_first) {
1759
+ // it did. It also exits on its own rather than joining `missing` below, for
1760
+ // the same reason `missing` is unreachable on an exempt diff.
1761
+ //
1762
+ // Gated on the same `task_create_first` flag as the reminder itself so the
1763
+ // two halves are always consistent: a project that turned the nag off is
1764
+ // not then blocked about the other end of it.
1765
+ //
1766
+ // Fail-open is load-bearing. readTaskLedger() returns null on a missing,
1767
+ // oversized, or unreadable transcript and on a session with no TaskCreate at
1768
+ // all, and null must never block — a gate that stops PRs because it could
1769
+ // not read a file is worse than the reporting gap it is closing.
1770
+ //
1771
+ // State is read ONCE for the whole case, here — the pre-PR gate logic below
1772
+ // reuses it and nothing writes in between. Reading it before the ledger also
1773
+ // means an already-acknowledged run never pays for the transcript scan.
1774
+ var s = readState();
1775
+ if (config.task_create_first && config.task_status_gate !== 'off' && !s.tasksAcknowledged) {
1660
1776
  var ledger = readTaskLedger();
1661
1777
  if (ledger && ledger.open > 0) {
1662
- process.stdout.write(
1663
- 'REMINDER: ' + ledger.created + ' task' + (ledger.created === 1 ? '' : 's') +
1664
- ' created this session, ' + ledger.open + ' still open. Close them with ' +
1665
- 'TaskUpdate (status: completed), or delete the ones that no longer apply, ' +
1666
- 'so the run does not report done over an unfinished list.\n');
1778
+ var tally = ledger.created + ' task' + (ledger.created === 1 ? '' : 's') +
1779
+ ' created this session, ' + ledger.open + ' still open.';
1780
+ var closeIt = 'Close them with TaskUpdate (status: completed), or delete the ones ' +
1781
+ 'that no longer apply, so the run does not report done over an unfinished list.\n';
1782
+ if (config.task_status_gate === 'warn') {
1783
+ process.stdout.write('REMINDER: ' + tally + ' ' + closeIt);
1784
+ } else {
1785
+ process.stderr.write(
1786
+ 'BLOCKED: ' + tally + '\n' + closeIt +
1787
+ 'Deferring them on purpose is a legitimate outcome — declare it instead of\n' +
1788
+ 'closing tasks that are not done: node "' + __filename + '" record-tasks-acknowledged\n' +
1789
+ GATE_ORIGIN_NOTE + '\n' +
1790
+ 'Report instead of blocking via moflo.yaml: gates: task_status_gate: warn (or: off)\n');
1791
+ process.exit(2);
1792
+ }
1667
1793
  }
1668
1794
  }
1669
1795
  // No-source-files exemption (#1176, supersedes the original docs-only path).
@@ -1688,7 +1814,6 @@ switch (command) {
1688
1814
  break;
1689
1815
  }
1690
1816
  }
1691
- var s = readState();
1692
1817
  // Expire any credit whose fingerprint no longer matches the code before
1693
1818
  // reading the flags. This is what catches the mutations reset-edit-gates
1694
1819
  // structurally cannot see — Bash writes, git checkout/pull/merge, and the
@@ -1721,7 +1846,7 @@ switch (command) {
1721
1846
  var missing = [];
1722
1847
  if (config.testing_gate && !s.testsRun) missing.push('tests have not run green since the last code edit (run npm test, vitest, jest, pytest, or similar — a run whose output reports failures does not count)');
1723
1848
  if (config.simplify_gate && !s.simplifyRun) missing.push('/flo-simplify (or /distill) has not run since the last code edit');
1724
- if (config.learnings_gate && !s.learningsStored) missing.push('learnings have not been stored (call mcp__moflo__memory_store)');
1849
+ if (config.learnings_gate && !s.learningsStored) missing.push(LEARNINGS_MISSING);
1725
1850
  if (missing.length === 0) break;
1726
1851
  process.stderr.write('BLOCKED: gh pr create requires the following before opening a PR:\n');
1727
1852
  for (var i = 0; i < missing.length; i++) {
@@ -26,9 +26,26 @@ export async function readHookStdin() {
26
26
  let done = false;
27
27
  let timer = null;
28
28
  const parse = (s) => { try { return s ? JSON.parse(s) : {}; } catch { return {}; } };
29
- const finish = () => { if (done) return; done = true; if (timer) clearTimeout(timer); res(parse(data)); };
29
+ const onData = (c) => { if (!done) data += c; };
30
+ // Detach and pause on the way out. Without this the 500ms cap is a lie for
31
+ // any caller that keeps running afterwards: the listeners stay attached and
32
+ // a flowing stdin keeps the event loop alive well past the timeout. It was
33
+ // invisible while every caller exited immediately after reading; #1441 made
34
+ // the session-start launcher the first caller with real work left to do.
35
+ const finish = () => {
36
+ if (done) return;
37
+ done = true;
38
+ if (timer) clearTimeout(timer);
39
+ try {
40
+ process.stdin.off('data', onData);
41
+ process.stdin.off('end', finish);
42
+ process.stdin.off('error', finish);
43
+ process.stdin.pause();
44
+ } catch { /* teardown must never outrank returning the payload */ }
45
+ res(parse(data));
46
+ };
30
47
  process.stdin.setEncoding('utf-8');
31
- process.stdin.on('data', (c) => { if (!done) data += c; });
48
+ process.stdin.on('data', onData);
32
49
  process.stdin.on('end', finish);
33
50
  process.stdin.on('error', finish);
34
51
  timer = setTimeout(finish, 500);
@@ -19,6 +19,7 @@ import { makeSyncer, contentEqual, syncDirRecursive } from './lib/file-sync.mjs'
19
19
  import { INTERNAL_SKILLS } from './lib/internal-skills.mjs';
20
20
  import { parseSkillCategories, computeExcludedSkills } from './lib/skill-categories.mjs';
21
21
  import { loadShippedScripts } from './lib/shipped-scripts.mjs';
22
+ import { readHookStdin } from './lib/hook-io.mjs';
22
23
  import {
23
24
  readContinuityConfig,
24
25
  readGitState,
@@ -651,7 +652,7 @@ function stopDaemon(lockFile) {
651
652
  // error 'process can only be terminated forcefully'. The prior
652
653
  // implementation invoked it anyway, swallowed the error, then polled
653
654
  // alive for 3s before escalating — exactly the time-waste that pushed
654
- // §3's stopDaemon past the 3000ms SessionStart hook timeout. Go
655
+ // §3's stopDaemon past the SessionStart hook timeout. Go
655
656
  // straight to /F /T (tree-kill, in case a worker child outlived its
656
657
  // parent) on Win.
657
658
  if (process.platform === 'win32') {
@@ -753,23 +754,155 @@ function resolveDaemonRecyclerPath() {
753
754
  }
754
755
 
755
756
  // ── 2. Reset workflow state for new session ──────────────────────────────────
756
- const stateDir = resolve(projectRoot, '.claude');
757
- const stateFile = resolve(stateDir, 'workflow-state.json');
758
- try {
759
- if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true });
760
- writeFileSync(stateFile, JSON.stringify({
757
+ //
758
+ // #1441 reset on a NEW session only. Claude Code fires SessionStart with a
759
+ // `source` of `startup`, `resume`, `clear` or `compact`, and moflo's settings
760
+ // entry carries no matcher, so this launcher runs for all four. A compaction
761
+ // is the SAME session continuing with a shorter context; a resume is the same
762
+ // session reopened. Resetting there wiped gate state mid-run, two ways:
763
+ //
764
+ // 1. `flMode` and `sddMode` are derived ONLY from the user's prompt text
765
+ // (gate.cjs applyPromptStateReset). Clearing them turned the #952
766
+ // swarm/hive invocation gate and the #1297 SDD gate OFF for the rest of
767
+ // the run — the user's next prompt is ordinary prose, so nothing ever
768
+ // re-armed them. A `/fl -s` run that compacted stopped enforcing
769
+ // swarm_init before Agent spawns, silently.
770
+ // 2. testsRun / simplifyRun / verifyRun / learningsStored and their
771
+ // fingerprints were discarded, so a compaction forced a full re-run of
772
+ // tests, /flo-simplify and /verify before `gh pr create`.
773
+ //
774
+ // Unknown, absent, or unparseable source resets — byte-identical to the old
775
+ // behaviour on a host that doesn't send the field, and the safe direction
776
+ // (gates armed rather than silently off).
777
+ const CONTINUING_SESSION_SOURCES = new Set(['compact', 'resume']);
778
+ const KNOWN_SESSION_SOURCES = new Set(['startup', 'clear', 'compact', 'resume']);
779
+
780
+ // …but a compaction is NOT a pure "keep everything" either. The credits above
781
+ // describe work that still stands — tests ran, /verify passed, the diff is
782
+ // unchanged. The memory-search credit is the one thing compaction genuinely
783
+ // invalidates: `memorySearched` says "this actor has the search results in
784
+ // context", and after a compaction it does not. Preserving it would hand the
785
+ // post-compaction model a satisfied memory gate over a context that no longer
786
+ // holds a single result — the exact "moflo isn't being used" symptom, arrived
787
+ // at from the opposite direction.
788
+ //
789
+ // `memoryRequired` is re-armed for the same reason, and it also closes a second
790
+ // hole reported against #1441: gate.cjs derives `memoryRequired` from the user's
791
+ // prompt TEXT, and `/compact` is an 8-character non-task string, so submitting it
792
+ // set `memoryRequired: false` and the scan/read gates went quiet until some later
793
+ // prompt happened to qualify. SessionStart fires AFTER that UserPromptSubmit, so
794
+ // re-arming here corrects it — and unlike a prompt-text rule it also covers AUTO
795
+ // compaction, where no `/compact` is ever typed.
796
+ //
797
+ // Resume is untouched by this: it reloads the conversation, so a prior search is
798
+ // still in context.
799
+ //
800
+ // Forcing `memoryRequired` on is deliberate even when the pre-compaction prompt
801
+ // was genuinely trivial and scored false on its own merits. One memory search is
802
+ // cheap; a context-blind model exploring files with the gate disarmed is the
803
+ // thing this whole issue is about.
804
+ const MEMORY_CREDIT_KEYS = ['memorySearched', 'memorySearchedBy', 'memoryRequired'];
805
+
806
+ // Full shape, not the 4-field literal this used to write. gate.cjs readState()
807
+ // merges STATE_DEFAULTS over whatever it parses, so the short shape behaved
808
+ // identically THERE — but it left a half-populated file for every other reader
809
+ // of workflow-state.json, and it silently drifted from STATE_DEFAULTS each time
810
+ // a gate field was added. tests/bin/launcher-1441-compact-preserves-state.test.ts
811
+ // pins these keys to gate.cjs's STATE_DEFAULTS so they cannot drift apart again.
812
+ function freshWorkflowState() {
813
+ return {
761
814
  tasksCreated: false,
762
815
  taskCount: 0,
816
+ tasksAcknowledged: false,
763
817
  memorySearched: false,
764
- sessionStart: new Date().toISOString()
765
- }, null, 2));
766
- } catch {
767
- // Non-fatal - workflow gate will use defaults
818
+ memorySearchedBy: {},
819
+ memoryRequired: true,
820
+ learningsStored: false,
821
+ testsRun: false,
822
+ testsFingerprint: null,
823
+ simplifyRun: false,
824
+ simplifySnapshotSha: null,
825
+ simplifyFingerprint: null,
826
+ verifyRun: false,
827
+ verifyOutcome: null,
828
+ verifyFingerprint: null,
829
+ interactionCount: 0,
830
+ sessionStart: new Date().toISOString(),
831
+ lastBlockedAt: null,
832
+ lastNamespaceHint: '',
833
+ lastNamespaceHintEmittedBy: {},
834
+ flMode: null,
835
+ swarmInitialized: false,
836
+ hiveInitialized: false,
837
+ sddMode: false,
838
+ activeSddSlug: null,
839
+ };
840
+ }
841
+
842
+ // Derived from freshWorkflowState(), not a second literal: a re-armed memory
843
+ // gate is by definition the fresh-session value of those keys, and one place to
844
+ // change beats two that agree only by inspection.
845
+ function rearmedMemoryState() {
846
+ const fresh = freshWorkflowState();
847
+ return Object.fromEntries(MEMORY_CREDIT_KEYS.map((key) => [key, fresh[key]]));
848
+ }
849
+
850
+ // Bounded at 500ms by readHookStdin and short-circuited on a TTY, so a withheld
851
+ // stdin cannot eat the 5000ms SessionStart budget (hook-block-hash.ts). Read
852
+ // here rather than at the top of the file so the cost lands next to its only
853
+ // consumer.
854
+ const hookPayload = await readHookStdin();
855
+ const sessionSource = typeof hookPayload?.source === 'string' ? hookPayload.source : '';
856
+ // A source we don't recognise still resets — but say so. Falling through in
857
+ // silence is how a renamed or newly-added "continuing" source would re-open
858
+ // this bug with nothing to notice it by; the reset is only the safe default
859
+ // while the set above is accurate.
860
+ if (sessionSource && !KNOWN_SESSION_SOURCES.has(sessionSource)) {
861
+ emitWarning(
862
+ `unrecognized SessionStart source "${sessionSource}" — resetting workflow state. ` +
863
+ 'If this source continues an existing session, it belongs in §2\'s skip list (#1441).',
864
+ );
865
+ }
866
+ const stateDir = resolve(projectRoot, '.claude');
867
+ const stateFile = resolve(stateDir, 'workflow-state.json');
868
+ if (!CONTINUING_SESSION_SOURCES.has(sessionSource)) {
869
+ try {
870
+ if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true });
871
+ writeFileSync(stateFile, JSON.stringify(freshWorkflowState(), null, 2));
872
+ } catch {
873
+ // Non-fatal - workflow gate will use defaults
874
+ }
875
+ } else if (sessionSource === 'compact') {
876
+ // Merge, never rewrite: everything the run has earned stays, only the memory
877
+ // credit is re-armed. Skipped entirely when there is no state file yet — a
878
+ // compaction before any prompt has nothing to re-arm, and writing a partial
879
+ // file here would defeat freshWorkflowState()'s shape guarantee.
880
+ //
881
+ // Read-modify-write, unsynchronised, like gate.cjs's own writeState. Safe
882
+ // here because a compaction quiesces the session: no tool hook is mid-flight
883
+ // to race with, and the next UserPromptSubmit is strictly after this hook.
884
+ // Leaving an unparseable file alone is also correct rather than merely
885
+ // tolerable — gate.cjs readState() falls back to STATE_DEFAULTS on a parse
886
+ // failure, which arms the memory gate. Repairing it here would only convert a
887
+ // fail-safe into an equivalent write.
888
+ try {
889
+ if (existsSync(stateFile)) {
890
+ const parsed = JSON.parse(readFileSync(stateFile, 'utf-8'));
891
+ writeFileSync(
892
+ stateFile,
893
+ JSON.stringify({ ...parsed, ...rearmedMemoryState() }, null, 2),
894
+ );
895
+ }
896
+ } catch (err) {
897
+ // Non-fatal, but not silent (#854): a failure here leaves the memory gate
898
+ // credited over a context that no longer holds the results.
899
+ emitWarning(`could not re-arm the memory gate after compaction (${errMessage(err)})`);
900
+ }
768
901
  }
769
902
 
770
903
  // ── 2a. Recycle daemon when behind installed version (#1054 follow-up) ──────
771
904
  // Promoted from §3a-pre to run BEFORE §3's file-sync work. The launcher has
772
- // a 3000ms SessionStart hook timeout (src/cli/services/hook-block-hash.ts);
905
+ // a 5000ms SessionStart hook timeout (src/cli/services/hook-block-hash.ts);
773
906
  // §0c (DB repair) + §3 (file-sync, manifest, cherry-pick) + stopDaemon's
774
907
  // up-to-4s graceful poll routinely exceeds it on upgrade sessions, killing
775
908
  // the launcher mid-§3. Result: §3a-pre never ran on the very sessions that
@@ -1470,7 +1603,7 @@ try {
1470
1603
 
1471
1604
  // ── 3a-pre. (removed) Daemon-version-skew recycle moved to §2a. ─────────────
1472
1605
  // The previous version of this block ran AFTER §3's heavy file-sync work,
1473
- // which routinely exceeded the 3000ms SessionStart hook timeout and was
1606
+ // which routinely exceeded the then-3000ms SessionStart hook timeout and was
1474
1607
  // killed before reaching this point. §2a now runs early and force-kills the
1475
1608
  // stale daemon before §3 can starve out. Don't restore §3a-pre — keep the
1476
1609
  // recycle in one place so the two paths can't drift.