session-orchestrator 5.11.0 → 5.12.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/.claude-plugin/marketplace.json +12 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +19 -0
- package/README.md +22 -12
- package/commands/session.md +3 -1
- package/docs/install.md +12 -8
- package/docs/session-config-reference.md +6 -6
- package/docs/session-config-template.md +2 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-start.mjs +19 -10
- package/package.json +1 -2
- package/scripts/fleet-checkin.mjs +61 -3
- package/scripts/lib/config/remote-hosts.mjs +2 -2
- package/scripts/lib/config.mjs +1 -1
- package/scripts/lib/fleet-protocol.mjs +17 -0
- package/scripts/lib/session-end/vault-orphans.mjs +205 -0
- package/scripts/lib/session-invocation.mjs +14 -3
- package/scripts/lib/session-registry.mjs +14 -4
- package/scripts/lib/so-hud/collect.mjs +130 -0
- package/scripts/lib/so-hud/runtime.mjs +76 -0
- package/scripts/lib/state-md/idle-reset.mjs +239 -0
- package/scripts/lib/state-md/recommendations.mjs +26 -4
- package/scripts/lib/vault-mirror/namespace.mjs +3 -2
- package/scripts/lib/vault-mirror/process.mjs +143 -40
- package/scripts/lib/vault-mirror/render-learnings.mjs +6 -5
- package/scripts/lib/vault-mirror/utils.mjs +27 -13
- package/scripts/lib/vault-status/board-writer.mjs +32 -4
- package/scripts/resolve-session-invocation.mjs +64 -8
- package/scripts/so-hud.mjs +45 -0
- package/scripts/state-md-idle-reset.mjs +81 -0
- package/scripts/vault-orphans.mjs +171 -0
- package/skills/_shared/fleet-protocol.md +24 -2
- package/skills/_shared/state-ownership.md +3 -3
- package/skills/autopilot/SKILL.md +3 -2
- package/skills/session-end/SKILL.md +3 -2
- package/skills/session-end/references/phase-3-7e-vault-orphans.md +63 -0
- package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
- package/skills/session-end/references/session-summary-template.md +4 -0
- package/skills/session-start/SKILL.md +1 -1
- package/skills/session-start/phase-7-5-mode-selector.md +3 -1
- package/skills/session-start/references/phase-1-5-session-continuity.md +12 -4
- package/AGENTS.md +0 -207
|
@@ -10,32 +10,43 @@
|
|
|
10
10
|
import { parseSessionId } from '../session-id.mjs';
|
|
11
11
|
import { digestSha256Short } from '../crypto-digest-utils.mjs';
|
|
12
12
|
|
|
13
|
+
/** Normalize letters for new note slugs. */
|
|
14
|
+
function normalizeSlugLetters(value) {
|
|
15
|
+
const german = { ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' };
|
|
16
|
+
return value.normalize('NFC').toLowerCase()
|
|
17
|
+
.replace(/[äöüß]/g, (letter) => german[letter])
|
|
18
|
+
.normalize('NFKD').replace(/\p{M}/gu, '');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Historical ASCII deletion, frozen for record identity and old note lookup. */
|
|
22
|
+
export function legacySubjectToSlug(subject) {
|
|
23
|
+
return slugSubject(subject, (value) => value.toLowerCase());
|
|
24
|
+
}
|
|
25
|
+
|
|
13
26
|
/**
|
|
14
27
|
* Convert a subject string to a kebab slug.
|
|
15
28
|
* - If subject contains slashes, collapse to last path segment.
|
|
29
|
+
* - Transliterate German letters and decompose accents.
|
|
16
30
|
* - Replace dots and underscores with hyphens.
|
|
17
31
|
* - Strip all non-[a-z0-9-] chars.
|
|
18
32
|
* - Collapse consecutive hyphens.
|
|
19
33
|
* - Trim leading/trailing hyphens.
|
|
20
34
|
*/
|
|
21
35
|
export function subjectToSlug(subject) {
|
|
22
|
-
|
|
36
|
+
return slugSubject(subject, normalizeSlugLetters);
|
|
37
|
+
}
|
|
23
38
|
|
|
24
|
-
|
|
39
|
+
function slugSubject(subject, normalize) {
|
|
40
|
+
let s = subject;
|
|
25
41
|
if (s.includes('/')) {
|
|
26
42
|
const parts = s.split('/').filter(Boolean);
|
|
27
43
|
s = parts[parts.length - 1];
|
|
28
44
|
}
|
|
29
|
-
|
|
30
|
-
// Normalise: lowercase, dots/underscores → hyphens, strip invalid chars
|
|
31
|
-
s = s
|
|
32
|
-
.toLowerCase()
|
|
45
|
+
return normalize(s)
|
|
33
46
|
.replace(/[._]/g, '-')
|
|
34
47
|
.replace(/[^a-z0-9-]/g, '')
|
|
35
48
|
.replace(/-+/g, '-')
|
|
36
49
|
.replace(/^-+|-+$/g, '');
|
|
37
|
-
|
|
38
|
-
return s;
|
|
39
50
|
}
|
|
40
51
|
|
|
41
52
|
const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
@@ -80,8 +91,8 @@ export const TAG_MAX_LENGTH = 64;
|
|
|
80
91
|
* @returns {string}
|
|
81
92
|
*/
|
|
82
93
|
export function slugifyTagSegment(input) {
|
|
83
|
-
|
|
84
|
-
|
|
94
|
+
// Tags are persistent identifiers, so retain historical ASCII deletion.
|
|
95
|
+
const slug = String(input ?? '').toLowerCase()
|
|
85
96
|
.replace(/[\s._]+/g, '-')
|
|
86
97
|
.replace(/[^a-z0-9-]/g, '')
|
|
87
98
|
.replace(/-+/g, '-')
|
|
@@ -123,9 +134,11 @@ export function buildTag(segments) {
|
|
|
123
134
|
*/
|
|
124
135
|
export function slugifyIdSafe(input) {
|
|
125
136
|
// Normalise whitespace to hyphens first (so "My ID" → "my-id" rather than
|
|
126
|
-
// "myid"), then
|
|
137
|
+
// "myid"), then preserve the historical ASCII kebab contract.
|
|
127
138
|
const normalised = String(input ?? '').replace(/\s+/g, '-');
|
|
128
|
-
|
|
139
|
+
// Session identity retains historical deletion; transliteration is for new
|
|
140
|
+
// learning names, never an incidental Session-ID migration.
|
|
141
|
+
const slug = legacySubjectToSlug(normalised);
|
|
129
142
|
if (!slug) return null;
|
|
130
143
|
// Schema requires min length 2; pad a single-char slug deterministically.
|
|
131
144
|
return slug.length >= 2 ? slug : `${slug}-x`;
|
|
@@ -198,7 +211,8 @@ export function resolveSourceSessionLink(source_session, opts = {}) {
|
|
|
198
211
|
if (!raw || raw === 'unknown') return { isLink: false, target: raw || 'unknown' };
|
|
199
212
|
// Provenance tags like "agent-proposed@wave-1" are never session notes.
|
|
200
213
|
if (/@/.test(raw)) return { isLink: false, target: raw };
|
|
201
|
-
|
|
214
|
+
// Session targets retain the same historical identity as their note filenames.
|
|
215
|
+
const slug = legacySubjectToSlug(raw) || raw;
|
|
202
216
|
if (typeof opts.noteExists === 'function') {
|
|
203
217
|
// EXISTENCE-based (authoritative when a predicate is supplied).
|
|
204
218
|
return opts.noteExists(slug) ? { isLink: true, target: slug } : { isLink: false, target: raw };
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
* canonical-vault refusal (#1450). Plain Node ESM. No external deps.
|
|
49
49
|
*/
|
|
50
50
|
|
|
51
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
51
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'node:fs';
|
|
52
52
|
import os from 'node:os';
|
|
53
53
|
import path from 'node:path';
|
|
54
54
|
|
|
@@ -56,6 +56,7 @@ import { emitEvent, sessionAttribution } from '../events.mjs';
|
|
|
56
56
|
import { isLockLive, readLock, DEFAULT_TTL_HOURS } from '../session-lock.mjs';
|
|
57
57
|
import { stableHostname } from '../host-identity.mjs';
|
|
58
58
|
import { readRegistry, repoPathHash, isRegistryEntryFresh } from '../session-registry.mjs';
|
|
59
|
+
import { digestSha256 } from '../crypto-digest-utils.mjs';
|
|
59
60
|
import { parseFrontmatter } from '../vault-mirror/utils.mjs';
|
|
60
61
|
import { readConfigFile, parseSessionConfig } from '../config.mjs';
|
|
61
62
|
import { validatePathInsideProject } from '../path-utils.mjs';
|
|
@@ -134,10 +135,29 @@ const CASE_INSENSITIVE_FS = process.platform === 'darwin' || process.platform ==
|
|
|
134
135
|
* @returns {string|null} `KEY_LENGTH`-char hex prefix, or null for an unusable path.
|
|
135
136
|
*/
|
|
136
137
|
export function boardKey(repoRoot) {
|
|
138
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) return null;
|
|
139
|
+
try {
|
|
140
|
+
let resolved = path.resolve(repoRoot);
|
|
141
|
+
try { resolved = realpathSync.native(resolved); } catch { /* Keep unavailable paths' legacy identity. */ }
|
|
142
|
+
// Fold AFTER realpath, then hash directly: repoPathHash would restore
|
|
143
|
+
// on-disk casing and change existing case-folded board keys on macOS.
|
|
144
|
+
return digestSha256(CASE_INSENSITIVE_FS ? resolved.toLowerCase() : resolved).slice(0, KEY_LENGTH);
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Pre-#1557 lexical board key, used to migrate rows when alias paths now
|
|
152
|
+
* resolve to a canonical key. BV-004 revisit/deletion trigger: remove once a
|
|
153
|
+
* host-wide board audit confirms no rows written before the #1557 canonical-key
|
|
154
|
+
* rollout remain for 30 consecutive days; age alone does not prove migration.
|
|
155
|
+
*/
|
|
156
|
+
function legacyBoardKey(repoRoot) {
|
|
137
157
|
if (typeof repoRoot !== 'string' || repoRoot.length === 0) return null;
|
|
138
158
|
try {
|
|
139
159
|
const resolved = path.resolve(repoRoot);
|
|
140
|
-
return
|
|
160
|
+
return digestSha256(CASE_INSENSITIVE_FS ? resolved.toLowerCase() : resolved).slice(0, KEY_LENGTH);
|
|
141
161
|
} catch {
|
|
142
162
|
return null;
|
|
143
163
|
}
|
|
@@ -435,6 +455,7 @@ export async function collectRows({ repos, now = new Date(), registry, priorStat
|
|
|
435
455
|
rows.push({
|
|
436
456
|
repo: repoName,
|
|
437
457
|
key,
|
|
458
|
+
legacyKey: legacyBoardKey(repo.repoRoot), // Internal migration identity; never rendered.
|
|
438
459
|
status,
|
|
439
460
|
session: status === STATUS_FREI ? null : session,
|
|
440
461
|
branch: status === STATUS_FREI ? null : branch,
|
|
@@ -1098,8 +1119,8 @@ async function mirrorBoardInner({ repoRoot, repos, explicitStatus, now = new Dat
|
|
|
1098
1119
|
// skips `frei` candidates and the TTL pass only rewrites `status`, never
|
|
1099
1120
|
// removes a row), and the board would grow a permanent duplicate per repo.
|
|
1100
1121
|
// So a fresh row first claims its hash slot; if that slot is new, it ADOPTS
|
|
1101
|
-
// the legacy name slot for the same folded name
|
|
1102
|
-
//
|
|
1122
|
+
// the legacy name slot for the same folded name and the pre-#1557 lexical
|
|
1123
|
+
// key slot — one board write converts the row for that repo.
|
|
1103
1124
|
const merged = new Map(staleRederivedRows);
|
|
1104
1125
|
for (const row of rows) {
|
|
1105
1126
|
const slot = row.key ? hashSlot(row.key) : nameSlot(row.repo);
|
|
@@ -1107,6 +1128,13 @@ async function mirrorBoardInner({ repoRoot, repos, explicitStatus, now = new Dat
|
|
|
1107
1128
|
// First keyed write for this repo — take over its legacy row rather than
|
|
1108
1129
|
// rendering a second one beside it.
|
|
1109
1130
|
merged.delete(nameSlot(row.repo));
|
|
1131
|
+
if (row.legacyKey && row.legacyKey !== row.key) {
|
|
1132
|
+
// An 8-char key can collide: adopt the legacy-key row only when it
|
|
1133
|
+
// names this repo, never another repo's row that shares the prefix.
|
|
1134
|
+
const legacySlot = hashSlot(row.legacyKey);
|
|
1135
|
+
const legacyRow = merged.get(legacySlot);
|
|
1136
|
+
if (legacyRow && foldKey(legacyRow.repo) === foldKey(row.repo)) merged.delete(legacySlot);
|
|
1137
|
+
}
|
|
1110
1138
|
}
|
|
1111
1139
|
merged.set(slot, row);
|
|
1112
1140
|
}
|
|
@@ -1,19 +1,67 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/** Stdin-only boundary for commands/session.md; never executes task context. */
|
|
3
|
-
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { accessSync, constants, readFileSync, statSync } from 'node:fs';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
4
5
|
import { parseArgs } from 'node:util';
|
|
5
6
|
import { resolveSessionInvocation } from './lib/session-invocation.mjs';
|
|
7
|
+
import { readCanonicalSessions } from './lib/sessions-canonical.mjs';
|
|
8
|
+
import { isRealSession } from './lib/session-schema/filters.mjs';
|
|
9
|
+
import { resolveEventsLedgerRoot } from './lib/platform.mjs';
|
|
6
10
|
|
|
7
|
-
|
|
11
|
+
/**
|
|
12
|
+
* One ledger: `false` when absent, `undefined` when present but not a readable
|
|
13
|
+
* file, else whether it holds a real session.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} root
|
|
16
|
+
* @returns {boolean|undefined}
|
|
17
|
+
*/
|
|
18
|
+
function ledgerHasHistory(root) {
|
|
19
|
+
const file = join(root, '.orchestrator', 'metrics', 'sessions.jsonl');
|
|
20
|
+
try {
|
|
21
|
+
if (!statSync(file).isFile()) return undefined;
|
|
22
|
+
accessSync(file, constants.R_OK);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
return error?.code === 'ENOENT' ? false : undefined;
|
|
25
|
+
}
|
|
26
|
+
return readCanonicalSessions({ filePath: file }).some(isRealSession);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether the repo holds a real session of its own: `true`/`false`, or
|
|
31
|
+
* `undefined` when that cannot be measured. sessions.jsonl is gitignored, so a
|
|
32
|
+
* linked worktree also reads its main checkout's ledger (`resolveEventsLedgerRoot`);
|
|
33
|
+
* one with history in either place has history. A ledger that exists but
|
|
34
|
+
* cannot be read is "not measured", never "no history".
|
|
35
|
+
*
|
|
36
|
+
* @param {string} repoRoot
|
|
37
|
+
* @returns {boolean|undefined}
|
|
38
|
+
*/
|
|
39
|
+
function measureHistory(repoRoot) {
|
|
40
|
+
const roots = [...new Set([resolve(repoRoot), resolveEventsLedgerRoot(repoRoot)])];
|
|
41
|
+
let unmeasured = false;
|
|
42
|
+
for (const root of roots) {
|
|
43
|
+
const has = ledgerHasHistory(root);
|
|
44
|
+
if (has === true) return true;
|
|
45
|
+
if (has === undefined) unmeasured = true;
|
|
46
|
+
}
|
|
47
|
+
return unmeasured ? undefined : false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const HELP = `Usage: node scripts/resolve-session-invocation.mjs [--json] [--repo-root <dir>] < arguments.txt
|
|
8
51
|
|
|
9
52
|
Read the complete /session argument text from UTF-8 stdin.
|
|
10
53
|
Resolve the leading housekeeping, feature, deep or ultradeep token; preserve
|
|
11
54
|
the rest as task context. Empty input defaults to deep. An invalid mode emits
|
|
12
55
|
a warning and resolves to deep, as the session command has always specified.
|
|
56
|
+
A bare /session in a repo whose .orchestrator/metrics/sessions.jsonl holds no
|
|
57
|
+
real session adds defaultReason "no-session-history": ask for task and mode
|
|
58
|
+
before starting. A linked git worktree reads its main checkout's ledger; an
|
|
59
|
+
unreadable ledger counts as not measured and adds nothing.
|
|
13
60
|
|
|
14
|
-
--json
|
|
15
|
-
--
|
|
16
|
-
--
|
|
61
|
+
--json Emit one JSON object instead of a human-readable summary
|
|
62
|
+
--repo-root <dir> Project root whose session history is read (default: cwd)
|
|
63
|
+
--help Show this help
|
|
64
|
+
--version Show the plugin version
|
|
17
65
|
|
|
18
66
|
Example: node scripts/resolve-session-invocation.mjs --json < arguments.txt
|
|
19
67
|
Write arguments.txt with a file tool; do not interpolate user text into a shell.
|
|
@@ -23,7 +71,12 @@ Exit codes: 0 resolved (including fallback), 1 bad CLI arguments, 2 stdin I/O er
|
|
|
23
71
|
let options;
|
|
24
72
|
try {
|
|
25
73
|
options = parseArgs({
|
|
26
|
-
options: {
|
|
74
|
+
options: {
|
|
75
|
+
json: { type: 'boolean' },
|
|
76
|
+
'repo-root': { type: 'string' },
|
|
77
|
+
help: { type: 'boolean' },
|
|
78
|
+
version: { type: 'boolean' },
|
|
79
|
+
},
|
|
27
80
|
allowPositionals: false,
|
|
28
81
|
}).values;
|
|
29
82
|
} catch (error) {
|
|
@@ -45,7 +98,10 @@ if (options.help) {
|
|
|
45
98
|
process.stderr.write('session-invocation: could not read UTF-8 stdin\n');
|
|
46
99
|
process.exit(2);
|
|
47
100
|
}
|
|
48
|
-
|
|
101
|
+
// History is read only for a bare /session: an explicit mode never needs it.
|
|
102
|
+
const hasHistory =
|
|
103
|
+
input.trim() === '' ? measureHistory(options['repo-root'] || process.cwd()) : undefined;
|
|
104
|
+
const resolved = resolveSessionInvocation(input, { hasHistory });
|
|
49
105
|
if (resolved.invalidMode !== undefined) {
|
|
50
106
|
process.stderr.write(
|
|
51
107
|
`Invalid session type '${resolved.invalidMode}'. Valid types: housekeeping, feature, deep (alias: ultradeep). Falling back to deep.\n`,
|
|
@@ -54,6 +110,6 @@ if (options.help) {
|
|
|
54
110
|
process.stdout.write(
|
|
55
111
|
options.json
|
|
56
112
|
? `${JSON.stringify(resolved)}\n`
|
|
57
|
-
: `Session type: ${resolved.sessionType}${resolved.profile ? ` (profile: ${resolved.profile})` : ''}\nTask context: ${resolved.context}\n`,
|
|
113
|
+
: `Session type: ${resolved.sessionType}${resolved.profile ? ` (profile: ${resolved.profile})` : ''}${resolved.defaultReason ? ` (default: ${resolved.defaultReason})` : ''}\nTask context: ${resolved.context}\n`,
|
|
58
114
|
);
|
|
59
115
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { promisify, parseArgs } from 'node:util';
|
|
8
|
+
import { measure } from './lib/so-hud/runtime.mjs';
|
|
9
|
+
import { isMainModule } from './lib/is-main-module.mjs';
|
|
10
|
+
const exec = promisify(execFile);
|
|
11
|
+
const SO_ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
12
|
+
|
|
13
|
+
export async function main(argv, deps = {}) {
|
|
14
|
+
const output = deps.output ?? (s => process.stdout.write(s));
|
|
15
|
+
const error = deps.error ?? (s => process.stderr.write(s));
|
|
16
|
+
let values;
|
|
17
|
+
try {
|
|
18
|
+
({ values } = parseArgs({ args: argv, options: { json: { type: 'boolean' }, 'repo-root': { type: 'string' }, 'register-root': { type: 'boolean' }, help: { type: 'boolean' } }, allowPositionals: false }));
|
|
19
|
+
if (values.help) {
|
|
20
|
+
output('so-hud --json [--repo-root <path>] [--register-root]\nRead-only HUD JSON v1. --register-root registers this SO installation (0600).\nDefault repo: git toplevel of cwd. Exit 0: measured; 2: invocation/registration error; 6: repository unavailable.\n');
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
if (!values.json && !values['register-root']) throw new Error('--json required');
|
|
24
|
+
} catch { error('so-hud: --json erforderlich; siehe --help\n'); return 2; }
|
|
25
|
+
if (values['register-root']) {
|
|
26
|
+
try {
|
|
27
|
+
const dir = path.join(deps.home ?? os.homedir(), '.config/session-orchestrator/hud');
|
|
28
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
29
|
+
await fs.chmod(dir, 0o700);
|
|
30
|
+
const target = path.join(dir, 'so-root');
|
|
31
|
+
const temp = `${target}.${process.pid}.tmp`;
|
|
32
|
+
try { await fs.writeFile(temp, `${SO_ROOT}\n`, { mode: 0o600, flag: 'wx' }); await fs.rename(temp, target); }
|
|
33
|
+
finally { await fs.unlink(temp).catch(() => {}); }
|
|
34
|
+
} catch { error('so-hud: Registrierung fehlgeschlagen\n'); return 2; }
|
|
35
|
+
if (!values.json) return 0;
|
|
36
|
+
}
|
|
37
|
+
let root;
|
|
38
|
+
try {
|
|
39
|
+
root = values['repo-root'] ? path.resolve(values['repo-root']) : (await (deps.exec ?? exec)('git', ['rev-parse', '--show-toplevel'], { timeout: 700 })).stdout.trim();
|
|
40
|
+
if (!root || !(await fs.stat(root)).isDirectory()) throw new Error('missing');
|
|
41
|
+
} catch { error('so-hud: Repo nicht ermittelbar\n'); return 6; }
|
|
42
|
+
try { output(`${JSON.stringify(await (deps.measure ?? measure)(root))}\n`); return 0; }
|
|
43
|
+
catch { error('so-hud: Messung fehlgeschlagen\n'); return 6; }
|
|
44
|
+
}
|
|
45
|
+
if (isMainModule(import.meta.url)) process.exitCode = await main(process.argv.slice(2));
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** session-start Phase 1.5 Idle Reset (#1560): rewrite a completed STATE.md to idle, under the STATE.md lock. */
|
|
3
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { parseArgs } from 'node:util';
|
|
6
|
+
import { resetStateMdToIdleOnDisk } from './lib/state-md/idle-reset.mjs';
|
|
7
|
+
|
|
8
|
+
const HELP = `Usage: node scripts/state-md-idle-reset.mjs [--repo-root <dir>] [--json]
|
|
9
|
+
|
|
10
|
+
Reset the repo's STATE.md to idle when its status is completed (session-start
|
|
11
|
+
Phase 1.5, Idle Reset rules 1-7): status idle, current-wave 0, Wave History
|
|
12
|
+
demoted into Previous Session, Deviations emptied, scope-baseline-* keys
|
|
13
|
+
deleted. The five recommendation keys stay in the frontmatter so the Phase 7.5
|
|
14
|
+
Mode-Selector can read them. Any other status is a no-op; reasons:
|
|
15
|
+
status-not-completed, unparseable, missing (no or empty STATE.md).
|
|
16
|
+
|
|
17
|
+
--repo-root <dir> Repository root holding the state dir (default: cwd)
|
|
18
|
+
--json Emit {"changed", "reason", "path"} instead of one line
|
|
19
|
+
--help Show this help
|
|
20
|
+
--version Show the plugin version
|
|
21
|
+
|
|
22
|
+
Example: node scripts/state-md-idle-reset.mjs --json
|
|
23
|
+
Exit codes: 0 reset or no-op, 1 bad CLI arguments,
|
|
24
|
+
2 I/O error, --repo-root not a directory, or a write the
|
|
25
|
+
STATE.md guards refused
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
/** Reasons under which writeStateMd refused a wanted write. */
|
|
29
|
+
const REFUSED = new Set(['size-ceiling', 'frontmatter-unsafe']);
|
|
30
|
+
|
|
31
|
+
let options;
|
|
32
|
+
try {
|
|
33
|
+
options = parseArgs({
|
|
34
|
+
options: {
|
|
35
|
+
'repo-root': { type: 'string' },
|
|
36
|
+
json: { type: 'boolean' },
|
|
37
|
+
help: { type: 'boolean' },
|
|
38
|
+
version: { type: 'boolean' },
|
|
39
|
+
},
|
|
40
|
+
allowPositionals: false,
|
|
41
|
+
}).values;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
process.stderr.write(`state-md-idle-reset: ${error.message}\n`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (options.help) {
|
|
48
|
+
process.stdout.write(HELP);
|
|
49
|
+
} else if (options.version) {
|
|
50
|
+
process.stdout.write(
|
|
51
|
+
`${JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version}\n`,
|
|
52
|
+
);
|
|
53
|
+
} else {
|
|
54
|
+
const repoRoot = resolve(options['repo-root'] ?? process.cwd());
|
|
55
|
+
// The STATE.md lock would otherwise create <repoRoot>/.orchestrator/ for a
|
|
56
|
+
// root that does not exist and report a harmless-looking `missing`.
|
|
57
|
+
let rootStat;
|
|
58
|
+
try {
|
|
59
|
+
rootStat = statSync(repoRoot, { throwIfNoEntry: false });
|
|
60
|
+
} catch {
|
|
61
|
+
rootStat = undefined; // EACCES / ELOOP: an I/O error, exit 2 below
|
|
62
|
+
}
|
|
63
|
+
if (!rootStat?.isDirectory()) {
|
|
64
|
+
process.stderr.write(`state-md-idle-reset: --repo-root is not a directory: ${repoRoot}\n`);
|
|
65
|
+
process.exit(2);
|
|
66
|
+
}
|
|
67
|
+
let result;
|
|
68
|
+
try {
|
|
69
|
+
result = await resetStateMdToIdleOnDisk(repoRoot);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
process.stderr.write(`state-md-idle-reset: ${error.message}\n`);
|
|
72
|
+
process.exit(2);
|
|
73
|
+
}
|
|
74
|
+
const reason = result.reason ?? null;
|
|
75
|
+
process.stdout.write(
|
|
76
|
+
options.json
|
|
77
|
+
? `${JSON.stringify({ changed: result.changed, reason, path: result.path })}\n`
|
|
78
|
+
: `${result.changed ? 'reset to idle' : `unchanged (${reason})`}: ${result.path}\n`,
|
|
79
|
+
);
|
|
80
|
+
if (REFUSED.has(reason)) process.exit(2);
|
|
81
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* vault-orphans.mjs - CLI for `findVaultOrphans` (#1563, session-end Phase 3.7e).
|
|
4
|
+
*
|
|
5
|
+
* Names the untracked files in the Meta-Vault modified since a given instant
|
|
6
|
+
* (the session's start). Read-only: it never commits, moves or deletes anything
|
|
7
|
+
* in the vault. See scripts/lib/session-end/vault-orphans.mjs for the design.
|
|
8
|
+
*
|
|
9
|
+
* USAGE
|
|
10
|
+
* node scripts/vault-orphans.mjs --since <ISO> [--vault-dir <dir>] [--json]
|
|
11
|
+
*
|
|
12
|
+
* EXIT CODES
|
|
13
|
+
* 0 measured (including zero candidates)
|
|
14
|
+
* 1 bad arguments
|
|
15
|
+
* 2 could not measure (no vault dir resolvable, not a git repo, git failed)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { parseArgs } from 'node:util';
|
|
21
|
+
|
|
22
|
+
import { findVaultOrphans } from './lib/session-end/vault-orphans.mjs';
|
|
23
|
+
import { _parseVaultIntegration } from './lib/config/vault-integration.mjs';
|
|
24
|
+
import { loadHostPaths } from './lib/config/host-paths.mjs';
|
|
25
|
+
import { resolveVaultDir } from './lib/config.mjs';
|
|
26
|
+
import { expandTilde } from './lib/common.mjs';
|
|
27
|
+
import { isMainModule } from './lib/is-main-module.mjs';
|
|
28
|
+
|
|
29
|
+
const HELP = `vault-orphans.mjs - name untracked vault files written since a session started (#1563)
|
|
30
|
+
|
|
31
|
+
USAGE
|
|
32
|
+
node scripts/vault-orphans.mjs --since <ISO> [--vault-dir <dir>] [--json]
|
|
33
|
+
|
|
34
|
+
DESCRIPTION
|
|
35
|
+
Runs the read-only \`git -C <vault-dir> ls-files --others --exclude-standard\`
|
|
36
|
+
and lists every untracked file whose mtime is at or after --since. Files whose
|
|
37
|
+
frontmatter names one of this plugin's generators (\`_generator:
|
|
38
|
+
session-orchestrator-...\`) are left out: their writer owns them.
|
|
39
|
+
|
|
40
|
+
The results are CANDIDATES: mtime is a hint, not proof that this session
|
|
41
|
+
wrote the file. Nothing is committed, moved or deleted.
|
|
42
|
+
|
|
43
|
+
OPTIONS
|
|
44
|
+
--since <ISO> Required. ISO 8601 instant with Z or an offset, e.g. the
|
|
45
|
+
STATE.md \`started_at\` (2026-10-11T08:00:00.000Z).
|
|
46
|
+
--vault-dir <dir> Vault working tree. Default: the host-local resolution
|
|
47
|
+
SO_VAULT_DIR > owner.yaml > committed
|
|
48
|
+
vault-integration.vault-dir in ./CLAUDE.md or ./AGENTS.md.
|
|
49
|
+
--json Print the result object as JSON on stdout.
|
|
50
|
+
--help Show this help.
|
|
51
|
+
--version Print the plugin version.
|
|
52
|
+
|
|
53
|
+
EXIT CODES
|
|
54
|
+
0 measured, including zero candidates
|
|
55
|
+
1 bad arguments
|
|
56
|
+
2 could not measure (no vault dir, not a git repo, git failed)
|
|
57
|
+
|
|
58
|
+
EXAMPLE
|
|
59
|
+
node scripts/vault-orphans.mjs --since 2026-10-11T08:00:00.000Z --json
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
/** ISO 8601 with an explicit zone - a zone-less value would be read as local time. */
|
|
63
|
+
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve the vault dir like the other vault writers: SO_VAULT_DIR > owner.yaml
|
|
67
|
+
* `vault-dirs:` cwd match > owner.yaml `paths.vault-dir` > committed value.
|
|
68
|
+
*
|
|
69
|
+
* @returns {string|null}
|
|
70
|
+
*/
|
|
71
|
+
function resolveDefaultVaultDir() {
|
|
72
|
+
let content = '';
|
|
73
|
+
for (const name of ['CLAUDE.md', 'AGENTS.md']) {
|
|
74
|
+
const p = join(process.cwd(), name);
|
|
75
|
+
if (existsSync(p)) {
|
|
76
|
+
try {
|
|
77
|
+
content = readFileSync(p, 'utf8');
|
|
78
|
+
break;
|
|
79
|
+
} catch {
|
|
80
|
+
/* try the next file */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const vi = _parseVaultIntegration(content);
|
|
85
|
+
const value = resolveVaultDir(vi['vault-dir'], loadHostPaths()).value;
|
|
86
|
+
return typeof value === 'string' && value.trim() !== '' ? expandTilde(value) : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @param {{status: 'ok', candidates: Array<{path: string}>, total: number, truncated: boolean, untracked: number}} r */
|
|
90
|
+
function renderHuman(r, since) {
|
|
91
|
+
const lines = [];
|
|
92
|
+
if (r.total === 0) {
|
|
93
|
+
lines.push(`vault-orphans: no untracked vault file modified since ${since} (${r.untracked} untracked in total)`);
|
|
94
|
+
} else {
|
|
95
|
+
lines.push(
|
|
96
|
+
`vault-orphans: ${r.total} untracked vault file(s) modified since ${since} - candidates only, ` +
|
|
97
|
+
`mtime does not prove this session wrote them (${r.untracked} untracked in total)`,
|
|
98
|
+
);
|
|
99
|
+
for (const c of r.candidates) lines.push(` ${c.path}`);
|
|
100
|
+
if (r.truncated) lines.push(` ... showing ${r.candidates.length} of ${r.total}`);
|
|
101
|
+
}
|
|
102
|
+
return `${lines.join('\n')}\n`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* CLI entry.
|
|
107
|
+
*
|
|
108
|
+
* @param {string[]} [argv]
|
|
109
|
+
* @param {{stdout: {write: Function}, stderr: {write: Function}}} [io]
|
|
110
|
+
* @returns {number} exit code
|
|
111
|
+
*/
|
|
112
|
+
export function main(argv = process.argv.slice(2), io = { stdout: process.stdout, stderr: process.stderr }) {
|
|
113
|
+
let values;
|
|
114
|
+
try {
|
|
115
|
+
({ values } = parseArgs({
|
|
116
|
+
args: argv,
|
|
117
|
+
options: {
|
|
118
|
+
since: { type: 'string' },
|
|
119
|
+
'vault-dir': { type: 'string' },
|
|
120
|
+
json: { type: 'boolean', default: false },
|
|
121
|
+
help: { type: 'boolean', default: false },
|
|
122
|
+
version: { type: 'boolean', default: false },
|
|
123
|
+
},
|
|
124
|
+
strict: true,
|
|
125
|
+
allowPositionals: false,
|
|
126
|
+
}));
|
|
127
|
+
} catch (err) {
|
|
128
|
+
io.stderr.write(`vault-orphans: ${err.message}\nRun with --help for usage.\n`);
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (values.help) {
|
|
133
|
+
io.stdout.write(HELP);
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
if (values.version) {
|
|
137
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
138
|
+
io.stdout.write(`${pkg.version}\n`);
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const since = values.since;
|
|
143
|
+
const sinceMs = typeof since === 'string' && ISO_RE.test(since) ? Date.parse(since) : NaN;
|
|
144
|
+
if (!Number.isFinite(sinceMs)) {
|
|
145
|
+
io.stderr.write('vault-orphans: --since <ISO> is required, e.g. 2026-10-11T08:00:00.000Z\n');
|
|
146
|
+
return 1;
|
|
147
|
+
}
|
|
148
|
+
if (values['vault-dir'] !== undefined && values['vault-dir'].trim() === '') {
|
|
149
|
+
io.stderr.write('vault-orphans: --vault-dir must not be empty\n');
|
|
150
|
+
return 1;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const vaultDir =
|
|
154
|
+
values['vault-dir'] !== undefined ? expandTilde(values['vault-dir']) : resolveDefaultVaultDir();
|
|
155
|
+
const result = findVaultOrphans({ vaultDir, sinceMs });
|
|
156
|
+
|
|
157
|
+
if (result.status !== 'ok') {
|
|
158
|
+
io.stderr.write(
|
|
159
|
+
`vault-orphans: could not measure (${result.reason}${result.detail ? `: ${result.detail}` : ''})\n`,
|
|
160
|
+
);
|
|
161
|
+
if (values.json) io.stdout.write(`${JSON.stringify(result)}\n`);
|
|
162
|
+
return 2;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
io.stdout.write(values.json ? `${JSON.stringify(result)}\n` : renderHuman(result, since));
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (isMainModule(import.meta.url)) {
|
|
170
|
+
process.exitCode = main();
|
|
171
|
+
}
|
|
@@ -75,7 +75,14 @@ die vom Lease-Validator akzeptierte `adresse` einer aktiven Lease, sonst `null`
|
|
|
75
75
|
Lease, kein Eintrag oder ein abgelehnter Wert — nie ein ungeprüfter String). `fallback_due_at` ist
|
|
76
76
|
der Zeitpunkt, ab dem die Standard-Auflagen (unten) gelten, in der Form von `zeit` (Code:
|
|
77
77
|
`fallbackDueAt()`); er steht nur auf stdout, die Check-in-Datei behält ihr Schema. Exit 0
|
|
78
|
-
geschrieben, 2 ungültige Eingabe (Fehlerliste auf stderr, keine Datei)
|
|
78
|
+
geschrieben, 2 ungültige Eingabe (Fehlerliste auf stderr, keine Datei) oder semantische Session-ID
|
|
79
|
+
von einem anderen Repo belegt (eine Zeile auf stderr mit JSON-escaped Repo-Namen, fremde Datei unverändert,
|
|
80
|
+
tmp entfernt, kein Event), 1 Schreibfehler.
|
|
81
|
+
Vor dem `rename` lehnt der Schreiber ein vorhandenes String-`repo` ungleich dem eigenen ab;
|
|
82
|
+
gleiches `repo`, unlesbare Dateien und ungültiges JSON dürfen überschrieben werden.
|
|
83
|
+
Die Prüfung folgt keinen Symlinks, öffnet nichtblockierend und liest nur reguläre Dateien
|
|
84
|
+
bis 64 KiB. Symlinks, andere Dateitypen, größere Dateien oder fehlende sichere Open-Flags
|
|
85
|
+
gelten für diese Prüfung als unlesbar (fail-open zum `rename`).
|
|
79
86
|
|
|
80
87
|
| Feld | Inhalt |
|
|
81
88
|
|---|---|
|
|
@@ -130,9 +137,24 @@ aktiven Navigator ist `fallback_due_at` gleich `zeit`, die Auflagen gelten also
|
|
|
130
137
|
Lesende Arbeit und Schreiben im eigenen Worktree laufen währenddessen weiter; der Rückfall ist ein
|
|
131
138
|
Dateistand mit Frist, kein Warten auf eine Antwort.
|
|
132
139
|
|
|
140
|
+
## Verhalten ohne Flotte
|
|
141
|
+
|
|
142
|
+
Flottenintegration liegt vor, wenn `NAVIGATOR_CONFIG_DIR` gesetzt und nicht leer ist (ausdrückliches
|
|
143
|
+
Opt-in, auch vor dem ersten Anlegen) oder `~/.config/navigator/` als Verzeichnis existiert (Code:
|
|
144
|
+
`fleetIntegrationEnabled()`, #1559). Ein Flottenhost hat das Verzeichnis, weil der Koordinator dort
|
|
145
|
+
die Lease schreibt. Ohne Flottenintegration validiert `fleet-checkin.mjs` die Eingabe wie immer
|
|
146
|
+
(ungültig → Exit 2), schreibt dann aber nichts, legt kein Verzeichnis an, emittiert kein Event und
|
|
147
|
+
meldet `{"ok":true,"skipped":"no-fleet",…}`. Es gelten keine Auflagen, auch keine Standard-Auflagen:
|
|
148
|
+
ohne Flotte gibt es nichts zu koordinieren. Die Navigator-Zeile im Sessionstart-Banner entfällt
|
|
149
|
+
ebenso. Das gilt auch für `plattform: kopflos`: der Datensatz beweist keinen Koordinator, und ein
|
|
150
|
+
Schreiben legte das Verzeichnis an, das danach jede Solo-Session zur Flotte macht. Ein kopfloser
|
|
151
|
+
Lauf ohne Flottenintegration wendet stattdessen die Standard-Auflagen sofort an — er kann niemanden
|
|
152
|
+
fragen. Ein Flotten-Starter, der auf einem frischen Remote-Host startet, setzt deshalb
|
|
153
|
+
`NAVIGATOR_CONFIG_DIR` oder legt das Verzeichnis vorher an.
|
|
154
|
+
|
|
133
155
|
## Verhalten ohne Navigator
|
|
134
156
|
|
|
135
|
-
Lease `none` oder `unreadable`: die Check-in-Datei wird **trotzdem** geschrieben (sie ist der
|
|
157
|
+
Flottenintegration vorhanden, Lease `none` oder `unreadable`: die Check-in-Datei wird **trotzdem** geschrieben (sie ist der
|
|
136
158
|
zählbare Beleg), die Standard-Auflagen gelten sofort, es geht keine Nachricht ab. Kopflose Läufe
|
|
137
159
|
sind per Nachricht nicht erreichbar und nutzen dieselben Dateien: vor jeder Welle `auflagen/` lesen.
|
|
138
160
|
|
|
@@ -40,7 +40,7 @@ The `session-type: none` + `status: idle` combination is used only for bootstrap
|
|
|
40
40
|
| `## Wave History` | Completed wave records | wave-executor (post-wave) |
|
|
41
41
|
| `## Deviations` | Plan adaptation log | wave-executor (step 3) |
|
|
42
42
|
| `## What Not To Retry` | Failed/abandoned approaches not to repeat (#623) | session-end (Phase 1.6) |
|
|
43
|
-
| `## Open Questions` | Unresolved user-facing questions (agent → gate → next session) | wave-executor (inter-wave) + session-end (marks answered) |
|
|
43
|
+
| `## Open Questions` | Unresolved user-facing questions (agent → gate → next session) | wave-executor (inter-wave) + session-end (marks answered; appends one vault-orphan entry at Phase 3.7e, #1563) |
|
|
44
44
|
|
|
45
45
|
Wave History lines MAY include a `→ issue #NNN` suffix (or `→ existing #NNN` when a duplicate was detected) for SPIRAL/FAILED agents, linking to the auto-created carryover issue (#261). This is optional and backward-compatible; readers that do not recognize the notation can skip it. Session-end Phase 1.6 uses the presence of this suffix to decide whether to retro-file a carryover as a fallback safety net.
|
|
46
46
|
|
|
@@ -139,8 +139,8 @@ growing across sessions is the CCU-009 anti-pattern.
|
|
|
139
139
|
| Skill | Access | Operations |
|
|
140
140
|
|-------|--------|------------|
|
|
141
141
|
| **wave-executor** | Read + Write (owner) | Creates STATE.md (Pre-Wave 1b), updates after each wave (current-wave, Wave History, Deviations); appends deduped `## Open Questions` at inter-wave checkpoints via `appendOpenQuestionOnDisk` (see `wave-loop.md` § 3e + Post-Wave step 6). |
|
|
142
|
-
| **session-end** | Read + Status-only write | Reads for metrics extraction (Phase 1.7), sets `status: completed` (Phase 3.4). Exception: only fields modified are `status` in frontmatter and marking entries answered in `## Open Questions` via `markOpenQuestionAnsweredOnDisk` (Close Handover-Alignment-Gate). |
|
|
143
|
-
| **session-start** | Read + conditional reset | Reads for continuity checks (Phase 1.5): inspects `status` field to detect crashed/paused sessions. Surfaces `## What Not To Retry` as a forced-read HISTORICAL block (Phase 6.5.1). May reset STATE.md to idle at the boundary between a completed session and a new session — only when prior `status: completed
|
|
142
|
+
| **session-end** | Read + Status-only write | Reads for metrics extraction (Phase 1.7), sets `status: completed` (Phase 3.4). Exception: only fields modified are `status` in frontmatter and marking entries answered in `## Open Questions` via `markOpenQuestionAnsweredOnDisk` (Close Handover-Alignment-Gate), plus at most one appended `## Open Questions` entry naming vault-orphan candidates via `appendOpenQuestionOnDisk` (Phase 3.7e, #1563). |
|
|
143
|
+
| **session-start** | Read + conditional reset | Reads for continuity checks (Phase 1.5): inspects `status` field to detect crashed/paused sessions. Surfaces `## What Not To Retry` as a forced-read HISTORICAL block (Phase 6.5.1). May reset STATE.md to idle at the boundary between a completed session and a new session — only when prior `status: completed`, via `scripts/state-md-idle-reset.mjs` (#1560). The reset keeps the five recommendation keys in the frontmatter for the Phase 7.5 Mode-Selector, clears `current-wave` (→ 0), sets `status: idle`, demotes `## Wave History` into `## Previous Session`, and empties `## Deviations` — but PRESERVES `## What Not To Retry` (cross-session continuity, #623) and `## Open Questions` (Close Handover-Alignment-Gate, PRD 2026-07-07). Never resets on `active` or `paused` (those paths are user-interactive). |
|
|
144
144
|
| **evolve** | Read-only | Reads `## Deviations` section for deviation pattern extraction (Step 2.2, pattern 5) |
|
|
145
145
|
|
|
146
146
|
### Shared-File Single-Writer Rule (`isolation: none` waves)
|
|
@@ -249,8 +249,9 @@ import { detectPeers } from '$PLUGIN_ROOT/scripts/lib/session-registry.mjs';
|
|
|
249
249
|
const flags = parseFlags(process.argv.slice(2));
|
|
250
250
|
|
|
251
251
|
const modeSelector = async () => {
|
|
252
|
-
// Each iteration rebuilds signals from current disk state. STATE.md will
|
|
253
|
-
//
|
|
252
|
+
// Each iteration rebuilds signals from current disk state. STATE.md will
|
|
253
|
+
// carry the recommendation the previous /close wrote (the idle reset at the
|
|
254
|
+
// next session-start keeps those keys, #1560), sessions.jsonl will have the
|
|
254
255
|
// new tail entry, etc. This is the contract: live signals every iteration.
|
|
255
256
|
// `repoRoot` is passed explicitly (#1071) rather than left to the helper's
|
|
256
257
|
// cwd default. Do NOT hand-write a `backlogLimit` here — the window default
|