session-orchestrator 4.2.0 → 5.0.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/.agents/skills/session-start/SKILL.md +1 -1
- package/.agents/skills/ux-grill/SKILL.md +22 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +3 -2
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/session-start/SKILL.md +1 -1
- package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
- package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
- package/.cursor/commands/ux-grill.md +14 -0
- package/.cursor/skills/session-start/SKILL.md +1 -1
- package/.cursor/skills/ux-grill/SKILL.md +13 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +94 -1
- package/README.md +98 -86
- package/agents/dialectic-deriver.md +11 -0
- package/commands/ux-grill.md +51 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/codex-setup.md +8 -0
- package/docs/components.md +7 -7
- package/docs/events-schema.md +9 -5
- package/docs/github-mirror-protection.md +61 -20
- package/docs/migration-v5.md +62 -0
- package/docs/scope-collision-guard.md +14 -0
- package/hooks/_lib/hook-import-set.json +44 -2
- package/hooks/_lib/lock-bootstrap.mjs +84 -1
- package/hooks/_lib/vcs-create-matcher.mjs +190 -3
- package/hooks/enforce-scope.mjs +201 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks.json +7 -2
- package/hooks/on-session-start.mjs +171 -49
- package/hooks/post-bash-issue-budget-refund.mjs +375 -0
- package/hooks/pre-auq-clarity.mjs +70 -18
- package/hooks/pre-bash-issue-budget.mjs +51 -4
- package/package.json +2 -1
- package/pi/prompts/ux-grill.md +12 -0
- package/scripts/ci/assert-vitest-green.mjs +4 -2
- package/scripts/dialectic-deriver.mjs +32 -8
- package/scripts/emit-session.mjs +72 -1
- package/scripts/lib/agent-status.mjs +441 -9
- package/scripts/lib/auq/schema.mjs +10 -3
- package/scripts/lib/ci-status-banner.mjs +29 -6
- package/scripts/lib/claude-md-budget-lint.mjs +52 -2
- package/scripts/lib/config.mjs +12 -1
- package/scripts/lib/eval/engine.mjs +7 -1
- package/scripts/lib/file-lock.mjs +114 -13
- package/scripts/lib/git-porcelain.mjs +113 -0
- package/scripts/lib/instruction-budget-guard.mjs +415 -47
- package/scripts/lib/io.mjs +29 -4
- package/scripts/lib/issue-budget.mjs +336 -6
- package/scripts/lib/learnings/sizing-subject.mjs +44 -0
- package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
- package/scripts/lib/locks/state-md-lock.mjs +19 -41
- package/scripts/lib/maintenance-due-banner.mjs +11 -1
- package/scripts/lib/peer-cards/merger.mjs +143 -0
- package/scripts/lib/pre-dispatch-check.mjs +20 -14
- package/scripts/lib/project-hygiene.mjs +81 -30
- package/scripts/lib/quality-gate.mjs +14 -65
- package/scripts/lib/reconcile/engine.mjs +19 -1
- package/scripts/lib/reconcile/writer.mjs +278 -11
- package/scripts/lib/scope-echo.mjs +346 -0
- package/scripts/lib/session-lock.mjs +62 -2
- package/scripts/lib/session-record-repair.mjs +91 -0
- package/scripts/lib/session-schema/filters.mjs +26 -1
- package/scripts/lib/session-start-probes.mjs +419 -53
- package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
- package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
- package/scripts/lib/tmux-layout/layouts.mjs +62 -4
- package/scripts/lib/ux-grill/collect.mjs +1163 -0
- package/scripts/lib/ux-grill/compare.mjs +285 -0
- package/scripts/lib/ux-grill/manifest.mjs +618 -0
- package/scripts/lib/ux-grill/measures.mjs +431 -0
- package/scripts/lib/ux-grill/paths.mjs +224 -0
- package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
- package/scripts/lib/ux-grill/reconcile.mjs +344 -0
- package/scripts/lib/ux-grill/run-record.mjs +316 -0
- package/scripts/lib/ux-grill/schema.mjs +321 -0
- package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
- package/scripts/lib/validate/check-unwired-features.mjs +48 -20
- package/scripts/lib/vault-status/board-lock.mjs +18 -0
- package/scripts/lib/vault-status/board-writer.mjs +8 -0
- package/scripts/mcp-server.sh +16 -1
- package/scripts/release.mjs +7 -2
- package/skills/bootstrap/SKILL.md +12 -209
- package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
- package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -2
- package/skills/claude-md-drift-check/checker.mjs +213 -21
- package/skills/discovery/SKILL.md +6 -173
- package/skills/discovery/probes/vault-staleness.mjs +35 -5
- package/skills/discovery/probes-docs.md +8 -4
- package/skills/discovery/probes-supply-chain.md +4 -2
- package/skills/discovery/probes-ui.md +7 -3
- package/skills/discovery/probes-vault.md +12 -4
- package/skills/discovery/references/discovery-interactive-triage.md +139 -0
- package/skills/discovery/references/discovery-triage-state.md +54 -0
- package/skills/eval/rubric-v1.md +13 -0
- package/skills/evolve/SKILL.md +2 -458
- package/skills/evolve/references/evolve-analyze-mode.md +360 -0
- package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
- package/skills/plan/mode-retro.md +4 -3
- package/skills/reconcile/SKILL.md +10 -0
- package/skills/session-end/drift-operations.md +20 -5
- package/skills/session-end/metrics-collection.md +1 -0
- package/skills/session-end/phase-3-6-tail.md +4 -2
- package/skills/session-end/references/phase-2-quality-gate.md +3 -3
- package/skills/session-end/references/phase-5-issue-cleanup.md +6 -1
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-plan/SKILL.md +2 -144
- package/skills/session-plan/references/session-plan-task-classification.md +152 -0
- package/skills/session-start/SKILL.md +24 -6
- package/skills/session-start/references/operations-contract.md +114 -0
- package/skills/session-start/references/phase-4-ssot-environment-check.md +22 -20
- package/skills/session-start/soul.md +2 -2
- package/skills/test-runner/SKILL.md +1 -1
- package/skills/tmux-layout/SKILL.md +3 -1
- package/skills/ux-grill/SKILL.md +211 -0
- package/skills/ux-grill/rubric-v2.md +201 -0
- package/skills/ux-grill/soul.md +76 -0
- package/skills/wave-executor/SKILL.md +3 -128
- package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
- package/skills/wave-executor/references/wave-loop-review.md +18 -5
- package/templates/_shared/ux-manifest.template.md +149 -0
- package/templates/_shared/journey-manifest.md +0 -114
|
@@ -186,8 +186,9 @@ export const CRITERIA = Object.freeze({
|
|
|
186
186
|
appliesTo: 'block',
|
|
187
187
|
hurdle: 'H1',
|
|
188
188
|
measures:
|
|
189
|
-
'Zeichen der Kopfzeile.
|
|
190
|
-
'
|
|
189
|
+
'Zeichen der Kopfzeile. 12 ist das von der Tool-Beschreibung genannte Budget ' +
|
|
190
|
+
'(`max 12 chars`) — in VORLAGEN eine harte Grenze, weil Kürzen dort nichts ' +
|
|
191
|
+
'kostet; zur Laufzeit nur beratend (siehe `evidence` an HURDLES.H1).',
|
|
191
192
|
}),
|
|
192
193
|
K6: Object.freeze({
|
|
193
194
|
id: 'K6',
|
|
@@ -251,7 +252,13 @@ export const HURDLES = Object.freeze({
|
|
|
251
252
|
criterion: 'K5',
|
|
252
253
|
evidence:
|
|
253
254
|
'Gemessen 2026-08-22: 26 von 42 Kopfzeilen-Literalen reißen diese Grenze (62 %), ' +
|
|
254
|
-
'Spitzenwert 54 Zeichen.
|
|
255
|
+
'Spitzenwert 54 Zeichen. Die 12 ist die Stilangabe der Tool-Beschreibung ' +
|
|
256
|
+
'(`max 12 chars`), KEINE erzwungene Grenze: im Bundle 2.1.268 steht die Zahl nur ' +
|
|
257
|
+
'in ebendieser Beschreibung, es gibt kein `.max(12)` im Zod-Schema und keinen ' +
|
|
258
|
+
'Render-Pfad, der sie liest — 125 längere Kopfzeilen wurden vom Tool angenommen ' +
|
|
259
|
+
'und beantwortet (gemessen 2026-09-11). Deshalb gilt H1 nur für VORLAGEN, wo ein ' +
|
|
260
|
+
'Autor kostenlos kürzen kann; zur Laufzeit meldet der Hook sie und blockt nicht ' +
|
|
261
|
+
'(siehe BLOCKING_HURDLES in hooks/pre-auq-clarity.mjs).',
|
|
255
262
|
}),
|
|
256
263
|
H2: Object.freeze({
|
|
257
264
|
id: 'H2',
|
|
@@ -531,7 +531,9 @@ async function checkGitlab(repoRoot, now, deps = {}) {
|
|
|
531
531
|
return null;
|
|
532
532
|
}
|
|
533
533
|
|
|
534
|
-
|
|
534
|
+
// #1332: an explicit `deps.sha` (validated full hex SHA, see checkCiStatus)
|
|
535
|
+
// replaces the local HEAD lookup — the caller asks about a NAMED commit.
|
|
536
|
+
const currentSha = deps.sha ?? (await getHeadSha(repoRoot, deps));
|
|
535
537
|
const apiDeps = { ...deps, repoHost: project.host };
|
|
536
538
|
const projectPath = `projects/${project.encodedProjectPath}`;
|
|
537
539
|
// `'array'` is load-bearing, not decoration: before it, a `glab api` that
|
|
@@ -700,8 +702,11 @@ async function checkGitlab(repoRoot, now, deps = {}) {
|
|
|
700
702
|
* (cross-family guard, unsafe-argv guard), and `normalizeGithubSpec` falls
|
|
701
703
|
* back to the raw URL on an unrecognised remote shape.
|
|
702
704
|
*
|
|
705
|
+
* `deps.sha` (#1332): when set, the check-runs query names THAT commit
|
|
706
|
+
* instead of the literal `HEAD` ref.
|
|
707
|
+
*
|
|
703
708
|
* @param {string} repoRoot
|
|
704
|
-
* @param {{ execFile?: Function, timeoutMs?: number, repoSpec?: string, repoHost?: string }} deps
|
|
709
|
+
* @param {{ execFile?: Function, timeoutMs?: number, repoSpec?: string, repoHost?: string, sha?: string }} deps
|
|
705
710
|
* @returns {Promise<object|null>}
|
|
706
711
|
*/
|
|
707
712
|
async function checkGithub(repoRoot, deps = {}) {
|
|
@@ -726,7 +731,7 @@ async function checkGithub(repoRoot, deps = {}) {
|
|
|
726
731
|
);
|
|
727
732
|
|
|
728
733
|
const data = await ghApi(
|
|
729
|
-
`repos/${nameWithOwner}/commits
|
|
734
|
+
`repos/${nameWithOwner}/commits/${deps.sha ?? 'HEAD'}/check-runs`,
|
|
730
735
|
repoRoot,
|
|
731
736
|
deps,
|
|
732
737
|
'object',
|
|
@@ -807,7 +812,12 @@ async function checkGithub(repoRoot, deps = {}) {
|
|
|
807
812
|
* vcs?: 'gitlab'|'github',
|
|
808
813
|
* timeoutMs?: number,
|
|
809
814
|
* now?: number,
|
|
810
|
-
*
|
|
815
|
+
* sha?: string,
|
|
816
|
+
* }} opts `sha` (#1332): query the verdict for THIS commit instead of the
|
|
817
|
+
* local HEAD. Must be a full hex object id (40 or 64 chars) — it is matched
|
|
818
|
+
* against GitLab's full pipeline SHAs and interpolated into a `gh api` path,
|
|
819
|
+
* so anything else is refused as `query-failed` before any spawn. Absent →
|
|
820
|
+
* behaviour identical to before the option existed.
|
|
811
821
|
* @param {{
|
|
812
822
|
* execFile?: Function,
|
|
813
823
|
* resolveRepoSpec?: (opts: { repoRoot: string, vcs: 'gitlab'|'github' }) => string|undefined,
|
|
@@ -842,8 +852,21 @@ export async function checkCiStatus(opts = {}, deps = {}) {
|
|
|
842
852
|
vcs: forcedVcs,
|
|
843
853
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
844
854
|
now = Date.now(),
|
|
855
|
+
sha: rawSha,
|
|
845
856
|
} = opts;
|
|
846
857
|
|
|
858
|
+
// Validate at the boundary (#1332): the value reaches an API path and an
|
|
859
|
+
// equality match against full SHAs. A short or non-hex SHA would silently
|
|
860
|
+
// match nothing (GitLab) or re-route the request path (GitHub).
|
|
861
|
+
let sha;
|
|
862
|
+
if (rawSha !== undefined) {
|
|
863
|
+
const candidate = typeof rawSha === 'string' ? rawSha.trim().toLowerCase() : '';
|
|
864
|
+
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(candidate)) {
|
|
865
|
+
return degradedResult('query-failed', 'sha must be a full hex commit id');
|
|
866
|
+
}
|
|
867
|
+
sha = candidate;
|
|
868
|
+
}
|
|
869
|
+
|
|
847
870
|
const execFileDep = deps.execFile
|
|
848
871
|
? promisify(deps.execFile)
|
|
849
872
|
: execFileAsync;
|
|
@@ -914,13 +937,13 @@ export async function checkCiStatus(opts = {}, deps = {}) {
|
|
|
914
937
|
'a GitLab remote was detected but its host/project path could not be derived',
|
|
915
938
|
);
|
|
916
939
|
}
|
|
917
|
-
return await checkGitlab(repoRoot, now, { ...depsWithExec, gitlabProject });
|
|
940
|
+
return await checkGitlab(repoRoot, now, { ...depsWithExec, gitlabProject, sha });
|
|
918
941
|
}
|
|
919
942
|
|
|
920
943
|
if (vcs === 'github') {
|
|
921
944
|
const repoSpec = resolveRepoSpecDep({ repoRoot, vcs });
|
|
922
945
|
const repoHost = resolveRepoHostDep({ repoRoot, vcs });
|
|
923
|
-
return await checkGithub(repoRoot, { ...depsWithExec, repoSpec, repoHost });
|
|
946
|
+
return await checkGithub(repoRoot, { ...depsWithExec, repoSpec, repoHost, sha });
|
|
924
947
|
}
|
|
925
948
|
|
|
926
949
|
// Unknown VCS value — silent no-op.
|
|
@@ -63,7 +63,8 @@
|
|
|
63
63
|
|
|
64
64
|
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
65
65
|
import { fileURLToPath } from 'node:url';
|
|
66
|
-
import { basename, resolve } from 'node:path';
|
|
66
|
+
import { basename, resolve, sep } from 'node:path';
|
|
67
|
+
import { homedir } from 'node:os';
|
|
67
68
|
import { resolveInstructionFile } from './common.mjs';
|
|
68
69
|
import { isSessionConfigHeading } from './config/section-extractor.mjs';
|
|
69
70
|
|
|
@@ -317,6 +318,36 @@ export function lintClaudeMd(opts = {}) {
|
|
|
317
318
|
* @param {number} [opts.maxLineChars] forwarded to lintClaudeMd (default DEFAULT_MAX_LINE_CHARS).
|
|
318
319
|
* @returns {{ severity: 'warn', message: string } | null}
|
|
319
320
|
*/
|
|
321
|
+
/**
|
|
322
|
+
* Replace a leading home-directory prefix with the literal `$HOME`.
|
|
323
|
+
*
|
|
324
|
+
* Why `$HOME` and not `~`: the banner emits the path inside DOUBLE QUOTES, and
|
|
325
|
+
* no POSIX shell expands a tilde inside double quotes — `node "~/x.mjs"` fails,
|
|
326
|
+
* `node "$HOME/x.mjs"` works. Why not `path.relative(repoRoot, __filename)`
|
|
327
|
+
* (the first proposal): measured 2026-09-11, it only redacts when the repo and
|
|
328
|
+
* the plugin share a home ancestor —
|
|
329
|
+
* repoRoot `~/Projects/bewerbungs-assistent`, plugin under `~/.claude/plugins`
|
|
330
|
+
* → `../../.claude/plugins/…` (private, 98 chars)
|
|
331
|
+
* repoRoot on a tmp/other volume, plugin under the home dir
|
|
332
|
+
* → `../../../../Users/<name>/.claude/…` (LEAKS, and longer)
|
|
333
|
+
* and it is cwd-bound, which contradicts the `--repo-root` echo whose whole
|
|
334
|
+
* purpose is cwd-independence. `$HOME` collapse is unconditional, cwd-free, and
|
|
335
|
+
* leaks nothing in either case.
|
|
336
|
+
*
|
|
337
|
+
* A path OUTSIDE the home directory is returned unchanged — it carries no
|
|
338
|
+
* username to redact, and rewriting it would break the command.
|
|
339
|
+
*
|
|
340
|
+
* @param {string} p absolute path
|
|
341
|
+
* @returns {string} `p` with a leading `homedir()` replaced by `$HOME`
|
|
342
|
+
*/
|
|
343
|
+
function homeCollapsed(p) {
|
|
344
|
+
const home = homedir();
|
|
345
|
+
if (typeof p !== 'string' || home.length === 0) return p;
|
|
346
|
+
if (p === home) return '$HOME';
|
|
347
|
+
if (p.startsWith(home + sep)) return '$HOME' + p.slice(home.length);
|
|
348
|
+
return p;
|
|
349
|
+
}
|
|
350
|
+
|
|
320
351
|
export function checkClaudeMdBudgetLint(opts = {}) {
|
|
321
352
|
const repoRoot = opts.repoRoot ?? process.cwd();
|
|
322
353
|
|
|
@@ -343,9 +374,28 @@ export function checkClaudeMdBudgetLint(opts = {}) {
|
|
|
343
374
|
if (!result || result.violations.length === 0) return null;
|
|
344
375
|
|
|
345
376
|
const ruleNames = [...new Set(result.violations.map((v) => v.rule))].join(', ');
|
|
377
|
+
// The hint MUST name a path the operator can actually run — `scripts/lib/…`
|
|
378
|
+
// repo-root-relative is only valid inside THIS repo's own checkout. A
|
|
379
|
+
// consumer repo has no such file: it either has no `scripts/lib/` at all,
|
|
380
|
+
// or a foreign one. `__filename` (module-scope, see the CLI section below)
|
|
381
|
+
// is the absolute path of the module that is EXECUTING RIGHT NOW — it is
|
|
382
|
+
// never wrong, because we could not be inside this function otherwise, and
|
|
383
|
+
// it needs no plugin-root lookup at all (no env var, no marketplace-cache
|
|
384
|
+
// scan, no "not resolvable" case to handle — see plugin-update-banner.mjs's
|
|
385
|
+
// module docstring for why guessing a plugin root from an env var is the
|
|
386
|
+
// WRONG move here: `$CLAUDE_PLUGIN_ROOT` can name a checkout that differs
|
|
387
|
+
// from the code that is actually loaded and running). `repoRoot` is echoed
|
|
388
|
+
// back too, so the copied command re-lints the exact file this banner
|
|
389
|
+
// reports on regardless of the operator's cwd when they paste it.
|
|
390
|
+
// PRIVACY (2026-09-11): the same message line already redacts `filePath` via
|
|
391
|
+
// `basename()`, while `__filename` and `repoRoot` went out verbatim — both
|
|
392
|
+
// CP1-shaped (`/Users/<name>/…`) on a personal host. The banner's route into
|
|
393
|
+
// public view is copy-paste into an issue or an agent report, the documented
|
|
394
|
+
// "agent reports carry private slugs" class. `homeCollapsed()` keeps the
|
|
395
|
+
// command EXECUTABLE (so #1302 stays closed) while dropping the username.
|
|
346
396
|
const message =
|
|
347
397
|
`⚠ CLAUDE.md budget lint: ${result.violations.length} violation(s) (${ruleNames}) in ${basename(filePath)} — ` +
|
|
348
|
-
`run \`node
|
|
398
|
+
`run \`node "${homeCollapsed(__filename)}" --repo-root "${homeCollapsed(repoRoot)}" --mode warn\` for details.`;
|
|
349
399
|
|
|
350
400
|
return { severity: 'warn', message };
|
|
351
401
|
}
|
package/scripts/lib/config.mjs
CHANGED
|
@@ -208,7 +208,18 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
|
|
|
208
208
|
_getVal(kv, 'ecosystem-health', undefined) !== undefined
|
|
209
209
|
? _coerceBoolean(kv, 'ecosystem-health', false)
|
|
210
210
|
: (_parseEcosystemHealthBlockEnabled(mdContent) ?? false);
|
|
211
|
-
|
|
211
|
+
// discovery-on-close alone accepts `auto` (= true) and defaults to true when
|
|
212
|
+
// absent: the template recommends `auto` and the 2026-07-29 doc decision made
|
|
213
|
+
// close-time discovery the default. Other booleans stay strict (#1340).
|
|
214
|
+
// Ceiling (BV-004): a one-key tri-state kept inline; revisit (move into
|
|
215
|
+
// coercers.mjs as a shared auto-boolean coercer) when a second key accepts `auto`.
|
|
216
|
+
const discoveryOnCloseRaw = (_getVal(kv, 'discovery-on-close', 'auto')).toLowerCase();
|
|
217
|
+
if (!['true', 'false', 'auto'].includes(discoveryOnCloseRaw)) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`config.mjs: invalid boolean for 'discovery-on-close': '${_getVal(kv, 'discovery-on-close', '')}' (expected true, false or auto)`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
const discoveryOnClose = discoveryOnCloseRaw !== 'false';
|
|
212
223
|
const reasoningOutput = _coerceBoolean(kv, 'reasoning-output', false);
|
|
213
224
|
const groundingCheck = _coerceBoolean(kv, 'grounding-check', true);
|
|
214
225
|
const allowDestructiveOps = _coerceBoolean(kv, 'allow-destructive-ops', false);
|
|
@@ -37,6 +37,7 @@ import path from 'node:path';
|
|
|
37
37
|
import { resolvePluginRoot } from '../common.mjs';
|
|
38
38
|
import { readJsonlFile } from '../io.mjs';
|
|
39
39
|
import { readCanonicalSessions } from '../sessions-canonical.mjs';
|
|
40
|
+
import { isCoordinatorDirectHousekeeping } from '../session-schema/filters.mjs';
|
|
40
41
|
import { buildRunId, CURRENT_STANDARD_VERSION, VALID_MODEL_SOURCES } from './schema.mjs';
|
|
41
42
|
import { resolveSession, computeWindow, findPeerOverlap } from './session-resolve.mjs';
|
|
42
43
|
|
|
@@ -250,7 +251,12 @@ function scoreGateHealth(ctx) {
|
|
|
250
251
|
const totalWaves = typeof ctx.record.total_waves === 'number' ? ctx.record.total_waves : null;
|
|
251
252
|
const wavesEmpty =
|
|
252
253
|
totalWaves === 0 || !Array.isArray(ctx.record.waves) || ctx.record.waves.length === 0;
|
|
253
|
-
|
|
254
|
+
// Since the metrics-collection writer rule (#1321), a session with no
|
|
255
|
+
// dispatched waves is recorded as ONE coordinator-direct Housekeeping wave
|
|
256
|
+
// with total_waves 1 — still "no waves ran" per rubric-v1's clarification.
|
|
257
|
+
// Only that wave SHAPE counts, never session_type: a housekeeping session
|
|
258
|
+
// that ran real waves stays cannot-determine (the pre-registered formula).
|
|
259
|
+
if (wavesEmpty || isCoordinatorDirectHousekeeping(ctx.record)) {
|
|
254
260
|
return {
|
|
255
261
|
id,
|
|
256
262
|
method,
|
|
@@ -30,6 +30,18 @@
|
|
|
30
30
|
* - Overrides always go through writeJsonAtomicSync (tmp + renameSync).
|
|
31
31
|
* - #1284 serializes acquisition and takeover: a stale observation must not
|
|
32
32
|
* replace a different process's newly acquired lock.
|
|
33
|
+
* - #1285 serializes owner-guarded release with takeover: release runs its
|
|
34
|
+
* read → owner-match → unlink under the same `${lock}.acquire` guard, so an
|
|
35
|
+
* old holder's delayed release can never delete the lock a successor took
|
|
36
|
+
* over after the old lease expired.
|
|
37
|
+
*
|
|
38
|
+
* Lease semantics (`staleCheck: 'mtime' | 'heartbeat'`): lease expiry prevents
|
|
39
|
+
* a STUCK lock; it does NOT protect the critical section. After `staleMs` the
|
|
40
|
+
* next acquirer takes over whether or not the old holder is still running, and
|
|
41
|
+
* neither mode renews the lease (`heartbeat` ages from `acquiredAt`, `mtime`
|
|
42
|
+
* from the file's last write). A `not-owner` release under a lease mode means
|
|
43
|
+
* YOUR lease expired during your critical section and a successor may have run
|
|
44
|
+
* concurrently — keep critical sections well below `staleMs`.
|
|
33
45
|
*
|
|
34
46
|
* No external dependencies — Node 20+ stdlib + io.mjs only.
|
|
35
47
|
*/
|
|
@@ -249,6 +261,25 @@ function serializeBody(body, indent) {
|
|
|
249
261
|
return JSON.stringify(body, null, indent) + '\n';
|
|
250
262
|
}
|
|
251
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Create the exclusive `${lockPath}.acquire` sibling guard that serializes every
|
|
266
|
+
* acquisition, takeover and owner-guarded release pass on `lockPath` (#1284,
|
|
267
|
+
* #1285). The caller owns the guard only when `ok` is true and must unlink it
|
|
268
|
+
* in a `finally`; an existing guard is NEVER replaced (see tryAcquireFileLock).
|
|
269
|
+
*
|
|
270
|
+
* @param {string} lockPath
|
|
271
|
+
* @param {string} tmpPrefix — tmp-file prefix; `.acquire` is appended.
|
|
272
|
+
* @returns {{ ok: true } | { ok: false, reason: 'exists' } | { ok: false, reason: 'fs-error', error: string }}
|
|
273
|
+
*/
|
|
274
|
+
function createAcquireGuard(lockPath, tmpPrefix) {
|
|
275
|
+
return createExclusive(`${lockPath}.acquire`, {
|
|
276
|
+
pid: process.pid,
|
|
277
|
+
host: os.hostname(),
|
|
278
|
+
acquiredAt: new Date().toISOString(),
|
|
279
|
+
kind: 'acquisition-guard',
|
|
280
|
+
}, { indent: 2, tmpPrefix: `${tmpPrefix}.acquire` });
|
|
281
|
+
}
|
|
282
|
+
|
|
252
283
|
// ---------------------------------------------------------------------------
|
|
253
284
|
// Exported primitive
|
|
254
285
|
// ---------------------------------------------------------------------------
|
|
@@ -264,13 +295,18 @@ function serializeBody(body, indent) {
|
|
|
264
295
|
* Every acquisition pass owns the exclusive sibling `${lockPath}.acquire`
|
|
265
296
|
* from before create/read through any takeover. This prevents a waiter from
|
|
266
297
|
* reading an old holder, observing its exit, then replacing a newer holder.
|
|
298
|
+
* Owner-guarded releases (releaseFileLock) take the SAME guard for their
|
|
299
|
+
* read → owner-match → unlink pass (#1285), so a release can never unlink a
|
|
300
|
+
* lock that a takeover replaced after the releaser read its own body.
|
|
267
301
|
* All contenders must use this guarded implementation; legacy writers that
|
|
268
302
|
* ignore the sibling guard cannot participate safely in the same protocol.
|
|
269
303
|
*
|
|
270
|
-
* Crash-liveness tradeoff: the guard is held only for
|
|
271
|
-
* not for the caller's critical section. If its owner
|
|
272
|
-
* cleanup fails, the guard remains
|
|
273
|
-
* withFileLock's normal deadline bounds polling
|
|
304
|
+
* Crash-liveness tradeoff: the guard is held only for one synchronous pass
|
|
305
|
+
* (acquire or release), not for the caller's critical section. If its owner
|
|
306
|
+
* dies during a pass or cleanup fails, the guard remains: acquire attempts
|
|
307
|
+
* return `held` immediately (withFileLock's normal deadline bounds polling) and
|
|
308
|
+
* owner-guarded releases return `busy` after their bounded retry, leaving the
|
|
309
|
+
* primary lock to its stale policy. Even a dead-PID or malformed
|
|
274
310
|
* guard is NEVER stolen, because stale-guard replacement would repeat the same
|
|
275
311
|
* race. Recovery requires quiescing every process that can acquire this lock,
|
|
276
312
|
* verifying the guard is abandoned, then explicitly removing only that sibling.
|
|
@@ -303,12 +339,7 @@ function serializeBody(body, indent) {
|
|
|
303
339
|
*/
|
|
304
340
|
export function tryAcquireFileLock(lockPath, opts = {}) {
|
|
305
341
|
const guardPath = `${lockPath}.acquire`;
|
|
306
|
-
const guard =
|
|
307
|
-
pid: process.pid,
|
|
308
|
-
host: os.hostname(),
|
|
309
|
-
acquiredAt: new Date().toISOString(),
|
|
310
|
-
kind: 'acquisition-guard',
|
|
311
|
-
}, { indent: 2, tmpPrefix: `${opts.tmpPrefix ?? '.file.lock'}.acquire` });
|
|
342
|
+
const guard = createAcquireGuard(lockPath, opts.tmpPrefix ?? '.file.lock');
|
|
312
343
|
if (!guard.ok) {
|
|
313
344
|
return guard.reason === 'exists'
|
|
314
345
|
? { acquired: false, reason: 'held', existing: null }
|
|
@@ -418,21 +449,35 @@ function tryAcquireGuardedFileLock(lockPath, opts) {
|
|
|
418
449
|
* match. This reproduces the agent-status / state-lock / staging-fence owner
|
|
419
450
|
* guard (PSA-003: never delete a lock another holder owns).
|
|
420
451
|
*
|
|
452
|
+
* Guaranteed (#1285): the owner-guarded read → owner-match → unlink runs under
|
|
453
|
+
* the same `${lockPath}.acquire` guard as acquisition and takeover, so an old
|
|
454
|
+
* holder's release can never delete a replacement's lock. Without the guard, a
|
|
455
|
+
* holder that read its own body, then paused while its lease expired and a
|
|
456
|
+
* successor took over, unlinked the successor's lock and let a third process
|
|
457
|
+
* acquire beside a live holder. Under `staleCheck: 'mtime' | 'heartbeat'`, a
|
|
458
|
+
* `not-owner` answer means your lease expired during your critical section —
|
|
459
|
+
* see the module header § Lease semantics. The guard is never stolen: when it
|
|
460
|
+
* stays taken past the budget the release returns `busy` and leaves the lock
|
|
461
|
+
* untouched for its stale policy to reclaim.
|
|
462
|
+
*
|
|
421
463
|
* With `ownerGuard: false` the file is unlinked unconditionally, ENOENT
|
|
422
464
|
* ignored — reproducing memory-proposals/store.mjs's `releaseProposalsLock`.
|
|
465
|
+
* That path takes no guard (its callers never run a takeover).
|
|
423
466
|
*
|
|
424
467
|
* @param {string} lockPath
|
|
425
468
|
* @param {object} [opts]
|
|
426
469
|
* @param {string} [opts.holder] — expected holder for the owner guard.
|
|
427
470
|
* @param {boolean} [opts.ownerGuard=true]
|
|
471
|
+
* @param {number} [opts.guardTimeoutMs=1000] — owner-guarded path only: how long
|
|
472
|
+
* to retry for the `.acquire` guard before answering `busy`.
|
|
428
473
|
* @param {(errToken: string) => void} [opts.warn] — sink for unexpected fs
|
|
429
474
|
* errors on the ownerGuard:false path. Receives the raw
|
|
430
475
|
* `err.code ?? err.message` token; the call-site formats the message.
|
|
431
476
|
* @returns {{ ok: true }
|
|
432
|
-
* | { ok: false, reason: 'not-found'|'not-owner'|'fs-error', error?: string }}
|
|
477
|
+
* | { ok: false, reason: 'not-found'|'not-owner'|'busy'|'fs-error', error?: string }}
|
|
433
478
|
*/
|
|
434
479
|
export function releaseFileLock(lockPath, opts = {}) {
|
|
435
|
-
const { holder, ownerGuard = true, warn } = opts;
|
|
480
|
+
const { holder, ownerGuard = true, warn, guardTimeoutMs } = opts;
|
|
436
481
|
|
|
437
482
|
if (ownerGuard === false) {
|
|
438
483
|
// Unconditional unlink; ENOENT ignored. Other fs errors surfaced via warn.
|
|
@@ -448,6 +493,53 @@ export function releaseFileLock(lockPath, opts = {}) {
|
|
|
448
493
|
}
|
|
449
494
|
}
|
|
450
495
|
|
|
496
|
+
// Nothing to release → answer without the guard. Taking it would create the
|
|
497
|
+
// lock's directory (createExclusive mkdirs) and contend with live acquirers
|
|
498
|
+
// for a pass that can never unlink anything. Only ENOENT short-cuts; every
|
|
499
|
+
// other stat error falls through to the guarded read, which reports it.
|
|
500
|
+
try {
|
|
501
|
+
fs.statSync(lockPath);
|
|
502
|
+
} catch (err) {
|
|
503
|
+
if (err.code === 'ENOENT') return { ok: false, reason: 'not-found' };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const budget = typeof guardTimeoutMs === 'number' && guardTimeoutMs >= 0
|
|
507
|
+
? guardTimeoutMs
|
|
508
|
+
: DEFAULT_RELEASE_GUARD_MS;
|
|
509
|
+
const deadline = Date.now() + budget;
|
|
510
|
+
for (;;) {
|
|
511
|
+
const guard = createAcquireGuard(lockPath, '.file.lock');
|
|
512
|
+
if (guard.ok) break;
|
|
513
|
+
if (guard.reason === 'fs-error') return { ok: false, reason: 'fs-error', error: guard.error };
|
|
514
|
+
// Never steal the guard (same rule as acquisition): wait, then give up.
|
|
515
|
+
if (Date.now() >= deadline) return { ok: false, reason: 'busy' };
|
|
516
|
+
sleepSync(RELEASE_GUARD_POLL_MS);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
try {
|
|
520
|
+
return releaseGuardedFileLock(lockPath, holder);
|
|
521
|
+
} finally {
|
|
522
|
+
// Only this pass owns the guard; an unlink failure fails closed exactly
|
|
523
|
+
// like the acquisition path (see tryAcquireFileLock § recovery).
|
|
524
|
+
try { fs.unlinkSync(`${lockPath}.acquire`); } catch { /* fail closed */ }
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Budget for an owner-guarded release to obtain the `.acquire` guard.
|
|
530
|
+
*
|
|
531
|
+
* CEILING (BV-004): a live contender holds the guard for one synchronous pass
|
|
532
|
+
* (a handful of fs syscalls), so the wait is normally sub-millisecond; the full
|
|
533
|
+
* 1000 ms is only spent on an ABANDONED guard (owner crashed mid-pass), where
|
|
534
|
+
* waiting longer cannot help. REVISIT if a `busy` release is ever observed on a
|
|
535
|
+
* host without a crash.
|
|
536
|
+
*/
|
|
537
|
+
const DEFAULT_RELEASE_GUARD_MS = 1000;
|
|
538
|
+
/** Poll cadence while a release waits for the `.acquire` guard. */
|
|
539
|
+
const RELEASE_GUARD_POLL_MS = 5;
|
|
540
|
+
|
|
541
|
+
/** Caller must hold the acquisition guard throughout this synchronous pass. */
|
|
542
|
+
function releaseGuardedFileLock(lockPath, holder) {
|
|
451
543
|
let raw;
|
|
452
544
|
try {
|
|
453
545
|
raw = fs.readFileSync(lockPath, 'utf8');
|
|
@@ -523,6 +615,11 @@ function delay(ms) {
|
|
|
523
615
|
* @param {boolean} [opts.sync=false] — true → synchronous busy-wait poll +
|
|
524
616
|
* synchronous fn (agent-status variant). false → async poll.
|
|
525
617
|
* @param {boolean} [opts.ownerGuard=true] — passed to releaseFileLock.
|
|
618
|
+
* @param {(release: ReturnType<typeof releaseFileLock>) => void} [opts.onRelease]
|
|
619
|
+
* — receives the releaseFileLock result from the finally (#1336). The
|
|
620
|
+
* only reader of `busy` / `not-owner` (a lease that expired mid-`fn`);
|
|
621
|
+
* the return value of withFileLock is unchanged. A throwing callback is
|
|
622
|
+
* swallowed so it can mask neither `value` nor an error thrown by `fn`.
|
|
526
623
|
* @param {...*} [opts.acquireOpts] — remaining keys forwarded to tryAcquireFileLock.
|
|
527
624
|
* @returns {Promise<{ ok: true, value: T }
|
|
528
625
|
* | { ok: false, reason: 'timeout'|'fs-error', error?: string, existing?: object|null }>}
|
|
@@ -534,6 +631,7 @@ export async function withFileLock(lockPath, fn, opts = {}) {
|
|
|
534
631
|
pollMs = 100,
|
|
535
632
|
sync = false,
|
|
536
633
|
ownerGuard = true,
|
|
634
|
+
onRelease,
|
|
537
635
|
...acquireOpts
|
|
538
636
|
} = opts;
|
|
539
637
|
|
|
@@ -567,6 +665,9 @@ export async function withFileLock(lockPath, fn, opts = {}) {
|
|
|
567
665
|
const value = await fn(acquired.body);
|
|
568
666
|
return { ok: true, value };
|
|
569
667
|
} finally {
|
|
570
|
-
releaseFileLock(lockPath, { holder: releaseHolder, ownerGuard });
|
|
668
|
+
const released = releaseFileLock(lockPath, { holder: releaseHolder, ownerGuard });
|
|
669
|
+
if (typeof onRelease === 'function') {
|
|
670
|
+
try { onRelease(released); } catch { /* diagnostic sink — never masks fn */ }
|
|
671
|
+
}
|
|
571
672
|
}
|
|
572
673
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-porcelain.mjs — the ONE parser for `git status --porcelain -z` output.
|
|
3
|
+
*
|
|
4
|
+
* Three consumers used to hand-roll this: `quality-gate.mjs` (hardened),
|
|
5
|
+
* `project-hygiene.mjs` (inline, R/C-aware) and `pre-dispatch-check.mjs`
|
|
6
|
+
* (non-`-z`, hand-unquoting only `\"` and `\\`, so any path carrying a TAB or a
|
|
7
|
+
* non-ASCII byte resolved to a NON-EXISTENT path — #1354). They now share this
|
|
8
|
+
* module.
|
|
9
|
+
*
|
|
10
|
+
* `-z` is not a convenience flag here — it is the only shape of this command
|
|
11
|
+
* whose paths are unambiguous. Measured 2026-08-23 (git 2.53.0) on a fixture
|
|
12
|
+
* carrying a space, a non-ASCII name, a literal `"` and a rename:
|
|
13
|
+
*
|
|
14
|
+
* ```
|
|
15
|
+
* git status --porcelain git status --porcelain -z
|
|
16
|
+
* ------------------------------ ---------------------------------
|
|
17
|
+
* M "scripts/lib/old name.mjs" M scripts/lib/old name.mjs
|
|
18
|
+
* M "scripts/lib/\303\274ml.mjs" M scripts/lib/üml.mjs
|
|
19
|
+
* ?? "scripts/lib/quo\"te.mjs" ?? scripts/lib/quo"te.mjs
|
|
20
|
+
* R old.mjs -> new.mjs R new.mjs \0 old.mjs
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* The non-`-z` form C-quotes any path containing a space, a `"` or — under the
|
|
24
|
+
* default `core.quotePath=true` — a non-ASCII byte. `-c core.quotePath=false`
|
|
25
|
+
* repairs only the non-ASCII third of that (measured: the space and the `"`
|
|
26
|
+
* stayed quoted). A field-splitting parser over the non-`-z` form fails three
|
|
27
|
+
* separate ways on one input — measured `awk '{print $2}'` output for the four
|
|
28
|
+
* lines above: `"scripts/lib/old` (truncated at the space), the undecoded
|
|
29
|
+
* `\303\274` octal escape, and `old.mjs` (the PRE-rename path) for the `R`
|
|
30
|
+
* line. `-z` emits every path verbatim, so there is no unquoting step to get
|
|
31
|
+
* wrong.
|
|
32
|
+
*
|
|
33
|
+
* Rename/copy entries carry their ORIGINAL path as the NEXT NUL field, with NO
|
|
34
|
+
* `XY ` prefix. Consuming that extra field is mandatory, not optional: a naive
|
|
35
|
+
* per-field `slice(3)` would emit `.mjs`-suffixed garbage (`d.mjs` for
|
|
36
|
+
* `old.mjs`) as if it were a real path. `R`/`C` are checked in BOTH status
|
|
37
|
+
* columns because git-status(1) documents `R `/`C ` (renamed/copied in index)
|
|
38
|
+
* as well as ` R`/` C` (renamed/copied in work tree).
|
|
39
|
+
*
|
|
40
|
+
* Ceiling (BV-004): this parses **porcelain v1** (`--porcelain` / `--porcelain=v1`)
|
|
41
|
+
* with `-z`, as emitted by git ≥ 2.x — the `XY <path>` entry shape plus the
|
|
42
|
+
* bare original-path field for `R`/`C`. It also accepts the `!!` entries that
|
|
43
|
+
* `--ignored` adds and the individual-file entries `-uall` produces. It does
|
|
44
|
+
* NOT parse porcelain **v2** (`--porcelain=v2`), whose entries are
|
|
45
|
+
* space-delimited records beginning with `1`/`2`/`u`/`?`/`!`, and it does not
|
|
46
|
+
* decode C-quoting, because `-z` never emits any. Revisit trigger: the first
|
|
47
|
+
* caller that needs v2's per-entry metadata (mode bits, object ids, submodule
|
|
48
|
+
* state) or that must parse output produced without `-z`.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @typedef {Object} PorcelainEntry
|
|
53
|
+
* @property {string} x first status column (index)
|
|
54
|
+
* @property {string} y second status column (work tree)
|
|
55
|
+
* @property {string} status both columns, e.g. `??`, `!!`, `R `, ` M`
|
|
56
|
+
* @property {string} path path as git emitted it (verbatim, never quoted)
|
|
57
|
+
* @property {string|null} original source path for a rename/copy, else `null`
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parse `git status --porcelain -z` stdout into structured entries.
|
|
62
|
+
*
|
|
63
|
+
* Malformed fields are DROPPED rather than guessed at: `XY P` is the shortest
|
|
64
|
+
* well-formed entry, so anything shorter — including the empty trailing field
|
|
65
|
+
* `split` always produces — is not an entry header, and the `[2] === ' '`
|
|
66
|
+
* check rejects a stray original-path field that a malformed stream could
|
|
67
|
+
* leave unconsumed.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} raw — raw stdout of `git status --porcelain -z …`.
|
|
70
|
+
* @returns {PorcelainEntry[]} entries, in git's emission order.
|
|
71
|
+
*/
|
|
72
|
+
export function parsePorcelainEntries(raw) {
|
|
73
|
+
const fields = String(raw ?? '').split('\0');
|
|
74
|
+
/** @type {PorcelainEntry[]} */
|
|
75
|
+
const entries = [];
|
|
76
|
+
for (let i = 0; i < fields.length; i += 1) {
|
|
77
|
+
const entry = fields[i];
|
|
78
|
+
if (typeof entry !== 'string' || entry.length < 4 || entry[2] !== ' ') continue;
|
|
79
|
+
const x = entry[0];
|
|
80
|
+
const y = entry[1];
|
|
81
|
+
let original = null;
|
|
82
|
+
if (x === 'R' || x === 'C' || y === 'R' || y === 'C') {
|
|
83
|
+
i += 1;
|
|
84
|
+
const src = fields[i];
|
|
85
|
+
if (typeof src === 'string' && src) original = src;
|
|
86
|
+
}
|
|
87
|
+
entries.push({ x, y, status: `${x}${y}`, path: entry.slice(3), original });
|
|
88
|
+
}
|
|
89
|
+
return entries;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Parse `git status --porcelain -z` stdout into repo-root-relative paths.
|
|
94
|
+
*
|
|
95
|
+
* Both halves of a rename/copy are kept — a file moved OUT of a watched
|
|
96
|
+
* directory is as much a touch as one moved in, and a fixer needs the old path
|
|
97
|
+
* to make sense of the new one.
|
|
98
|
+
*
|
|
99
|
+
* Untracked DIRECTORIES are not a case this function has to handle when the
|
|
100
|
+
* caller passes `-uall`, which expands them to individual files (measured:
|
|
101
|
+
* `?? nd/` became `?? nd/a.mjs` + `?? nd/b.mjs`).
|
|
102
|
+
*
|
|
103
|
+
* @param {string} raw — raw stdout of `git status --porcelain -z …`.
|
|
104
|
+
* @returns {string[]} repo-root-relative paths, in git's emission order.
|
|
105
|
+
*/
|
|
106
|
+
export function parsePorcelainZ(raw) {
|
|
107
|
+
const paths = [];
|
|
108
|
+
for (const entry of parsePorcelainEntries(raw)) {
|
|
109
|
+
if (entry.path) paths.push(entry.path);
|
|
110
|
+
if (entry.original) paths.push(entry.original);
|
|
111
|
+
}
|
|
112
|
+
return paths;
|
|
113
|
+
}
|