session-orchestrator 5.3.0 → 5.5.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/claude-md-drift-check/SKILL.md +1 -1
- package/.agents/skills/test-audit/SKILL.md +21 -0
- package/.agents/skills/test-audit/agents/openai.yaml +5 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
- package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
- package/.cursor/commands/test-audit.md +14 -0
- package/.cursor/rules/060-evolve.mdc +28 -13
- package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
- package/.cursor/skills/test-audit/SKILL.md +12 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +1 -1
- package/AGENTS.md +9 -31
- package/CHANGELOG.md +162 -0
- package/README.md +14 -10
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +10 -1
- package/docs/ci-setup.md +154 -4
- package/docs/components.md +11 -11
- package/docs/events-schema.md +25 -19
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/persona-panel.md +5 -4
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/rule-authoring.md +27 -12
- package/docs/scope-collision-guard.md +40 -16
- package/docs/session-config-reference.md +61 -12
- package/docs/session-config-template.md +11 -2
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +42 -29
- package/hooks/_lib/lock-bootstrap.mjs +118 -26
- package/hooks/config-protection.mjs +1 -1
- package/hooks/enforce-commands.mjs +28 -13
- package/hooks/enforce-scope.mjs +201 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks-pi.json +5 -0
- package/hooks/hooks.json +6 -1
- package/hooks/on-session-start.mjs +57 -2
- package/hooks/on-stop.mjs +70 -168
- package/hooks/post-bash-write-verify.mjs +98 -17
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +32 -152
- package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
- package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
- package/hooks/pre-task-scope-disjoint.mjs +1143 -279
- package/hooks/subagent-telemetry.mjs +96 -19
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/pi/prompts/test-audit.md +12 -0
- package/rules/README.md +6 -1
- package/rules/_index.md +3 -3
- package/rules/always-on/bash-harness-pitfalls.md +7 -1
- package/rules/opt-in-domain/prompt-caching.md +5 -1
- package/rules/opt-in-stack/backend-data.md +9 -1
- package/rules/opt-in-stack/backend.md +7 -1
- package/rules/opt-in-stack/frontend.md +6 -1
- package/rules/opt-in-stack/security-web.md +7 -1
- package/rules/opt-in-stack/swift.md +5 -1
- package/scripts/apply-session-learnings.mjs +513 -0
- package/scripts/archive-closed-prds.mjs +40 -12
- package/scripts/autopilot.mjs +57 -54
- package/scripts/backfill-abandoned-sessions.mjs +81 -6
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/baseline-archetypes.mjs +1 -1
- package/scripts/check-package-manager.mjs +62 -5
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +47 -6
- package/scripts/emit-session.mjs +298 -2
- package/scripts/export-hw-learnings.mjs +40 -10
- package/scripts/fleet-checkin.mjs +170 -0
- package/scripts/lib/agent-frontmatter.mjs +3 -3
- package/scripts/lib/auto-dream.mjs +7 -6
- package/scripts/lib/autopilot/flags.mjs +6 -1
- package/scripts/lib/autopilot/loop.mjs +32 -13
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/baseline-archetypes.mjs +41 -22
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/host-paths.mjs +79 -0
- package/scripts/lib/config/persona-gate-wave.mjs +3 -3
- package/scripts/lib/config/reaper.mjs +7 -6
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config-schema.mjs +13 -0
- package/scripts/lib/config.mjs +75 -10
- package/scripts/lib/eval/engine.mjs +13 -0
- package/scripts/lib/events-retention-banner.mjs +393 -0
- package/scripts/lib/events-rotation.mjs +21 -6
- package/scripts/lib/events.mjs +72 -5
- package/scripts/lib/fleet-protocol.mjs +288 -0
- package/scripts/lib/gates/gate-baseline.mjs +13 -3
- package/scripts/lib/gates/gate-full.mjs +7 -2
- package/scripts/lib/gates/gate-helpers.mjs +93 -2
- package/scripts/lib/gates/gate-incremental.mjs +23 -22
- package/scripts/lib/gates/gate-per-file.mjs +16 -7
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +77 -11
- package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
- package/scripts/lib/learnings/io.mjs +274 -37
- package/scripts/lib/learnings/schema.mjs +163 -15
- package/scripts/lib/learnings/sizing-subject.mjs +2 -2
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/memory-proposals/schema.mjs +11 -5
- package/scripts/lib/named-baseline-resolver.mjs +53 -28
- package/scripts/lib/named-vault-resolver.mjs +147 -5
- package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
- package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
- package/scripts/lib/orphan-reaper/scan.mjs +510 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +47 -1315
- package/scripts/lib/owner-yaml.mjs +104 -6
- package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +134 -22
- package/scripts/lib/project-hygiene.mjs +21 -1
- package/scripts/lib/quality-gate.mjs +45 -5
- package/scripts/lib/resource-probe.mjs +1 -1
- package/scripts/lib/rule-loader.mjs +100 -62
- package/scripts/lib/rules-sync.mjs +27 -12
- package/scripts/lib/scope-baseline.mjs +119 -29
- package/scripts/lib/scope-echo.mjs +7 -0
- package/scripts/lib/scope-gate.mjs +194 -27
- package/scripts/lib/session-close-backfill.mjs +129 -4
- package/scripts/lib/session-end/tail-runner.mjs +10 -5
- package/scripts/lib/session-lock-shape.mjs +6 -3
- package/scripts/lib/session-lock.mjs +320 -67
- package/scripts/lib/session-record-repair.mjs +183 -26
- package/scripts/lib/session-registry.mjs +67 -17
- package/scripts/lib/session-schema/constants.mjs +30 -4
- package/scripts/lib/session-schema/validator.mjs +28 -1
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +252 -54
- package/scripts/lib/sessions-canonical.mjs +100 -9
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/sh-quote.mjs +29 -0
- package/scripts/lib/subagents-schema.mjs +30 -3
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +121 -71
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +215 -23
- package/scripts/lib/tmux-layout/layouts.mjs +1 -11
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
- package/scripts/lib/ux-grill/reconcile.mjs +1 -1
- package/scripts/lib/validate/check-agents.mjs +1 -1
- package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +1 -1
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/validate-vendored-rules.mjs +206 -81
- package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
- package/scripts/lib/vault-mirror/namespace.mjs +47 -26
- package/scripts/lib/vault-mirror/process.mjs +350 -62
- package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
- package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
- package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
- package/scripts/lib/vault-status/board-writer.mjs +53 -12
- package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
- package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
- package/scripts/lib/wave-resource-gate.mjs +34 -8
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/memory-propose.mjs +18 -5
- package/scripts/release-session-lock.mjs +2 -1
- package/scripts/release.mjs +11 -3
- package/scripts/repair-invalid-sessions.mjs +37 -4
- package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
- package/scripts/run-quality-gate.mjs +331 -307
- package/scripts/self-update.mjs +229 -0
- package/scripts/site-numbers.mjs +163 -5
- package/scripts/sweep-expired-learnings.mjs +603 -20
- package/scripts/sweep-expired-rules.mjs +2 -1
- package/scripts/validate-plugin.mjs +7 -0
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-integration-watcher.mjs +2 -1
- package/scripts/vault-mirror.mjs +238 -75
- package/scripts/wave-scope-binding.mjs +2 -1
- package/skills/_shared/config-reading.md +3 -3
- package/skills/_shared/fleet-protocol.md +134 -0
- package/skills/_shared/state-ownership.md +41 -12
- package/skills/autopilot/SKILL.md +25 -11
- package/skills/bootstrap/SKILL.md +6 -4
- package/skills/bootstrap/_shared-template.md +97 -2
- package/skills/bootstrap/fast-template.md +4 -1
- package/skills/bootstrap/private-contract.md +2 -1
- package/skills/bootstrap/public-fallback.md +6 -3
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/claude-md-drift-check/SKILL.md +10 -10
- package/skills/claude-md-drift-check/checker.mjs +26 -18
- package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
- package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
- package/skills/discovery/probes/vault-staleness.mjs +9 -3
- package/skills/docs-orchestrator/SKILL.md +12 -5
- package/skills/docs-orchestrator/audience-mapping.md +5 -3
- package/skills/evolve/SKILL.md +13 -4
- package/skills/evolve/references/evolve-analyze-mode.md +42 -18
- package/skills/memory-cleanup/SKILL.md +1 -1
- package/skills/persona-panel/SKILL.md +2 -2
- package/skills/persona-panel/persona-format.md +2 -2
- package/skills/persona-panel/presets/designer-lens.md +1 -1
- package/skills/persona-panel/presets/engineer-lens.md +1 -1
- package/skills/persona-panel/presets/pm-lens.md +1 -1
- package/skills/plan/mode-retro.md +19 -4
- package/skills/quality-gates/SKILL.md +6 -4
- package/skills/remote-offload/SKILL.md +4 -2
- package/skills/session-end/learning-patterns.md +23 -13
- package/skills/session-end/metrics-collection.md +6 -7
- package/skills/session-end/phase-3-2-docs-verification.md +7 -6
- package/skills/session-end/plan-verification.md +25 -9
- package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
- package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
- package/skills/session-end/references/session-summary-template.md +1 -1
- package/skills/session-end/session-metrics-write.md +65 -68
- package/skills/session-plan/SKILL.md +0 -3
- package/skills/session-start/SKILL.md +8 -2
- package/skills/session-start/references/operations-contract.md +6 -0
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
- package/skills/test-audit/SKILL.md +152 -0
- package/skills/test-audit/references/campaign.md +111 -0
- package/skills/test-audit/references/mutate.sh +50 -0
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +2 -2
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
- package/skills/wave-executor/references/wave-loop-review.md +4 -6
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/templates/personas/README.md +5 -5
- package/templates/personas/accounting-compliance.v1.md +1 -1
- package/templates/personas/accounting-tax-advisor.v1.md +1 -1
- package/templates/personas/buyer-p1-cto.v1.md +1 -1
- package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
- package/templates/personas/buyer-p3-build.v1.md +1 -1
- package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
- package/templates/personas/buyer-p5-solo.v1.md +1 -1
- package/templates/personas/buyer-p6-ld.v1.md +1 -1
- package/templates/personas/klima-ai-expert.v1.md +1 -1
- package/templates/personas/klima-physicist.v1.md +1 -1
|
@@ -25,11 +25,11 @@
|
|
|
25
25
|
* ttl_hours: number,
|
|
26
26
|
* }
|
|
27
27
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
28
|
+
* scripts/lib/session-lock.mjs `buildLock()` has written `last_heartbeat`
|
|
29
|
+
* itself since Epic #583 W2-I3 (`last_heartbeat` = `started_at` at genesis).
|
|
30
|
+
* This helper's tmp+rename overlay therefore re-writes an identical
|
|
31
|
+
* `last_heartbeat`, and its remaining job is `semantic_session_id`, which
|
|
32
|
+
* `acquire()` / `forceAcquire()` are not given here.
|
|
33
33
|
*
|
|
34
34
|
* @module hooks/_lib/lock-bootstrap
|
|
35
35
|
*/
|
|
@@ -55,6 +55,10 @@ import { writeJsonAtomicSync } from '../../scripts/lib/io.mjs';
|
|
|
55
55
|
*/
|
|
56
56
|
export const SAME_LOGICAL_SESSION_SOURCES = new Set(['resume', 'clear', 'compact']);
|
|
57
57
|
|
|
58
|
+
/** Re-reads of a just-acquired lock that answers ENOENT, and the pause between them (#1494). */
|
|
59
|
+
const ENRICH_READ_RETRIES = 5;
|
|
60
|
+
const ENRICH_READ_RETRY_MS = 10;
|
|
61
|
+
|
|
58
62
|
/**
|
|
59
63
|
* Bootstrap the session.lock for this hook invocation.
|
|
60
64
|
*
|
|
@@ -189,6 +193,13 @@ export async function bootstrapLock({
|
|
|
189
193
|
// session-lock.mjs; matching only the new one keeps this force-branch
|
|
190
194
|
// reachable.
|
|
191
195
|
acquireResult.reason === 'stale-heartbeat' ||
|
|
196
|
+
// A lock file that is not a lock record (invalid JSON or not the lock
|
|
197
|
+
// shape) can never be a live lock — every writer writes atomically — so
|
|
198
|
+
// it is reclaimed like a stale one, with one stderr WARN below. Before
|
|
199
|
+
// acquire() reported 'corrupt' it answered 'active' / existingLock:null,
|
|
200
|
+
// which this predicate could never force: one damaged file wedged every
|
|
201
|
+
// new session in the repo.
|
|
202
|
+
acquireResult.reason === 'corrupt' ||
|
|
192
203
|
(acquireResult.reason === 'active' &&
|
|
193
204
|
acquireResult.existingLock &&
|
|
194
205
|
(acquireResult.existingLock.session_id === sessionId ||
|
|
@@ -201,12 +212,41 @@ export async function bootstrapLock({
|
|
|
201
212
|
acquireResult.existingLock.session_id === predecessorSessionId)))
|
|
202
213
|
);
|
|
203
214
|
|
|
215
|
+
// Recorded on the acquired event below, so a takeover of a stale or corrupt
|
|
216
|
+
// lock is countable — its stderr WARN is discarded by the harness (#1401).
|
|
217
|
+
let reclaimed = null;
|
|
204
218
|
if (!acquireResult.ok && shouldForce) {
|
|
219
|
+
const reclaimingCorrupt = acquireResult.reason === 'corrupt';
|
|
220
|
+
const forcedFrom = acquireResult.reason;
|
|
221
|
+
// A session resuming its OWN stale lock (same raw id after > ttl idle) is
|
|
222
|
+
// a refresh, not a takeover — counting it would inflate the very figure
|
|
223
|
+
// `reclaimed` exists to make countable.
|
|
224
|
+
const priorOwner = acquireResult.existingLock?.session_id;
|
|
205
225
|
try {
|
|
206
|
-
|
|
226
|
+
// A stale or corrupt lock is reclaimed single-winner (#1494): two starts
|
|
227
|
+
// that both saw it used to force it, both got ok:true, and the later
|
|
228
|
+
// rename won. With onlyIfNotLive the loser gets the winner's live lock
|
|
229
|
+
// back as 'active' and bails into the conflict signal below. The same-
|
|
230
|
+
// session refresh of a LIVE lock keeps the unconditional overwrite.
|
|
231
|
+
const reclaim = forcedFrom === 'stale-heartbeat' || forcedFrom === 'corrupt';
|
|
232
|
+
acquireResult = forceAcquireFn({ sessionId, mode, ttlHours, repoRoot, ...(reclaim ? { onlyIfNotLive: true } : {}) });
|
|
207
233
|
} catch {
|
|
208
234
|
return null;
|
|
209
235
|
}
|
|
236
|
+
if (
|
|
237
|
+
acquireResult?.ok === true &&
|
|
238
|
+
(forcedFrom === 'corrupt' || (forcedFrom === 'stale-heartbeat' && priorOwner !== sessionId))
|
|
239
|
+
) {
|
|
240
|
+
reclaimed = forcedFrom;
|
|
241
|
+
}
|
|
242
|
+
if (reclaimingCorrupt) {
|
|
243
|
+
// The file's content is not echoed: it is unbounded and may be anything.
|
|
244
|
+
process.stderr.write(
|
|
245
|
+
acquireResult && acquireResult.ok === true
|
|
246
|
+
? '⚠ lock-bootstrap: .orchestrator/session.lock was not a valid lock record — reclaimed for this session\n'
|
|
247
|
+
: `⚠ lock-bootstrap: .orchestrator/session.lock is not a valid lock record and could not be reclaimed (${acquireResult?.reason ?? 'unknown'}) — this session runs without a lock\n`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
210
250
|
}
|
|
211
251
|
|
|
212
252
|
// Any other non-ok reason (parallel-conflict, fs-error, other-session-active)
|
|
@@ -221,6 +261,20 @@ export async function bootstrapLock({
|
|
|
221
261
|
// failure is swallowed and the bail proceeds. The return contract is unchanged —
|
|
222
262
|
// bootstrapLock STILL returns null on this path.
|
|
223
263
|
if (!acquireResult || acquireResult.ok !== true) {
|
|
264
|
+
// #1494 / #599: a lock path that exists but cannot be read (EACCES, or a
|
|
265
|
+
// directory) is never reclaimed — but it is no longer silent either. The
|
|
266
|
+
// WARN is discarded by the harness, so the read_anomaly event (the one
|
|
267
|
+
// SessionEnd already emits for the same state) is the countable trace.
|
|
268
|
+
if (acquireResult && acquireResult.reason === 'unreadable') {
|
|
269
|
+
process.stderr.write(
|
|
270
|
+
`⚠ lock-bootstrap: .orchestrator/session.lock exists but cannot be read (${acquireResult.error ?? 'unknown'}) — this session runs without a lock\n`,
|
|
271
|
+
);
|
|
272
|
+
await emitBestEffort(_emitEventImpl, 'orchestrator.session.lock.read_anomaly', {
|
|
273
|
+
session_id: sessionId,
|
|
274
|
+
status: 'unreadable',
|
|
275
|
+
...(typeof acquireResult.error === 'string' ? { error: acquireResult.error } : {}),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
224
278
|
if (
|
|
225
279
|
acquireResult &&
|
|
226
280
|
acquireResult.reason === 'active' &&
|
|
@@ -236,19 +290,46 @@ export async function bootstrapLock({
|
|
|
236
290
|
|
|
237
291
|
// Step 2: enrich the lock with v2 fields (last_heartbeat + semantic_session_id).
|
|
238
292
|
// We re-read the file fresh (acquire() just wrote it) and overlay the new
|
|
239
|
-
// fields, then atomically tmp+rename.
|
|
240
|
-
//
|
|
241
|
-
//
|
|
293
|
+
// fields, then atomically tmp+rename. acquire() already writes
|
|
294
|
+
// last_heartbeat (Epic #583 W2-I3), so that half of the overlay re-writes an
|
|
295
|
+
// identical value; semantic_session_id is the half that adds information.
|
|
242
296
|
const lockFile = path.join(repoRoot, '.orchestrator', 'session.lock');
|
|
297
|
+
let raw;
|
|
298
|
+
// ENOENT right after ok:true is most likely a racer holding OUR fresh lock in
|
|
299
|
+
// its tombstone: a second start that saw the same stale lock renames it
|
|
300
|
+
// aside, finds it live and links it back within microseconds
|
|
301
|
+
// (session-lock.mjs reclaimIfNotLive). Reading once and giving up left the
|
|
302
|
+
// lock ours but without semantic_session_id, owner proof and lock.acquired
|
|
303
|
+
// (#1494 review: two racers are enough). Bounded: 5 retries × 10 ms, so a lock
|
|
304
|
+
// that is really gone costs this hook at most ~50 ms. Measured 2026-10-02
|
|
305
|
+
// (2000 live-restore reclaims, load average ~9): the racer's rename→link-back
|
|
306
|
+
// window was p50 0.34 ms, p99 2.4 ms, max 31.7 ms. Ceiling (BV-004): a window
|
|
307
|
+
// longer than ~50 ms still ends in the lockless-enrichment null above;
|
|
308
|
+
// revisit if lock.acquired goes missing for sessions that own the lock.
|
|
309
|
+
for (let attempt = 0; raw === undefined; attempt += 1) {
|
|
310
|
+
try {
|
|
311
|
+
raw = fs.readFileSync(lockFile, 'utf8');
|
|
312
|
+
} catch (err) {
|
|
313
|
+
if (err?.code !== 'ENOENT' || attempt >= ENRICH_READ_RETRIES) return null;
|
|
314
|
+
await new Promise((resolve) => setTimeout(resolve, ENRICH_READ_RETRY_MS));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
243
317
|
let baseLock;
|
|
244
318
|
try {
|
|
245
|
-
const raw = fs.readFileSync(lockFile, 'utf8');
|
|
246
319
|
baseLock = JSON.parse(raw);
|
|
247
320
|
if (typeof baseLock !== 'object' || baseLock === null) return null;
|
|
248
321
|
} catch {
|
|
249
|
-
// Lock vanished between write and read — best-effort, return null.
|
|
250
322
|
return null;
|
|
251
323
|
}
|
|
324
|
+
// The file can belong to another session by now. The reclaim above is
|
|
325
|
+
// single-winner since #1494 (tombstone rename + link-create in
|
|
326
|
+
// forceAcquire's onlyIfNotLive), but a plain forceAcquire — the same-session
|
|
327
|
+
// refresh, an operator's Phase-1.2 force-take — still overwrites
|
|
328
|
+
// unconditionally, and a link-back that loses to a third writer drops the
|
|
329
|
+
// lock it was restoring (session-lock.mjs reclaimIfNotLive, ceiling 2).
|
|
330
|
+
// Enriching a foreign lock would stamp OUR label onto ITS record and write
|
|
331
|
+
// an owner proof that verifies against it, so it is left as is.
|
|
332
|
+
if (baseLock.session_id !== sessionId) return null;
|
|
252
333
|
|
|
253
334
|
const startedAt = typeof baseLock.started_at === 'string'
|
|
254
335
|
? baseLock.started_at
|
|
@@ -301,27 +382,38 @@ export async function bootstrapLock({
|
|
|
301
382
|
}
|
|
302
383
|
} catch { /* best-effort — a missing proof never breaks session-start */ }
|
|
303
384
|
|
|
304
|
-
// Step 3: best-effort observability breadcrumb.
|
|
305
|
-
|
|
385
|
+
// Step 3: best-effort observability breadcrumb.
|
|
386
|
+
await emitBestEffort(_emitEventImpl, 'orchestrator.session.lock.acquired', {
|
|
387
|
+
session_id: enriched.session_id,
|
|
388
|
+
semantic_session_id: enriched.semantic_session_id,
|
|
389
|
+
mode: enriched.mode,
|
|
390
|
+
pid: enriched.pid,
|
|
391
|
+
host: enriched.host,
|
|
392
|
+
ttl_hours: enriched.ttl_hours,
|
|
393
|
+
...(reclaimed ? { reclaimed } : {}),
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
return enriched;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Emit one event; every failure is swallowed so a missing events module never
|
|
401
|
+
* breaks the hook (observability is best-effort).
|
|
402
|
+
*
|
|
403
|
+
* @param {Function|undefined} emitImpl — the `_emitEventImpl` test seam.
|
|
404
|
+
* @param {string} name
|
|
405
|
+
* @param {object} payload
|
|
406
|
+
* @returns {Promise<void>}
|
|
407
|
+
*/
|
|
408
|
+
async function emitBestEffort(emitImpl, name, payload) {
|
|
306
409
|
try {
|
|
307
|
-
let emitFn =
|
|
410
|
+
let emitFn = emitImpl;
|
|
308
411
|
if (!emitFn) {
|
|
309
412
|
const eventsMod = await import('../../scripts/lib/events.mjs');
|
|
310
413
|
emitFn = eventsMod.emitEvent;
|
|
311
414
|
}
|
|
312
|
-
if (typeof emitFn === 'function')
|
|
313
|
-
await emitFn('orchestrator.session.lock.acquired', {
|
|
314
|
-
session_id: enriched.session_id,
|
|
315
|
-
semantic_session_id: enriched.semantic_session_id,
|
|
316
|
-
mode: enriched.mode,
|
|
317
|
-
pid: enriched.pid,
|
|
318
|
-
host: enriched.host,
|
|
319
|
-
ttl_hours: enriched.ttl_hours,
|
|
320
|
-
});
|
|
321
|
-
}
|
|
415
|
+
if (typeof emitFn === 'function') await emitFn(name, payload);
|
|
322
416
|
} catch { /* observability is best-effort */ }
|
|
323
|
-
|
|
324
|
-
return enriched;
|
|
325
417
|
}
|
|
326
418
|
|
|
327
419
|
/**
|
|
@@ -81,7 +81,7 @@ const PROTECTED_PATTERNS = [
|
|
|
81
81
|
/^tsconfig.*\.json$/, // tsconfig.json, tsconfig.base.json, tsconfig.build.json, …
|
|
82
82
|
/^\.prettierrc(?:\..+)?$/, // .prettierrc, .prettierrc.json, .prettierrc.js, …
|
|
83
83
|
/^prettier\.config\.(?:js|cjs|mjs)$/,
|
|
84
|
-
/^commitlint
|
|
84
|
+
/^commitlint(?:\.ci)?\.config\.(?:js|cjs|mjs|ts)$/, // + the strict CI config (commitlint.ci.config.mjs)
|
|
85
85
|
];
|
|
86
86
|
|
|
87
87
|
/**
|
|
@@ -95,9 +95,10 @@ import { pathToFileURL } from 'node:url';
|
|
|
95
95
|
/** @type {typeof import('../scripts/lib/io.mjs').emitAllow} */ let emitAllow;
|
|
96
96
|
/** @type {typeof import('../scripts/lib/io.mjs').emitDeny} */ let emitDeny;
|
|
97
97
|
/** @type {typeof import('../scripts/lib/io.mjs').emitWarn} */ let emitWarn;
|
|
98
|
-
let
|
|
98
|
+
let resolveSessionRoot;
|
|
99
99
|
let readJson;
|
|
100
|
-
|
|
100
|
+
// #1504 point 6 — bound from scope-gate below.
|
|
101
|
+
let findOwnScopeFile;
|
|
101
102
|
let extractBashWriteTargets;
|
|
102
103
|
let pathMatchesPattern;
|
|
103
104
|
/** @type {typeof import('../scripts/lib/session-identity/own-session.mjs').readProcessLocalSessionIds} */
|
|
@@ -165,7 +166,8 @@ function bannerProjectDir() {
|
|
|
165
166
|
* the entry-point catch banners.
|
|
166
167
|
*
|
|
167
168
|
* `hardening.mjs` is bound (no headFallback — it carries relative imports) for
|
|
168
|
-
* `
|
|
169
|
+
* `extractBashWriteTargets` / `pathMatchesPattern` (and `scope-gate.mjs` for
|
|
170
|
+
* `findOwnScopeFile`, #1504 point 6); the two
|
|
169
171
|
* command-matching primitives come from the direct `blocker` namespace, which is
|
|
170
172
|
* the only entry that opts into the `git show HEAD:` recovery. Because hardening
|
|
171
173
|
* transitively imports command-blocker (via scope-gate.mjs), a broken command-blocker
|
|
@@ -184,6 +186,9 @@ async function bootstrap() {
|
|
|
184
186
|
platform: { specifier: lib('platform.mjs') },
|
|
185
187
|
common: { specifier: lib('common.mjs') },
|
|
186
188
|
hardening: { specifier: lib('hardening.mjs') },
|
|
189
|
+
// #1504 point 6 — `findOwnScopeFile`. Already reachable through the
|
|
190
|
+
// hardening barrel, so no new module edge.
|
|
191
|
+
scopeGate: { specifier: lib('scope-gate.mjs') },
|
|
187
192
|
blocker: {
|
|
188
193
|
specifier: lib('command-blocker.mjs'),
|
|
189
194
|
headFallback: true,
|
|
@@ -203,9 +208,10 @@ async function bootstrap() {
|
|
|
203
208
|
);
|
|
204
209
|
|
|
205
210
|
({ readStdin, emitAllow, emitDeny, emitWarn } = modules.io);
|
|
206
|
-
({
|
|
211
|
+
({ resolveSessionRoot } = modules.platform);
|
|
207
212
|
({ readJson } = modules.common);
|
|
208
|
-
({
|
|
213
|
+
({ extractBashWriteTargets, pathMatchesPattern } = modules.hardening);
|
|
214
|
+
({ findOwnScopeFile } = modules.scopeGate);
|
|
209
215
|
({ readProcessLocalSessionIds, classifyManifestSession } = modules.sessionIdentity);
|
|
210
216
|
blocker = modules.blocker;
|
|
211
217
|
degradedLabels = degraded;
|
|
@@ -275,10 +281,22 @@ async function main() {
|
|
|
275
281
|
);
|
|
276
282
|
}
|
|
277
283
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
//
|
|
281
|
-
const
|
|
284
|
+
// #1492 — the SESSION's working copy, not the launch dir `$CLAUDE_PROJECT_DIR`
|
|
285
|
+
// keeps after `EnterWorktree` (the manifest lives in the worktree). See
|
|
286
|
+
// `resolveSessionRoot` in scripts/lib/platform.mjs.
|
|
287
|
+
const projectRoot = resolveSessionRoot(input.cwd);
|
|
288
|
+
|
|
289
|
+
// `new Set(...)` is load-bearing: `readProcessLocalSessionIds` returns a
|
|
290
|
+
// string[], and `classifyManifestSession` does `ownIds instanceof Set ?
|
|
291
|
+
// ownIds : new Set()` — a bare array would silently become EMPTY.
|
|
292
|
+
const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
|
|
293
|
+
|
|
294
|
+
// G3 — no scope file → allow. #1504 point 6: the first manifest that is not
|
|
295
|
+
// provably a peer's, so a peer's higher-precedence `.pi`/`.cursor`/`.codex`
|
|
296
|
+
// manifest no longer hides this session's own one; only when every candidate
|
|
297
|
+
// is foreign does the first come back, and G3b below stands down as before.
|
|
298
|
+
const located = findOwnScopeFile(projectRoot, ownIds, classifyManifestSession);
|
|
299
|
+
const scopePath = located.path ?? located.foreignPath;
|
|
282
300
|
if (!scopePath) return flushNotices(notices);
|
|
283
301
|
|
|
284
302
|
// SECURITY-REQ-08: read scope file exactly once; use the parsed object
|
|
@@ -313,10 +331,7 @@ async function main() {
|
|
|
313
331
|
// empty, so this gate is permanently `'unknown'` = enforce = pre-#1153
|
|
314
332
|
// behaviour there. Revisit when those hook payloads carry a session id.
|
|
315
333
|
{
|
|
316
|
-
// `
|
|
317
|
-
// string[], and `classifyManifestSession` does `ownIds instanceof Set ?
|
|
318
|
-
// ownIds : new Set()` — a bare array would silently become EMPTY.
|
|
319
|
-
const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
|
|
334
|
+
// `ownIds` is the process-local set built above G3.
|
|
320
335
|
const { verdict, manifestIds } = classifyManifestSession(scope, ownIds);
|
|
321
336
|
if (verdict === 'foreign') {
|
|
322
337
|
// Observability only, and deliberately NOT emitWarn: this branch is hit on
|
package/hooks/enforce-scope.mjs
CHANGED
|
@@ -107,7 +107,12 @@ import { isMainModule } from '../scripts/lib/is-main-module.mjs';
|
|
|
107
107
|
let isPathInside;
|
|
108
108
|
let relativeFromRoot;
|
|
109
109
|
let resolveProjectDir;
|
|
110
|
-
let
|
|
110
|
+
let resolveSessionRoot;
|
|
111
|
+
// The launch dir the harness states — platform.mjs's one definition of the env
|
|
112
|
+
// chain, so the relocation trace below cannot drift from the clamp (#1504).
|
|
113
|
+
let launchDirFromEnv;
|
|
114
|
+
// #1504 point 6 — the manifest that governs THIS session; bound from scope-gate below.
|
|
115
|
+
let findOwnScopeFile;
|
|
111
116
|
let pathMatchesPattern;
|
|
112
117
|
let suggestForScopeViolation;
|
|
113
118
|
let readJson;
|
|
@@ -197,8 +202,8 @@ async function bootstrap() {
|
|
|
197
202
|
|
|
198
203
|
({ readStdin, emitAllow, emitDeny, emitWarn } = modules.io);
|
|
199
204
|
({ isPathInside, relativeFromRoot } = modules.pathUtils);
|
|
200
|
-
({ resolveProjectDir } = modules.platform);
|
|
201
|
-
({
|
|
205
|
+
({ resolveProjectDir, resolveSessionRoot, launchDirFromEnv } = modules.platform);
|
|
206
|
+
({ pathMatchesPattern, suggestForScopeViolation } = modules.hardening);
|
|
202
207
|
({ readJson } = modules.common);
|
|
203
208
|
({
|
|
204
209
|
classifyEmptyScope,
|
|
@@ -206,6 +211,7 @@ async function bootstrap() {
|
|
|
206
211
|
sessionStartedAtMs,
|
|
207
212
|
gradeScopeEntry,
|
|
208
213
|
canonicalizeGrantPrefix,
|
|
214
|
+
findOwnScopeFile,
|
|
209
215
|
} = modules.scopeGate);
|
|
210
216
|
({ readProcessLocalSessionIds, classifyManifestSession } = modules.sessionIdentity);
|
|
211
217
|
}
|
|
@@ -224,7 +230,13 @@ async function main() {
|
|
|
224
230
|
// Gate 2: file_path must be a non-empty string
|
|
225
231
|
if (!filePath || typeof filePath !== 'string') return emitAllow();
|
|
226
232
|
|
|
227
|
-
|
|
233
|
+
// #1492 — the SESSION's working copy, not the launch dir: after
|
|
234
|
+
// `EnterWorktree`, `$CLAUDE_PROJECT_DIR` still names the dir the session was
|
|
235
|
+
// launched in, while the manifest lives in the worktree. Never above the launch
|
|
236
|
+
// dir; a harness subagent worktree (`isolation: "worktree"`) is lifted to the
|
|
237
|
+
// launch checkout it sits under — still unenforced in an entered-worktree
|
|
238
|
+
// session (named ceiling in `resolveSessionRoot`, scripts/lib/platform.mjs).
|
|
239
|
+
const projectRootRaw = resolveSessionRoot(input.cwd);
|
|
228
240
|
|
|
229
241
|
// Resolve symlinks in the project root itself so that realpath(file) comparisons
|
|
230
242
|
// are consistent. On macOS /tmp → /private/tmp; mismatches would cause false denials.
|
|
@@ -235,9 +247,26 @@ async function main() {
|
|
|
235
247
|
projectRoot = projectRootRaw;
|
|
236
248
|
}
|
|
237
249
|
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
250
|
+
// `new Set(...)` is load-bearing: `readProcessLocalSessionIds` returns a
|
|
251
|
+
// string[], and `classifyManifestSession` does `ownIds instanceof Set ?
|
|
252
|
+
// ownIds : new Set()` — a bare array would silently become EMPTY, making
|
|
253
|
+
// every manifest read `unknown`. Process-local only (#1194, see Gate 3b).
|
|
254
|
+
const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
|
|
255
|
+
|
|
256
|
+
// Gate 3: no wave-scope.json → nothing to enforce.
|
|
257
|
+
//
|
|
258
|
+
// #1504 point 6 — the first manifest that is not provably a peer's, in the
|
|
259
|
+
// `.pi` > `.cursor` > `.codex` > `.claude` order. `findScopeFile` stopped at
|
|
260
|
+
// the first file that exists, so a peer's `.pi`/`.cursor` manifest made Gate
|
|
261
|
+
// 3b stand down and this session's own `.claude/wave-scope.json` was never
|
|
262
|
+
// read. Only when EVERY candidate is foreign does the first of them come back
|
|
263
|
+
// (`foreignPath`), so Gate 3b's stand-down below stays unchanged.
|
|
264
|
+
const located = findOwnScopeFile(projectRoot, ownIds, classifyManifestSession);
|
|
265
|
+
const scopePath = located.path ?? located.foreignPath;
|
|
266
|
+
if (!scopePath) {
|
|
267
|
+
await recordHiddenManifestOncePerWave({ projectRootRaw, projectRoot, ownIds, filePath });
|
|
268
|
+
return emitAllow();
|
|
269
|
+
}
|
|
241
270
|
|
|
242
271
|
// SECURITY-REQ-08: read scope file once; pass parsed object to all subsequent checks
|
|
243
272
|
//
|
|
@@ -305,11 +334,7 @@ async function main() {
|
|
|
305
334
|
// `enforcement: "off"` already grants in the same file — this gate adds no new
|
|
306
335
|
// authority, and the manifest is the coordinator's own artefact either way.
|
|
307
336
|
{
|
|
308
|
-
// `
|
|
309
|
-
// string[], and `classifyManifestSession` does `ownIds instanceof Set ?
|
|
310
|
-
// ownIds : new Set()` — a bare array would silently become EMPTY, making
|
|
311
|
-
// every manifest read `unknown`.
|
|
312
|
-
const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
|
|
337
|
+
// `ownIds` is the process-local set built above Gate 3.
|
|
313
338
|
const { verdict, manifestIds } = classifyManifestSession(scope, ownIds);
|
|
314
339
|
if (verdict === 'foreign') {
|
|
315
340
|
// Observability only, and deliberately NOT emitWarn: this branch is hit on
|
|
@@ -489,6 +514,7 @@ async function main() {
|
|
|
489
514
|
if (matchedGrant !== null) {
|
|
490
515
|
const grade = gradeScopeEntry(matchedGrant, { resolve: canonicalizeGrantPrefix });
|
|
491
516
|
if (grade?.verdict === 'error') {
|
|
517
|
+
await recordRefusedGrantOncePerWave({ scope, scopePath, projectRoot, input, grant: matchedGrant, grade, filePath: resolvedPath });
|
|
492
518
|
return emitWarn(
|
|
493
519
|
`Gate 5b honoured an out-of-repo grant that scripts/validate-wave-scope.mjs would REFUSE ` +
|
|
494
520
|
`before dispatch — allowedPaths ${grade.message}. The write to '${resolvedPath}' is ` +
|
|
@@ -538,7 +564,9 @@ async function main() {
|
|
|
538
564
|
// dispatched wave agent's. See {@link classifyCaller} for the measurement.
|
|
539
565
|
// A subagent falls through to the gates below — under a manifest that does
|
|
540
566
|
// not grant the path (Discovery's `allowedPaths: []`) that is a DENY.
|
|
541
|
-
|
|
567
|
+
// #1492: the LAUNCH dir too — the harness keys auto-memory on the path it
|
|
568
|
+
// was launched with, which after `EnterWorktree` is no longer the root.
|
|
569
|
+
const memoryDirs = await ownMemoryDirs([resolveProjectDir(), projectRootRaw, projectRoot]);
|
|
542
570
|
const caller = classifyCaller(input);
|
|
543
571
|
if (caller !== 'subagent' && memoryDirs.some((dir) => isInsideDir(resolvedPath, dir))) {
|
|
544
572
|
// One event per decision point, awaited before emitAllow() —
|
|
@@ -652,6 +680,155 @@ async function main() {
|
|
|
652
680
|
return emitAllow();
|
|
653
681
|
}
|
|
654
682
|
|
|
683
|
+
/**
|
|
684
|
+
* #1485 point 1 — the catch event for Gate 5b's WARN branch, ONCE per wave.
|
|
685
|
+
*
|
|
686
|
+
* `scripts/validate-wave-scope.mjs` has no mechanical caller; the Gate 5b WARN
|
|
687
|
+
* is the only place a refused grant is seen at all, and a `systemMessage` is not
|
|
688
|
+
* countable. Owner decision 2026-10-02, option (b): the FIRST WARN of a wave
|
|
689
|
+
* writes `orchestrator.scope.refused_grant_allowed`, so "how many waves ran on
|
|
690
|
+
* a manifest the validator would refuse" becomes a ledger query (HR-105). The
|
|
691
|
+
* WARN itself is unchanged and still fires on every write.
|
|
692
|
+
*
|
|
693
|
+
* DEDUPE: a marker `.orchestrator/tmp/gate5b-refused-grant/<session>-w<wave>`
|
|
694
|
+
* created with `flag: 'wx'` (O_CREAT|O_EXCL). Exclusive create is atomic, so of
|
|
695
|
+
* two hook processes racing on the first WARN exactly one wins and emits — no
|
|
696
|
+
* lock, no read-modify-write. The session joins the key because wave numbers
|
|
697
|
+
* restart in every session sharing this working copy. `.orchestrator/tmp/` is
|
|
698
|
+
* gitignored; one marker per (session, wave) that ever hit a refused grant.
|
|
699
|
+
*
|
|
700
|
+
* Any marker failure OTHER than EEXIST (unwritable dir, ENOTDIR) still emits:
|
|
701
|
+
* over-reporting a rare event beats re-creating the silent gap this closes.
|
|
702
|
+
*
|
|
703
|
+
* COST: runs only on the Gate 5b `error` branch — an ordinary in-repo write
|
|
704
|
+
* never reaches it, so the hot path pays nothing. NAMED CEILING (BV-004): a
|
|
705
|
+
* legacy manifest without a session id and a payload without one share the key
|
|
706
|
+
* `no-session-w<N>` across sessions; revisit if such manifests are still seen.
|
|
707
|
+
*
|
|
708
|
+
* Never throws — observability must not change the WARN.
|
|
709
|
+
*
|
|
710
|
+
* @param {{scope: Record<string, unknown>, scopePath: string, projectRoot: string,
|
|
711
|
+
* input: Record<string, unknown>, grant: string, grade: {code?: string},
|
|
712
|
+
* filePath: string}} facts
|
|
713
|
+
* @returns {Promise<void>}
|
|
714
|
+
*/
|
|
715
|
+
async function recordRefusedGrantOncePerWave({ scope, scopePath, projectRoot, input, grant, grade, filePath }) {
|
|
716
|
+
try {
|
|
717
|
+
const session = String(
|
|
718
|
+
scope.session_id ?? scope.session ?? input?.session_id ?? process.env.CLAUDE_CODE_SESSION_ID ?? 'no-session',
|
|
719
|
+
);
|
|
720
|
+
const key = `${session}-w${scope.wave ?? 'unknown'}`.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 160);
|
|
721
|
+
const markerDir = path.join(projectRoot, '.orchestrator', 'tmp', 'gate5b-refused-grant');
|
|
722
|
+
try { await fs.mkdir(markerDir, { recursive: true }); } catch { /* the create below decides */ }
|
|
723
|
+
try {
|
|
724
|
+
await fs.writeFile(path.join(markerDir, key), `${new Date().toISOString()}\n`, { flag: 'wx' });
|
|
725
|
+
} catch (e) {
|
|
726
|
+
if (e?.code === 'EEXIST') return; // this wave already reported
|
|
727
|
+
}
|
|
728
|
+
const { emitEvent } = await import('../scripts/lib/events.mjs');
|
|
729
|
+
await emitEvent(
|
|
730
|
+
'orchestrator.scope.refused_grant_allowed',
|
|
731
|
+
{
|
|
732
|
+
hook: HOOK_NAME,
|
|
733
|
+
manifest: scopePath,
|
|
734
|
+
wave: scope.wave,
|
|
735
|
+
grant,
|
|
736
|
+
code: grade?.code,
|
|
737
|
+
file_path: filePath,
|
|
738
|
+
},
|
|
739
|
+
{ repoRoot: projectRoot },
|
|
740
|
+
);
|
|
741
|
+
} catch { /* observability is best-effort — never changes the WARN */ }
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* #1504 point 3 — the catch event for a session root that HID an active wave,
|
|
746
|
+
* ONCE per (session, wave, session root).
|
|
747
|
+
*
|
|
748
|
+
* `resolveSessionRoot()` answers with the repo the payload `cwd` sits in. A wave
|
|
749
|
+
* agent that `cd`s into a nested or sibling repo (or a session that entered a
|
|
750
|
+
* worktree) therefore resolves a root without a manifest, and Gate 3 allows
|
|
751
|
+
* every write there while the wave's manifest sits at the launch dir, unread.
|
|
752
|
+
* The revisit trigger `resolveSessionRoot` names for exactly that relocation
|
|
753
|
+
* ("an `orchestrator.scope.*` record") could never fire: this allow left no
|
|
754
|
+
* trace. Behaviour stays ALLOW — this is the trace, not a new deny.
|
|
755
|
+
*
|
|
756
|
+
* Fires only when BOTH hold, so the ordinary no-manifest allow — every write in
|
|
757
|
+
* every repo without a wave — stays silent (HR-101):
|
|
758
|
+
* 1. the harness states a launch dir and it is not the resolved session root;
|
|
759
|
+
* 2. the launch root carries a manifest that is not provably a peer's
|
|
760
|
+
* ({@link findOwnScopeFile}: own, or unbound/legacy — one that WOULD have
|
|
761
|
+
* been enforced). A peer's manifest at the launch root hides nothing of ours.
|
|
762
|
+
*
|
|
763
|
+
* COST on the hot path: rung 1 is an env read plus a string compare — no
|
|
764
|
+
* filesystem call while the session root IS the launch dir, i.e. for every
|
|
765
|
+
* session that never left its checkout. One `realpath` when only the spelling
|
|
766
|
+
* differs (macOS `/var` → `/private/var`); the ≤4 `existsSync` of rung 2 only
|
|
767
|
+
* for a genuinely relocated root.
|
|
768
|
+
*
|
|
769
|
+
* DEDUPE: the {@link recordRefusedGrantOncePerWave} pattern — an exclusive
|
|
770
|
+
* create (`flag: 'wx'`) of `<launch>/.orchestrator/tmp/scope-root-relocated/<key>`.
|
|
771
|
+
* The event goes to the LAUNCH repo's ledger, where the wave's other records
|
|
772
|
+
* live. The session root's basename joins the key so a second relocated repo in
|
|
773
|
+
* one wave is reported too. NAMED CEILING (BV-004): two relocated roots sharing a
|
|
774
|
+
* basename in one wave report once; revisit if a record ever names the wrong repo.
|
|
775
|
+
*
|
|
776
|
+
* Never throws — observability must not change the allow.
|
|
777
|
+
*
|
|
778
|
+
* @param {{projectRootRaw: string, projectRoot: string, ownIds: Set<string>,
|
|
779
|
+
* filePath: string}} facts
|
|
780
|
+
* @returns {Promise<void>}
|
|
781
|
+
*/
|
|
782
|
+
async function recordHiddenManifestOncePerWave({ projectRootRaw, projectRoot, ownIds, filePath }) {
|
|
783
|
+
try {
|
|
784
|
+
const launchRaw = launchDirFromEnv();
|
|
785
|
+
if (launchRaw === '') return;
|
|
786
|
+
const launchResolved = path.resolve(launchRaw);
|
|
787
|
+
if (launchResolved === projectRootRaw || launchResolved === projectRoot) return;
|
|
788
|
+
let launch;
|
|
789
|
+
try {
|
|
790
|
+
launch = await fs.realpath(launchResolved);
|
|
791
|
+
} catch {
|
|
792
|
+
return; // a launch dir that does not exist holds no manifest
|
|
793
|
+
}
|
|
794
|
+
if (launch === projectRoot) return;
|
|
795
|
+
|
|
796
|
+
const { path: manifest } = findOwnScopeFile(launch, ownIds, classifyManifestSession);
|
|
797
|
+
if (!manifest) return;
|
|
798
|
+
|
|
799
|
+
let scope = {};
|
|
800
|
+
try {
|
|
801
|
+
const parsed = JSON.parse(await fs.readFile(manifest, 'utf8'));
|
|
802
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) scope = parsed;
|
|
803
|
+
} catch { /* unreadable → keyed and reported as wave `unknown` */ }
|
|
804
|
+
|
|
805
|
+
const session = String(scope.session_id ?? scope.session ?? [...ownIds][0] ?? 'no-session');
|
|
806
|
+
const key = `${session}-w${scope.wave ?? 'unknown'}-${path.basename(projectRoot)}`
|
|
807
|
+
.replace(/[^A-Za-z0-9._-]/g, '_')
|
|
808
|
+
.slice(0, 160);
|
|
809
|
+
const markerDir = path.join(launch, '.orchestrator', 'tmp', 'scope-root-relocated');
|
|
810
|
+
try { await fs.mkdir(markerDir, { recursive: true }); } catch { /* the create below decides */ }
|
|
811
|
+
try {
|
|
812
|
+
await fs.writeFile(path.join(markerDir, key), `${new Date().toISOString()}\n`, { flag: 'wx' });
|
|
813
|
+
} catch (e) {
|
|
814
|
+
if (e?.code === 'EEXIST') return; // this root already reported for this wave
|
|
815
|
+
}
|
|
816
|
+
const { emitEvent } = await import('../scripts/lib/events.mjs');
|
|
817
|
+
await emitEvent(
|
|
818
|
+
'orchestrator.scope.manifest_hidden_by_session_root',
|
|
819
|
+
{
|
|
820
|
+
hook: HOOK_NAME,
|
|
821
|
+
manifest,
|
|
822
|
+
launch_root: launch,
|
|
823
|
+
session_root: projectRoot,
|
|
824
|
+
wave: scope.wave,
|
|
825
|
+
file_path: path.isAbsolute(filePath) ? filePath : path.resolve(projectRoot, filePath),
|
|
826
|
+
},
|
|
827
|
+
{ repoRoot: launch },
|
|
828
|
+
);
|
|
829
|
+
} catch { /* observability is best-effort — never changes the allow */ }
|
|
830
|
+
}
|
|
831
|
+
|
|
655
832
|
/**
|
|
656
833
|
* `mtimeMs` of a file, or `null` when it cannot be stat'ed.
|
|
657
834
|
*
|
|
@@ -679,21 +856,22 @@ async function mtimeMsOf(file) {
|
|
|
679
856
|
/**
|
|
680
857
|
* #1295 — the Claude Code auto-memory directories that belong to THIS repo.
|
|
681
858
|
*
|
|
682
|
-
*
|
|
683
|
-
*
|
|
684
|
-
*
|
|
685
|
-
* encodes the path it was LAUNCHED with, while this hook
|
|
686
|
-
* canonical root (on macOS `/tmp` →
|
|
687
|
-
*
|
|
859
|
+
* One path per distinct root (per home spelling): the encoding of the launch
|
|
860
|
+
* dir the harness saw (`CLAUDE_PROJECT_DIR` / cwd), of the session root
|
|
861
|
+
* (#1492 — a worktree the session entered) and of its realpath. All are needed
|
|
862
|
+
* because Claude Code encodes the path it was LAUNCHED with, while this hook
|
|
863
|
+
* compares against the canonical session root (on macOS `/tmp` →
|
|
864
|
+
* `/private/tmp`). Keeping the launch dir keeps the pre-#1492 grant exactly
|
|
865
|
+
* (the launch dir WAS the root then); the session-root entries name the
|
|
866
|
+
* worktree this session works in. A sibling repo's root encodes to none of them.
|
|
688
867
|
*
|
|
689
868
|
* Returns `[]` when the encoder cannot be loaded, so the caller falls through to
|
|
690
869
|
* its deny (fail-closed).
|
|
691
870
|
*
|
|
692
|
-
* @param {string}
|
|
693
|
-
* @param {string} projectRoot — its realpath
|
|
871
|
+
* @param {string[]} candidateRoots — launch dir, session root, its realpath
|
|
694
872
|
* @returns {Promise<string[]>}
|
|
695
873
|
*/
|
|
696
|
-
async function ownMemoryDirs(
|
|
874
|
+
async function ownMemoryDirs(candidateRoots) {
|
|
697
875
|
try {
|
|
698
876
|
const [{ encodeProjectDir }, { homedir }] = await Promise.all([
|
|
699
877
|
import('../scripts/lib/wave-transcript-tail.mjs'),
|
|
@@ -710,7 +888,7 @@ async function ownMemoryDirs(projectRootRaw, projectRoot) {
|
|
|
710
888
|
} catch { /* non-existent home — the raw form is all there is */ }
|
|
711
889
|
const homes = new Set([home, homeReal]);
|
|
712
890
|
const roots = new Set(
|
|
713
|
-
|
|
891
|
+
candidateRoots.filter((p) => typeof p === 'string' && p.length > 0),
|
|
714
892
|
);
|
|
715
893
|
const dirs = [];
|
|
716
894
|
for (const h of homes) {
|
package/hooks/hooks-codex.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"hooks": [
|
|
8
8
|
{
|
|
9
9
|
"type": "command",
|
|
10
|
-
"command": "echo '🎯 Session Orchestrator v5.
|
|
10
|
+
"command": "echo '🎯 Session Orchestrator v5.5.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
|
|
11
11
|
"async": false
|
|
12
12
|
},
|
|
13
13
|
{
|
package/hooks/hooks-cursor.json
CHANGED
|
@@ -83,6 +83,11 @@
|
|
|
83
83
|
"type": "command",
|
|
84
84
|
"command": "sh \"$CURSOR_PLUGIN_ROOT/hooks/run-node.sh\" \"$CURSOR_PLUGIN_ROOT/hooks/config-protection.mjs\"",
|
|
85
85
|
"timeout": 5
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"type": "command",
|
|
89
|
+
"command": "sh \"$CURSOR_PLUGIN_ROOT/hooks/run-node.sh\" \"$CURSOR_PLUGIN_ROOT/hooks/pre-edit-sessions-ledger-guard.mjs\"",
|
|
90
|
+
"timeout": 5
|
|
86
91
|
}
|
|
87
92
|
]
|
|
88
93
|
}
|
package/hooks/hooks-pi.json
CHANGED
|
@@ -38,6 +38,11 @@
|
|
|
38
38
|
"type": "command",
|
|
39
39
|
"command": "sh \"$PI_PLUGIN_ROOT/hooks/run-node.sh\" \"$PI_PLUGIN_ROOT/hooks/config-protection.mjs\"",
|
|
40
40
|
"timeout": 5
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"type": "command",
|
|
44
|
+
"command": "sh \"$PI_PLUGIN_ROOT/hooks/run-node.sh\" \"$PI_PLUGIN_ROOT/hooks/pre-edit-sessions-ledger-guard.mjs\"",
|
|
45
|
+
"timeout": 5
|
|
41
46
|
}
|
|
42
47
|
]
|
|
43
48
|
},
|