session-orchestrator 3.16.0 → 3.19.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 +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +130 -412
- package/README.md +17 -12
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/docs/README.md +2 -1
- package/docs/USER-GUIDE.md +8 -3
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +444 -0
- package/docs/pi-setup.md +1 -1
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +306 -6
- package/docs/session-config-template.md +66 -3
- package/docs/telemetry/telemetry-claims.md +204 -0
- package/docs/telemetry.md +158 -0
- package/hooks/_lib/guard-source-loader.mjs +467 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +10 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +23 -0
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +525 -160
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/skill-invocation-telemetry.mjs +109 -10
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +16 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/compute-grounding-injection.sh +18 -3
- package/scripts/dialectic-deriver.mjs +7 -2
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/auto-dialectic.mjs +11 -2
- package/scripts/lib/auto-dream.mjs +16 -5
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/build-live-signals.mjs +7 -4
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1013 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/context-coverage.mjs +82 -0
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/moc-staleness.mjs +98 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config/worktree-orphans.mjs +138 -0
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +32 -0
- package/scripts/lib/context-coverage-banner.mjs +223 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +136 -30
- package/scripts/lib/dispatcher/rank.mjs +22 -8
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/moc-staleness-banner.mjs +267 -0
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +23 -4
- package/scripts/lib/reconcile/engine.mjs +147 -39
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +399 -98
- package/scripts/lib/session-close-backfill.mjs +61 -6
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/filters.mjs +88 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/skill-health/join.mjs +35 -9
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/telemetry/anon-id.mjs +141 -0
- package/scripts/lib/telemetry/consent.mjs +299 -0
- package/scripts/lib/telemetry/paths.mjs +27 -0
- package/scripts/lib/telemetry/queue.mjs +287 -0
- package/scripts/lib/telemetry/schema.mjs +384 -0
- package/scripts/lib/telemetry/sync.mjs +312 -0
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-rules.mjs +217 -35
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vault-status/board-writer.mjs +63 -5
- package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +32 -6
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +51 -12
- package/scripts/release.mjs +534 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/telemetry.mjs +250 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +1 -0
- package/skills/gitlab-ops/SKILL.md +20 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +97 -0
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +108 -14
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +2 -1
- package/skills/session-end/plan-verification.md +5 -2
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +149 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/SKILL.md +1 -1
- package/skills/vault-sync/package-lock.json +3 -3
- package/skills/vault-sync/validator.mjs +228 -40
- package/skills/wave-executor/SKILL.md +5 -2
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +163 -10
- package/templates/_shared/loop.md +4 -4
package/package.json
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "session-orchestrator",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.19.0",
|
|
4
4
|
"description": "Loop engineering for AI coding agents — turn ad-hoc sessions into a repeatable research → plan → wave-execute → close loop with verification gates. Runs on Claude Code, Codex CLI, Cursor, and Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://session-orchestrator.com",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"pi-package",
|
|
9
9
|
"claude-code",
|
|
10
|
+
"claude-code-plugin",
|
|
10
11
|
"codex",
|
|
11
12
|
"cursor",
|
|
12
13
|
"ai-agents",
|
|
13
|
-
"
|
|
14
|
+
"agentic-coding",
|
|
15
|
+
"subagents",
|
|
16
|
+
"orchestration",
|
|
17
|
+
"developer-tools",
|
|
18
|
+
"mcp",
|
|
19
|
+
"llm"
|
|
14
20
|
],
|
|
15
21
|
"engines": {
|
|
16
22
|
"node": ">=24.0.0"
|
|
@@ -33,6 +39,7 @@
|
|
|
33
39
|
".codex-plugin/",
|
|
34
40
|
".cursor/",
|
|
35
41
|
"docs/*.md",
|
|
42
|
+
"docs/telemetry/telemetry-claims.md",
|
|
36
43
|
"NOTICE",
|
|
37
44
|
"SECURITY.md",
|
|
38
45
|
"!**/node_modules/**",
|
|
@@ -42,6 +49,7 @@
|
|
|
42
49
|
"test": "node scripts/check-package-manager.mjs && vitest --run",
|
|
43
50
|
"test:coverage": "node scripts/check-package-manager.mjs && vitest --run --coverage",
|
|
44
51
|
"test:watch": "node scripts/check-package-manager.mjs && vitest",
|
|
52
|
+
"quality-gate": "node scripts/run-quality-gate.mjs --variant full-gate",
|
|
45
53
|
"lint": "eslint .",
|
|
46
54
|
"lint:fix": "eslint . --fix",
|
|
47
55
|
"typecheck": "node scripts/typecheck.mjs",
|
|
@@ -73,13 +81,18 @@
|
|
|
73
81
|
"vitest": "^4.1.5"
|
|
74
82
|
},
|
|
75
83
|
"overrides": {
|
|
76
|
-
"vite": "^8.0.16"
|
|
84
|
+
"vite": "^8.0.16",
|
|
85
|
+
"fast-uri": "^3.1.5",
|
|
86
|
+
"brace-expansion": "^5.0.9"
|
|
77
87
|
},
|
|
78
88
|
"license": "MIT",
|
|
79
89
|
"repository": {
|
|
80
90
|
"type": "git",
|
|
81
91
|
"url": "git+https://github.com/Kanevry/session-orchestrator.git"
|
|
82
92
|
},
|
|
93
|
+
"bugs": {
|
|
94
|
+
"url": "https://github.com/Kanevry/session-orchestrator/issues"
|
|
95
|
+
},
|
|
83
96
|
"pi": {
|
|
84
97
|
"extensions": [
|
|
85
98
|
"./pi/extensions/session-orchestrator.ts"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Classify and apply a version bump to a machine-readable contract (JSON Schema, API spec, config schema) — version literals, consumer compatibility, changelog entry, downstream drift
|
|
3
|
+
argument-hint: "[--contract <path>]"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# /contract-version-bump
|
|
7
|
+
|
|
8
|
+
Use the Session Orchestrator command definition at `commands/contract-version-bump.md`.
|
|
9
|
+
|
|
10
|
+
Arguments: $@
|
|
11
|
+
|
|
12
|
+
Read that command file and follow it exactly. When it references `$ARGUMENTS`, substitute the arguments above. Keep all Session Orchestrator platform fallbacks intact.
|
package/rules/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Rules Library (deliverable)
|
|
2
|
+
|
|
3
|
+
This directory is the **deliverable rule library** — the coding-standard and
|
|
4
|
+
discipline rules that *consumer* repos adopt via `/bootstrap --sync-rules`
|
|
5
|
+
(sync logic: [`scripts/lib/rules-sync.mjs`](../scripts/lib/rules-sync.mjs)).
|
|
6
|
+
|
|
7
|
+
It is **not** the always-on rule set of *this* repository — those live in
|
|
8
|
+
[`.claude/rules/`](../.claude/rules/). The two directories share the name
|
|
9
|
+
"rules" but play opposite roles: `rules/` ships rules **out** to other repos,
|
|
10
|
+
`.claude/rules/` is what this repo runs on day-to-day.
|
|
11
|
+
|
|
12
|
+
Every file here carries a
|
|
13
|
+
`<!-- source: session-orchestrator plugin (canonical: rules/<path>) -->` header.
|
|
14
|
+
On sync, plugin-sourced files are overwritten in the consumer while its
|
|
15
|
+
locally-authored rules are preserved (copy-on-write). The canonical manifest of
|
|
16
|
+
what vendors where is [`_index.md`](./_index.md).
|
|
17
|
+
|
|
18
|
+
## Structure
|
|
19
|
+
|
|
20
|
+
- **[`always-on/`](./always-on/)** — vendored to *every* consumer repo, whatever its stack (parallel-session discipline, commit discipline, the quality-gate triad).
|
|
21
|
+
- **[`opt-in-stack/`](./opt-in-stack/)** — vendored only when the consumer's resolved archetype matches (backend, frontend, Swift, web-security, …).
|
|
22
|
+
- **[`opt-in-domain/`](./opt-in-domain/)** — vendored on archetype match for a specific domain concern (e.g. prompt-caching).
|
|
23
|
+
|
|
24
|
+
The optional `[archetypes: …]` tag on an `_index.md` entry is the allowlist that
|
|
25
|
+
decides which repos receive an opt-in file; an untagged entry is universal.
|
|
26
|
+
|
|
27
|
+
## Authoring
|
|
28
|
+
|
|
29
|
+
New or changed rules follow the format in
|
|
30
|
+
[`../docs/rule-authoring.md`](../docs/rule-authoring.md) and must be registered
|
|
31
|
+
in [`_index.md`](./_index.md) to be vendored. Category entries must not share a
|
|
32
|
+
basename — all synced files flatten into the consumer's `.claude/rules/`.
|
|
@@ -42,6 +42,7 @@ import { findProjectRoot, resolveInstructionFile, warn } from './lib/common.mjs'
|
|
|
42
42
|
import { parseSessionConfig } from './lib/config.mjs';
|
|
43
43
|
import { glabRun as defaultGlabRun } from './lib/vault-backfill/glab.mjs';
|
|
44
44
|
import { archiveFileToVault, titleFromMarkdown } from './lib/vault-archive.mjs';
|
|
45
|
+
import { defaultGlabRepo } from './lib/vcs-repo-spec.mjs';
|
|
45
46
|
|
|
46
47
|
const DEFAULT_PRD_DIR = 'docs/prd';
|
|
47
48
|
const DEFAULT_VAULT_SUBDIR = '01-projects/session-orchestrator/prd';
|
|
@@ -57,28 +58,17 @@ function defaultGitRun(gitArgs) {
|
|
|
57
58
|
return { ok: r.status === 0, stdout: r.stdout || '', stderr: r.stderr || '' };
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
* @param {(args: string[]) => { ok: boolean, stdout: string, stderr: string }} gitRunFn
|
|
72
|
-
* @returns {string|undefined}
|
|
73
|
-
*/
|
|
74
|
-
export function defaultGlabRepo(repoRoot, gitRunFn) {
|
|
75
|
-
for (const remote of ['gitlab', 'origin']) {
|
|
76
|
-
const { ok, stdout } = gitRunFn(['-C', repoRoot, 'remote', 'get-url', remote]);
|
|
77
|
-
const url = ok ? stdout.trim() : '';
|
|
78
|
-
if (url) return url;
|
|
79
|
-
}
|
|
80
|
-
return undefined;
|
|
81
|
-
}
|
|
61
|
+
// Best-effort auto-detection of the glab `-R` repo spec from the local git
|
|
62
|
+
// remotes (prefers a `gitlab` helper-remote, else `origin`) — lifted into
|
|
63
|
+
// `scripts/lib/vcs-repo-spec.mjs::defaultGlabRepo` (#839) as the single shared
|
|
64
|
+
// implementation; re-exported here (`export { defaultGlabRepo }` below) so
|
|
65
|
+
// existing callers/tests importing it from this module keep working.
|
|
66
|
+
//
|
|
67
|
+
// This keeps the committed custom-phase command host-agnostic (no private host
|
|
68
|
+
// in CLAUDE.md — owner-leakage/#494) while still resolving the correct host when
|
|
69
|
+
// glab is spawned non-interactively (a bare `glab` spawn ignores the shell
|
|
70
|
+
// wrapper and falls back to the ambient GITLAB_HOST, which may not match).
|
|
71
|
+
export { defaultGlabRepo };
|
|
82
72
|
|
|
83
73
|
// ---------------------------------------------------------------------------
|
|
84
74
|
// Pure helpers
|
|
@@ -17,6 +17,7 @@ import { execFile as execFileCb, spawnSync } from 'node:child_process';
|
|
|
17
17
|
import { promisify } from 'node:util';
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
import fs from 'node:fs';
|
|
20
|
+
import { resolveRepoSpec } from './lib/vcs-repo-spec.mjs';
|
|
20
21
|
|
|
21
22
|
const execFile = promisify(execFileCb);
|
|
22
23
|
|
|
@@ -48,7 +49,9 @@ OPTIONS:
|
|
|
48
49
|
EXIT CODES:
|
|
49
50
|
0 Success
|
|
50
51
|
1 User error (bad flags)
|
|
51
|
-
2 System error (libs/binaries/probe failed)
|
|
52
|
+
2 System error (libs/binaries/probe failed) — or a terminal stop reason
|
|
53
|
+
(kill-switch / spiral / cohort-abort / spawn-error)
|
|
54
|
+
3 no productive work — apply run completed but zero loops did any work
|
|
52
55
|
`;
|
|
53
56
|
|
|
54
57
|
const FLAGS = {
|
|
@@ -274,15 +277,34 @@ function extractRefs(_issue, _field) {
|
|
|
274
277
|
* Fetch the ready-backlog from GitLab via `glab issue list`.
|
|
275
278
|
* Uses execFile (shell: false) per SEC-014 / cli-design rules.
|
|
276
279
|
*
|
|
280
|
+
* Host-pinning (#904): a bare `glab` spawn (no shell wrapper, no `-R`) falls
|
|
281
|
+
* back to the ambient `GITLAB_HOST` config default, which can silently
|
|
282
|
+
* resolve to the WRONG GitLab instance on a multi-host machine. Injecting
|
|
283
|
+
* `GITLAB_HOST` into the spawn env is NOT a fix — it is live-refuted whenever
|
|
284
|
+
* `~/.ssh/config` maps the hostname to a `HostName` IP alias (glab then
|
|
285
|
+
* reports "none of the git remotes ... correspond to the GITLAB_HOST"). Only
|
|
286
|
+
* `-R <spec>` survives that mismatch. See scripts/lib/vcs-repo-spec.mjs
|
|
287
|
+
* docblock; reference idiom: scripts/lib/spiral-carryover.mjs.
|
|
288
|
+
*
|
|
277
289
|
* @param {Function} execFileFn Promisified execFile
|
|
290
|
+
* @param {object} [opts]
|
|
291
|
+
* @param {string} [opts.repoRoot] Repo root to resolve the `-R` spec from (default: process.cwd())
|
|
292
|
+
* @param {'gitlab'|'github'} [opts.vcs] VCS backend (default: 'gitlab')
|
|
293
|
+
* @param {(o: { repoRoot: string, vcs: 'gitlab'|'github' }) => string|undefined} [opts.resolveRepoSpecFn]
|
|
294
|
+
* DI seam for `resolveRepoSpec` (testability).
|
|
278
295
|
* @returns {Promise<Array<{iid: number, title: string, blocks: number[], blockedBy: number[], labels: string[]}>>}
|
|
279
296
|
*/
|
|
280
|
-
async function fetchReadyBacklog(execFileFn) {
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
297
|
+
async function fetchReadyBacklog(execFileFn, opts = {}) {
|
|
298
|
+
const resolveRepoSpecFn = opts.resolveRepoSpecFn ?? resolveRepoSpec;
|
|
299
|
+
const vcs = opts.vcs === 'github' ? 'github' : 'gitlab';
|
|
300
|
+
const spec = resolveRepoSpecFn({ repoRoot: opts.repoRoot ?? process.cwd(), vcs });
|
|
301
|
+
|
|
302
|
+
const args = ['issue', 'list', '--label', 'status:ready', '--per-page', '50', '--output', 'json'];
|
|
303
|
+
// Never emit '-R undefined' — resolveRepoSpec returns undefined when it
|
|
304
|
+
// cannot auto-detect a remote; omit the flag entirely in that case.
|
|
305
|
+
if (spec) args.push('-R', spec);
|
|
306
|
+
|
|
307
|
+
const { stdout } = await execFileFn('glab', args, { shell: false, timeout: 30_000 });
|
|
286
308
|
const issues = JSON.parse(stdout);
|
|
287
309
|
return issues.map((i) => ({
|
|
288
310
|
iid: i.iid ?? i.id,
|
|
@@ -296,11 +318,19 @@ async function fetchReadyBacklog(execFileFn) {
|
|
|
296
318
|
/**
|
|
297
319
|
* Summarise a completed apply run into a canonical result envelope.
|
|
298
320
|
*
|
|
321
|
+
* Success/exit-code semantics (#905): a `reason` that is not a terminal
|
|
322
|
+
* stop-reason (see `terminalReasons`) used to be reported as `success: true`
|
|
323
|
+
* even when every registered loop did zero productive work (e.g. the inner
|
|
324
|
+
* loop fell back to manual mode with 0 iterations completed) — the classic
|
|
325
|
+
* "fallback counts as complete" bug. `success` now additionally requires
|
|
326
|
+
* either an empty backlog (no loops were ever registered — the legitimate
|
|
327
|
+
* "nothing to do" case) or at least one loop that actually completed work.
|
|
328
|
+
*
|
|
299
329
|
* @param {import('./lib/autopilot/multi-killswitch.mjs').LoopRegistration[]} allLoops
|
|
300
330
|
* @param {string} reason
|
|
301
331
|
* @param {number} startMs
|
|
302
332
|
* @param {{ kill: string, detail: string }|null} [killDetail]
|
|
303
|
-
* @returns {{ success: boolean, data: object }}
|
|
333
|
+
* @returns {{ success: boolean, exitCode: number, data: object }}
|
|
304
334
|
*/
|
|
305
335
|
function finalize(allLoops, reason, startMs, killDetail = null) {
|
|
306
336
|
const terminalReasons = new Set([
|
|
@@ -310,15 +340,35 @@ function finalize(allLoops, reason, startMs, killDetail = null) {
|
|
|
310
340
|
'spawn-error',
|
|
311
341
|
STALE_SUBAGENT_MIN,
|
|
312
342
|
]);
|
|
343
|
+
const isTerminal = terminalReasons.has(reason);
|
|
344
|
+
const completed = allLoops.filter((l) => l.status === 'complete').length;
|
|
345
|
+
const failed = allLoops.filter((l) => l.status === 'failed').length;
|
|
346
|
+
const noWork = allLoops.filter((l) => l.status === 'no-work').length;
|
|
347
|
+
const fellBackToManual = allLoops.filter((l) => l.fallbackToManual === true).length;
|
|
348
|
+
|
|
349
|
+
// Empty-backlog regression guard: zero registered loops is a legitimate
|
|
350
|
+
// "nothing to do" outcome, not a "no productive work" outcome — do not
|
|
351
|
+
// conflate the two.
|
|
352
|
+
const success = !isTerminal && (allLoops.length === 0 || completed > 0);
|
|
353
|
+
|
|
354
|
+
// Exit-code triage: success -> 0; a terminal stop-reason -> 2 (unchanged;
|
|
355
|
+
// this is the only way `!success` can coincide with an empty allLoops); a
|
|
356
|
+
// non-terminal reason with >=1 registered loop but zero productive loops
|
|
357
|
+
// -> 3 ("no productive work" — the run completed cleanly but nothing landed).
|
|
358
|
+
const exitCode = success ? 0 : (isTerminal ? 2 : 3);
|
|
359
|
+
|
|
313
360
|
return {
|
|
314
|
-
success
|
|
361
|
+
success,
|
|
362
|
+
exitCode,
|
|
315
363
|
data: {
|
|
316
364
|
reason,
|
|
317
|
-
loopCount:
|
|
318
|
-
completed
|
|
319
|
-
failed
|
|
320
|
-
|
|
321
|
-
|
|
365
|
+
loopCount: allLoops.length,
|
|
366
|
+
completed,
|
|
367
|
+
failed,
|
|
368
|
+
noWork,
|
|
369
|
+
fellBackToManual,
|
|
370
|
+
elapsedMs: Date.now() - startMs,
|
|
371
|
+
killDetail: killDetail ?? null,
|
|
322
372
|
},
|
|
323
373
|
};
|
|
324
374
|
}
|
|
@@ -430,6 +480,8 @@ async function runApplyLoop(state, libs, opts) {
|
|
|
430
480
|
issueIid: issue.iid,
|
|
431
481
|
status: 'running',
|
|
432
482
|
killSwitch: null,
|
|
483
|
+
iterationsCompleted: 0,
|
|
484
|
+
fallbackToManual: false,
|
|
433
485
|
spiralRecoveryCount: 0,
|
|
434
486
|
startedAt: Date.now(),
|
|
435
487
|
lastActivityAt: Date.now(),
|
|
@@ -453,7 +505,20 @@ async function runApplyLoop(state, libs, opts) {
|
|
|
453
505
|
// yet signal "first green test"; wire once runStoryPipeline
|
|
454
506
|
// propagates a firstGreenAt timestamp in StoryResult.
|
|
455
507
|
}).then((result) => {
|
|
456
|
-
|
|
508
|
+
// Three-way classification (#905): a StoryResult with no kill-switch
|
|
509
|
+
// AND zero completed iterations (or an explicit fallback-to-manual
|
|
510
|
+
// flag) is NOT the same as a completed loop — it did no productive
|
|
511
|
+
// work. Conflating the two used to make an empty apply run report
|
|
512
|
+
// false success. See finalize() for the corresponding success/
|
|
513
|
+
// exit-code semantics and multi-killswitch.mjs's LoopRegistration
|
|
514
|
+
// typedef (out of scope here) for the 'queued'|'running'|'complete'|
|
|
515
|
+
// 'failed' status enum this 'no-work' value extends locally.
|
|
516
|
+
registration.iterationsCompleted = result.iterationsCompleted ?? 0;
|
|
517
|
+
registration.fallbackToManual = result.fallbackToManual === true;
|
|
518
|
+
registration.status =
|
|
519
|
+
result.killSwitch ? 'failed'
|
|
520
|
+
: (registration.fallbackToManual || registration.iterationsCompleted === 0) ? 'no-work'
|
|
521
|
+
: 'complete';
|
|
457
522
|
registration.killSwitch = result.killSwitch ?? null;
|
|
458
523
|
completed.add(issue.iid);
|
|
459
524
|
lastCompletionAt = Date.now();
|
|
@@ -493,9 +558,17 @@ async function runApplyLoop(state, libs, opts) {
|
|
|
493
558
|
Promise.race(Array.from(inFlight.values()).map((v) => v.promise.then(() => null))),
|
|
494
559
|
new Promise((r) => setTimeout(r, 5000)),
|
|
495
560
|
]);
|
|
496
|
-
// Sweep completed / failed entries.
|
|
561
|
+
// Sweep completed / failed / no-work entries. 'no-work' MUST be swept
|
|
562
|
+
// here exactly like 'complete'/'failed' — omitting it leaves a settled
|
|
563
|
+
// promise permanently registered as active, which starves
|
|
564
|
+
// shouldStopOrchestrator's "backlog-empty" (activeLoops.length === 0)
|
|
565
|
+
// condition and hangs the orchestrator forever (#905 sweep regression).
|
|
497
566
|
for (const [id, { registration }] of inFlight.entries()) {
|
|
498
|
-
if (
|
|
567
|
+
if (
|
|
568
|
+
registration.status === 'complete' ||
|
|
569
|
+
registration.status === 'failed' ||
|
|
570
|
+
registration.status === 'no-work'
|
|
571
|
+
) {
|
|
499
572
|
inFlight.delete(id);
|
|
500
573
|
}
|
|
501
574
|
}
|
|
@@ -714,7 +787,7 @@ export async function main(argv = process.argv.slice(2), opts = {}) {
|
|
|
714
787
|
} else {
|
|
715
788
|
// Apply path: fetch real issues via glab (execFile, shell: false — SEC-014).
|
|
716
789
|
try {
|
|
717
|
-
issues = await fetchReadyBacklog(execFile);
|
|
790
|
+
issues = await fetchReadyBacklog(execFile, { repoRoot: process.cwd() });
|
|
718
791
|
} catch (err) {
|
|
719
792
|
const msg = `glab issue list failed: ${err.message}`;
|
|
720
793
|
process.stderr.write(`autopilot-multi: ${msg}\n`);
|
|
@@ -783,13 +856,23 @@ export async function main(argv = process.argv.slice(2), opts = {}) {
|
|
|
783
856
|
} else {
|
|
784
857
|
console.log(
|
|
785
858
|
`Multi-story apply complete: ${result.data.reason}` +
|
|
786
|
-
` — ${result.data.completed}/${result.data.loopCount} loops succeeded
|
|
859
|
+
` — ${result.data.completed}/${result.data.loopCount} loops succeeded, ` +
|
|
860
|
+
`${result.data.failed} failed, ${result.data.noWork} did no work` +
|
|
861
|
+
` (${result.data.fellBackToManual} fell back to manual)`,
|
|
787
862
|
);
|
|
788
863
|
}
|
|
789
864
|
|
|
790
|
-
exitFn(result.
|
|
865
|
+
exitFn(result.exitCode);
|
|
791
866
|
}
|
|
792
867
|
|
|
868
|
+
// ---------------------------------------------------------------------------
|
|
869
|
+
// Test-only exports — internal functions exercised directly by
|
|
870
|
+
// tests/scripts/autopilot-multi.test.mjs (no behaviour change on the CLI
|
|
871
|
+
// path; mirrors the export pattern in scripts/archive-closed-prds.mjs).
|
|
872
|
+
// ---------------------------------------------------------------------------
|
|
873
|
+
|
|
874
|
+
export { finalize, fetchReadyBacklog, runApplyLoop };
|
|
875
|
+
|
|
793
876
|
// ---------------------------------------------------------------------------
|
|
794
877
|
// CLI guard — prevents top-level execution on import (deep-2 #368)
|
|
795
878
|
// ---------------------------------------------------------------------------
|
|
@@ -41,12 +41,19 @@ import path from 'node:path';
|
|
|
41
41
|
import { parseArgs } from 'node:util';
|
|
42
42
|
import { fileURLToPath } from 'node:url';
|
|
43
43
|
|
|
44
|
-
import { backfillAbandonedSession } from './lib/session-close-backfill.mjs';
|
|
44
|
+
import { backfillAbandonedSession, isUuid } from './lib/session-close-backfill.mjs';
|
|
45
45
|
import { SO_PROJECT_DIR } from './lib/platform.mjs';
|
|
46
46
|
|
|
47
47
|
const LOCK_ACQUIRED = 'orchestrator.session.lock.acquired';
|
|
48
48
|
const SESSION_STARTED = 'orchestrator.session.started';
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Default cap on how many candidates may reach the (expensive) shared core in a
|
|
52
|
+
* single run. Only the SessionStart path passes a limit; the CLI stays uncapped.
|
|
53
|
+
* See `backfillOnSessionStart` for the latency rationale.
|
|
54
|
+
*/
|
|
55
|
+
export const SESSION_START_LIMIT = 25;
|
|
56
|
+
|
|
50
57
|
/** Read a JSONL file into parsed objects; missing → []; malformed lines skipped. */
|
|
51
58
|
function readJsonl(filePath) {
|
|
52
59
|
let raw;
|
|
@@ -101,6 +108,48 @@ export function planSessions({ repoRoot }) {
|
|
|
101
108
|
return plan;
|
|
102
109
|
}
|
|
103
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Cheap pre-filter (#926): the set of session ids ALREADY present in
|
|
113
|
+
* sessions.jsonl.
|
|
114
|
+
*
|
|
115
|
+
* WHY THIS EXISTS — latency. `backfillAbandonedSession` re-reads the whole
|
|
116
|
+
* events.jsonl on every call whose id cannot be resolved without it. On a
|
|
117
|
+
* mature store that is O(candidates x events-file): measured 1.26 s for 187
|
|
118
|
+
* candidates over a 1.77 MB events.jsonl, of which 183 were already recorded
|
|
119
|
+
* and therefore pure waste. Reading the (much smaller) sessions.jsonl ONCE up
|
|
120
|
+
* front lets us skip those without entering the core at all.
|
|
121
|
+
*
|
|
122
|
+
* This is a pure latency optimisation with NO behavioural change: it reproduces
|
|
123
|
+
* exactly the core's own early-dedupe branch (same `recordId` derivation, same
|
|
124
|
+
* defensive UUID check) and reports the same `skipped-already-recorded` key.
|
|
125
|
+
* Candidates whose id CANNOT be resolved cheaply (a bare UUID with no
|
|
126
|
+
* lock.acquired bridge — the synthetic-id mint needs events) are never
|
|
127
|
+
* pre-filtered; they still go through the core untouched.
|
|
128
|
+
*
|
|
129
|
+
* @param {string} repoRoot
|
|
130
|
+
* @returns {Set<string>}
|
|
131
|
+
*/
|
|
132
|
+
function readRecordedIds(repoRoot) {
|
|
133
|
+
const records = readJsonl(path.join(repoRoot, '.orchestrator', 'metrics', 'sessions.jsonl'));
|
|
134
|
+
const ids = new Set();
|
|
135
|
+
for (const r of records) {
|
|
136
|
+
if (r && typeof r.session_id === 'string') ids.add(r.session_id);
|
|
137
|
+
}
|
|
138
|
+
return ids;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Resolve the sessions.jsonl record id for a planned candidate WITHOUT reading
|
|
143
|
+
* events.jsonl. Mirrors the core's own two-phase resolution: a known semantic
|
|
144
|
+
* id, or a `sessionId` that is already semantic. Returns null when the id can
|
|
145
|
+
* only be resolved from events (bare UUID → lock bridge or synthetic mint).
|
|
146
|
+
*/
|
|
147
|
+
function cheapRecordId({ sessionId, semanticSessionId }) {
|
|
148
|
+
if (semanticSessionId) return semanticSessionId;
|
|
149
|
+
if (sessionId && !isUuid(sessionId)) return sessionId;
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
104
153
|
/**
|
|
105
154
|
* Run the migration over every planned session.
|
|
106
155
|
*
|
|
@@ -108,11 +157,35 @@ export function planSessions({ repoRoot }) {
|
|
|
108
157
|
* module docblock for why. `assumeDeadBeforeMs` is forwarded as-is (null when
|
|
109
158
|
* `--assume-dead-before` was not given).
|
|
110
159
|
*
|
|
111
|
-
* @param {
|
|
160
|
+
* @param {object} args
|
|
161
|
+
* @param {string} args.repoRoot
|
|
162
|
+
* @param {boolean} args.apply
|
|
163
|
+
* @param {number|null} [args.assumeDeadBeforeMs]
|
|
164
|
+
* @param {number|null} [args.limit=null]
|
|
165
|
+
* #926 — cap on how many candidates may reach the shared core. Pre-filtered
|
|
166
|
+
* (already-recorded) candidates do NOT count against it, since they cost
|
|
167
|
+
* nothing. On hitting the cap the run stops early and sets
|
|
168
|
+
* `summary.truncated = true`; the remainder is picked up by the next run.
|
|
169
|
+
* `null` (CLI default) means uncapped.
|
|
170
|
+
* @param {boolean} [args.newestFirst=false]
|
|
171
|
+
* #926 — walk the plan most-recent-first. Load-bearing whenever `limit` is
|
|
172
|
+
* set: a bare-UUID candidate with no lock.acquired bridge cannot be
|
|
173
|
+
* pre-filtered (its synthetic id is only derivable from events), so it always
|
|
174
|
+
* spends a core call just to be told "already recorded". In first-seen order
|
|
175
|
+
* those ancient candidates exhaust the whole budget before the run ever
|
|
176
|
+
* reaches the genuinely-abandoned recent ones. Measured on a copy of this
|
|
177
|
+
* repo's live store: first-seen order → backfilled 0, truncated true.
|
|
112
178
|
* @returns {Promise<object>} aggregate summary
|
|
113
179
|
*/
|
|
114
|
-
export async function runMigration({
|
|
115
|
-
|
|
180
|
+
export async function runMigration({
|
|
181
|
+
repoRoot,
|
|
182
|
+
apply,
|
|
183
|
+
assumeDeadBeforeMs = null,
|
|
184
|
+
limit = null,
|
|
185
|
+
newestFirst = false,
|
|
186
|
+
}) {
|
|
187
|
+
const planned = planSessions({ repoRoot });
|
|
188
|
+
const plan = newestFirst ? [...planned].reverse() : planned;
|
|
116
189
|
const summary = {
|
|
117
190
|
repoRoot,
|
|
118
191
|
mode: apply ? 'apply' : 'dry-run',
|
|
@@ -124,6 +197,10 @@ export async function runMigration({ repoRoot, apply, assumeDeadBeforeMs = null
|
|
|
124
197
|
skipped: {},
|
|
125
198
|
};
|
|
126
199
|
|
|
200
|
+
// Pre-filter pass — costs ONE sessions.jsonl read for the whole run.
|
|
201
|
+
const recordedIds = readRecordedIds(repoRoot);
|
|
202
|
+
let considered = 0;
|
|
203
|
+
|
|
127
204
|
// Dry-run has no incremental sessions.jsonl write, so two started-UUIDs that
|
|
128
205
|
// bridge to the SAME semantic id (a session that cleared/compacted mid-run)
|
|
129
206
|
// would both report 'would-backfill' and over-count. Track projected ids
|
|
@@ -131,6 +208,24 @@ export async function runMigration({ repoRoot, apply, assumeDeadBeforeMs = null
|
|
|
131
208
|
const projected = new Set();
|
|
132
209
|
|
|
133
210
|
for (const item of plan) {
|
|
211
|
+
// -- Cheap pre-filter: already recorded → skip WITHOUT entering the core --
|
|
212
|
+
const known = cheapRecordId(item);
|
|
213
|
+
if (
|
|
214
|
+
(known !== null && recordedIds.has(known)) ||
|
|
215
|
+
(isUuid(item.sessionId) && recordedIds.has(item.sessionId))
|
|
216
|
+
) {
|
|
217
|
+
summary.skipped['skipped-already-recorded'] =
|
|
218
|
+
(summary.skipped['skipped-already-recorded'] ?? 0) + 1;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// -- Latency cap (#926, SessionStart path only) --------------------------
|
|
223
|
+
if (typeof limit === 'number' && considered >= limit) {
|
|
224
|
+
summary.truncated = true;
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
considered += 1;
|
|
228
|
+
|
|
134
229
|
const res = await backfillAbandonedSession({
|
|
135
230
|
repoRoot,
|
|
136
231
|
sessionId: item.sessionId,
|
|
@@ -143,6 +238,10 @@ export async function runMigration({ repoRoot, apply, assumeDeadBeforeMs = null
|
|
|
143
238
|
case 'backfilled':
|
|
144
239
|
summary.backfilled += 1;
|
|
145
240
|
if (res.deadByAge) summary.dead_by_age += 1;
|
|
241
|
+
// Keep the pre-filter snapshot in step with what we just wrote, so a
|
|
242
|
+
// second candidate bridging to the SAME semantic id is skipped cheaply
|
|
243
|
+
// instead of re-entering the core (which would reach the same verdict).
|
|
244
|
+
if (typeof res.sessionId === 'string') recordedIds.add(res.sessionId);
|
|
146
245
|
break;
|
|
147
246
|
case 'would-backfill':
|
|
148
247
|
if (projected.has(res.sessionId)) {
|
|
@@ -164,6 +263,63 @@ export async function runMigration({ repoRoot, apply, assumeDeadBeforeMs = null
|
|
|
164
263
|
return summary;
|
|
165
264
|
}
|
|
166
265
|
|
|
266
|
+
/**
|
|
267
|
+
* SessionStart entry point (#926) — decouple the backfill from the /close path.
|
|
268
|
+
*
|
|
269
|
+
* THE BUG THIS FIXES: `hooks/on-session-end.mjs` calls the backfill correctly,
|
|
270
|
+
* but SessionEnd only fires on a REGULAR close. A session killed by Ctrl-C, a
|
|
271
|
+
* timeout, or a crash leaves no ledger entry at all, and the backfill then waits
|
|
272
|
+
* for the NEXT clean close — which may never come. Observed on this repo:
|
|
273
|
+
* sessions.jsonl 18.9 h behind events.jsonl with 8 commits and 0 records.
|
|
274
|
+
* Running at SessionStart closes the loop: the PREVIOUS abandoned session is
|
|
275
|
+
* reconstructed by the NEXT session's start, whatever killed it.
|
|
276
|
+
*
|
|
277
|
+
* ── SAFETY (the two ways this could do damage) ───────────────────────────────
|
|
278
|
+
* (1) NEVER record the CURRENTLY-STARTING session as abandoned. Two
|
|
279
|
+
* independent defences:
|
|
280
|
+
* a. STRUCTURAL — the caller invokes this BEFORE emitting
|
|
281
|
+
* `orchestrator.session.started`, so this session is not yet in
|
|
282
|
+
* events.jsonl and therefore not a candidate at all (`planSessions`
|
|
283
|
+
* enumerates started-events only).
|
|
284
|
+
* b. GUARD — on a clear/compact/resume re-fire, an EARLIER
|
|
285
|
+
* `session.started` for the same logical session IS present. It
|
|
286
|
+
* resolves to our own semantic id, whose lock is live, so the core's
|
|
287
|
+
* `skipped-own-live-lock` branch (#863) rejects it.
|
|
288
|
+
* (2) NEVER record a RUNNING FOREIGN session as abandoned. The core evaluates
|
|
289
|
+
* lock ownership against the CANDIDATE (not the running process): a
|
|
290
|
+
* candidate that holds a live lock returns `skipped-own-live-lock` BEFORE
|
|
291
|
+
* the dead-by-age relaxation is ever consulted. Verified against a fixture
|
|
292
|
+
* where the live lock holder is >4 h stale and thus relaxation-eligible.
|
|
293
|
+
*
|
|
294
|
+
* Residual risk, stated explicitly: a session that is live but does NOT hold
|
|
295
|
+
* the lock (it lost the acquire race — `bootstrapLock` leaves the foreign lock
|
|
296
|
+
* in place and bails) AND has emitted no event for longer than the lock TTL
|
|
297
|
+
* (`DEFAULT_TTL_HOURS` = 4 h) could still be relaxed past. That candidate is
|
|
298
|
+
* one the system's OWN liveness model already considers dead, since its lock
|
|
299
|
+
* would have expired; we deliberately reuse that same TTL rather than inventing
|
|
300
|
+
* a second notion of liveness.
|
|
301
|
+
*
|
|
302
|
+
* Never throws — a backfill failure must NEVER block a session start.
|
|
303
|
+
*
|
|
304
|
+
* @param {object} args
|
|
305
|
+
* @param {string} args.repoRoot
|
|
306
|
+
* @param {number|null} [args.limit=SESSION_START_LIMIT]
|
|
307
|
+
* @returns {Promise<object|null>} the summary, or null when disabled/failed
|
|
308
|
+
*/
|
|
309
|
+
export async function backfillOnSessionStart({ repoRoot, limit = SESSION_START_LIMIT } = {}) {
|
|
310
|
+
try {
|
|
311
|
+
// Escape hatch for operators who never want ledger writes at session start.
|
|
312
|
+
if (process.env.SO_DISABLE_STARTUP_BACKFILL === '1') return null;
|
|
313
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) return null;
|
|
314
|
+
// newestFirst is mandatory here — see runMigration's param docs for the
|
|
315
|
+
// measured failure (budget burned on ancient already-recorded candidates).
|
|
316
|
+
return await runMigration({ repoRoot, apply: true, limit, newestFirst: true });
|
|
317
|
+
} catch {
|
|
318
|
+
// Swallowed by contract — see the docblock. The hook is informational-only.
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
167
323
|
function renderHuman(summary) {
|
|
168
324
|
const lines = [];
|
|
169
325
|
lines.push(`Backfill abandoned sessions — ${summary.mode}`);
|
|
@@ -86,7 +86,16 @@ strip_fences "$CLAUDE" > "$CLAUDE_NOFENCE"
|
|
|
86
86
|
# ---------------------------------------------------------------------------
|
|
87
87
|
# Check 3: live `## Session Config` present in CLAUDE.md
|
|
88
88
|
# ---------------------------------------------------------------------------
|
|
89
|
-
|
|
89
|
+
# The pattern is EXACT, mirroring isSessionConfigHeading() in
|
|
90
|
+
# scripts/lib/config/section-extractor.mjs (the SSOT) — CR tolerated for CRLF
|
|
91
|
+
# checkouts, nothing else. Shell cannot import the JS predicate, so the
|
|
92
|
+
# alignment is by hand and this comment names the authority. It was
|
|
93
|
+
# `[[:space:]]*$` until #968, which accepted `## Session Config␠` — a heading
|
|
94
|
+
# the runtime parser rejects. That made this check, whose entire purpose is to
|
|
95
|
+
# report whether the runtime can find the block, answer "present" for the one
|
|
96
|
+
# state it exists to catch.
|
|
97
|
+
CR=$(printf '\r')
|
|
98
|
+
if ! grep -qE "^## Session Config${CR}?\$" "$CLAUDE"; then
|
|
90
99
|
emit "$CLAUDE:1" "live-config-missing" "## Session Config heading not found (skills/_shared/config-reading.md depends on it)"
|
|
91
100
|
fi
|
|
92
101
|
|
|
@@ -94,6 +103,13 @@ fi
|
|
|
94
103
|
# Check 1: H2 parity (CLAUDE.md → README.md)
|
|
95
104
|
# Canonical runtime-only headings live in CLAUDE.md exclusively and are exempt.
|
|
96
105
|
# ---------------------------------------------------------------------------
|
|
106
|
+
# NOT tightened to the SSOT (#968), deliberately. This is a README↔CLAUDE H2
|
|
107
|
+
# PARITY-exemption list, not a comparator against "can the runtime read the
|
|
108
|
+
# Session Config block" — four of its five entries have no SSOT at all, and a
|
|
109
|
+
# heading it wrongly exempts costs a missing parity warning, never a silent
|
|
110
|
+
# config fallback. Check 3 above is the site that answers the runtime question,
|
|
111
|
+
# and that one is exact. Tightening only the `Session Config` alternative would
|
|
112
|
+
# make section-extractor.mjs the authority for a fact it does not govern.
|
|
97
113
|
EXEMPT_HEADINGS_RE='^## (Structure|Destructive-Command Guard|Agent Authoring Rules|Current State|Session Config)[[:space:]]*$'
|
|
98
114
|
|
|
99
115
|
# Extract README H2 set (one per line, normalized — heading text without the leading `## `)
|
|
@@ -54,10 +54,25 @@ SESSION_ID="${SESSION_ID:-}"
|
|
|
54
54
|
WAVE="${WAVE:-0}"
|
|
55
55
|
AGENT_TYPE="${AGENT_TYPE:-}"
|
|
56
56
|
|
|
57
|
-
# --- Build list of last 3 session_ids from sessions.jsonl ---
|
|
58
|
-
|
|
57
|
+
# --- Build list of last 3 REAL session_ids from sessions.jsonl ---
|
|
58
|
+
|
|
59
|
+
# Filter out phantom `status: 'abandoned'` stubs (#834, session-close-backfill
|
|
60
|
+
# — 0 waves, seconds of runtime) BEFORE taking the tail. Otherwise a recent
|
|
61
|
+
# phantom can evict the one real session carrying the stagnation evidence,
|
|
62
|
+
# silently suppressing grounding injection for a genuinely stagnating file.
|
|
63
|
+
#
|
|
64
|
+
# `-R` (raw-input) + `fromjson?` parses each line individually and SKIPS
|
|
65
|
+
# unparseable ones instead of aborting the whole stream — plain
|
|
66
|
+
# `jq -c 'select(...)'` aborts at the FIRST malformed line (jq: parse error,
|
|
67
|
+
# exit 5). sessions.jsonl is append-only from multiple writers, so a torn
|
|
68
|
+
# write earlier in the file must not silently starve LAST_SESSIONS down to
|
|
69
|
+
# "[]" (fail-closed-empty is still wrong here, just a quieter wrong than the
|
|
70
|
+
# stale-session-id case this fixes) — the whole point is that corruption
|
|
71
|
+
# anywhere in the file no longer poisons the tail. Mirrors the per-line
|
|
72
|
+
# try/catch behaviour of the .mjs path (scripts/lib/session-schema/filters.mjs).
|
|
59
73
|
# Extract session_id values (skip entries without one, skip empty lines)
|
|
60
|
-
LAST_SESSIONS=$(
|
|
74
|
+
LAST_SESSIONS=$(jq -R -c 'fromjson? | select(.status != "abandoned")' "$SESSIONS_JSONL" 2>/dev/null \
|
|
75
|
+
| tail -n 3 \
|
|
61
76
|
| jq -r 'select(.session_id != null and .session_id != "") | .session_id' 2>/dev/null \
|
|
62
77
|
| jq -R -s 'split("\n") | map(select(length > 0))' 2>/dev/null) || LAST_SESSIONS="[]"
|
|
63
78
|
|