@event4u/agent-config 9.26.0 → 9.28.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/AGENTS.md +12 -11
- package/CHANGELOG.md +134 -207
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/conformance.md +43 -0
- package/dist/agent-src/commands/roadmap/next.md +65 -3
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +10 -3
- package/dist/agent-src/rules/active-remediation.md +2 -0
- package/dist/agent-src/rules/analysis-skill-routing.md +2 -0
- package/dist/agent-src/rules/architecture.md +2 -0
- package/dist/agent-src/rules/artifact-drafting-protocol.md +2 -0
- package/dist/agent-src/rules/artifact-engagement-recording.md +6 -1
- package/dist/agent-src/rules/augment-edit-discipline.md +2 -0
- package/dist/agent-src/rules/autonomous-execution.md +3 -1
- package/dist/agent-src/rules/brand-consistency.md +2 -0
- package/dist/agent-src/rules/brand-source-of-truth.md +35 -6
- package/dist/agent-src/rules/broken-access-control.md +2 -0
- package/dist/agent-src/rules/cli-output-handling.md +2 -0
- package/dist/agent-src/rules/code-comment-discipline.md +2 -0
- package/dist/agent-src/rules/code-provenance.md +2 -0
- package/dist/agent-src/rules/command-suggestion-policy.md +2 -0
- package/dist/agent-src/rules/commit-conventions.md +2 -0
- package/dist/agent-src/rules/communication-through-line.md +2 -0
- package/dist/agent-src/rules/content-quoting-floor.md +2 -0
- package/dist/agent-src/rules/context-hygiene.md +13 -1
- package/dist/agent-src/rules/copilot-routing.md +2 -0
- package/dist/agent-src/rules/council-availability.md +93 -0
- package/dist/agent-src/rules/cross-source-consistency.md +5 -0
- package/dist/agent-src/rules/decision-revisit-gate.md +2 -0
- package/dist/agent-src/rules/delegation-policy.md +31 -5
- package/dist/agent-src/rules/design-fidelity.md +5 -0
- package/dist/agent-src/rules/design-review-after-ui-write.md +4 -0
- package/dist/agent-src/rules/devcontainer-routing.md +2 -0
- package/dist/agent-src/rules/doc-screenshot-hygiene.md +2 -0
- package/dist/agent-src/rules/docker-commands.md +2 -0
- package/dist/agent-src/rules/domain-adoption-policy.md +2 -0
- package/dist/agent-src/rules/domain-safety-disclaimer.md +2 -0
- package/dist/agent-src/rules/domain-safety-pii.md +2 -0
- package/dist/agent-src/rules/domain-safety-retention.md +2 -0
- package/dist/agent-src/rules/downstream-changes.md +2 -0
- package/dist/agent-src/rules/engineering-safety-floor.md +2 -0
- package/dist/agent-src/rules/evaluator-independence.md +14 -3
- package/dist/agent-src/rules/external-code-graph-interop.md +4 -1
- package/dist/agent-src/rules/external-reference-deep-dive.md +2 -0
- package/dist/agent-src/rules/fast-path-marker-visibility.md +2 -0
- package/dist/agent-src/rules/finance-safety-floor.md +3 -1
- package/dist/agent-src/rules/framework-neutrality-in-generic-skills.md +2 -0
- package/dist/agent-src/rules/git-history-discipline.md +3 -1
- package/dist/agent-src/rules/guidelines.md +2 -0
- package/dist/agent-src/rules/history-discipline.md +5 -1
- package/dist/agent-src/rules/icon-consistency.md +2 -0
- package/dist/agent-src/rules/image-likeness-and-rights.md +2 -0
- package/dist/agent-src/rules/improve-before-implement.md +2 -0
- package/dist/agent-src/rules/invite-challenge.md +2 -0
- package/dist/agent-src/rules/laravel-routing.md +2 -0
- package/dist/agent-src/rules/laravel-translations.md +2 -0
- package/dist/agent-src/rules/legal-safety-floor.md +3 -1
- package/dist/agent-src/rules/lethal-trifecta-guard.md +2 -0
- package/dist/agent-src/rules/linked-projects-onboarding-gate.md +2 -0
- package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +2 -0
- package/dist/agent-src/rules/markdown-safe-codeblocks.md +2 -0
- package/dist/agent-src/rules/media-governance-routing.md +2 -0
- package/dist/agent-src/rules/media-sync-ground-truth.md +2 -0
- package/dist/agent-src/rules/minimal-safe-diff.md +2 -0
- package/dist/agent-src/rules/missing-tool-handling.md +2 -0
- package/dist/agent-src/rules/model-recommendation.md +2 -0
- package/dist/agent-src/rules/no-attribution-footers.md +2 -0
- package/dist/agent-src/rules/no-decorative-emojis-in-git-surfaces.md +2 -0
- package/dist/agent-src/rules/no-pr-progress-comments.md +5 -1
- package/dist/agent-src/rules/no-roadmap-references.md +2 -0
- package/dist/agent-src/rules/notes-first-reasoning.md +2 -0
- package/dist/agent-src/rules/onboarding-gate.md +5 -1
- package/dist/agent-src/rules/output-discipline.md +2 -0
- package/dist/agent-src/rules/package-ci-checks.md +2 -0
- package/dist/agent-src/rules/persona-governance.md +2 -0
- package/dist/agent-src/rules/php-coding.md +2 -0
- package/dist/agent-src/rules/prefer-enums-over-literals.md +2 -0
- package/dist/agent-src/rules/preservation-guard.md +2 -0
- package/dist/agent-src/rules/provider-lifecycle-discipline.md +2 -0
- package/dist/agent-src/rules/question-not-instruction.md +2 -0
- package/dist/agent-src/rules/reviewer-awareness.md +2 -0
- package/dist/agent-src/rules/roadmap-ci-steps-policy.md +6 -1
- package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
- package/dist/agent-src/rules/role-mode-adherence.md +3 -1
- package/dist/agent-src/rules/rule-type-governance.md +2 -0
- package/dist/agent-src/rules/runtime-safety.md +2 -0
- package/dist/agent-src/rules/scale-discipline.md +5 -1
- package/dist/agent-src/rules/secret-vcs-guard.md +2 -0
- package/dist/agent-src/rules/security-sensitive-stop.md +2 -0
- package/dist/agent-src/rules/self-repair-loop.md +103 -0
- package/dist/agent-src/rules/senior-engineering-discipline.md +2 -0
- package/dist/agent-src/rules/session-canary.md +62 -19
- package/dist/agent-src/rules/settings-ask-protocol.md +4 -0
- package/dist/agent-src/rules/size-enforcement.md +2 -0
- package/dist/agent-src/rules/skill-improvement-trigger.md +2 -0
- package/dist/agent-src/rules/skill-quality.md +2 -0
- package/dist/agent-src/rules/slash-command-routing-policy.md +2 -0
- package/dist/agent-src/rules/source-confidentiality.md +2 -0
- package/dist/agent-src/rules/source-discovery-gate.md +2 -0
- package/dist/agent-src/rules/source-of-truth.md +2 -0
- package/dist/agent-src/rules/spreadsheet-source-quality.md +2 -0
- package/dist/agent-src/rules/strategy-safety-floor.md +3 -1
- package/dist/agent-src/rules/symfony-routing.md +2 -0
- package/dist/agent-src/rules/think-before-action.md +2 -0
- package/dist/agent-src/rules/token-budget-discipline.md +5 -1
- package/dist/agent-src/rules/token-efficiency.md +2 -0
- package/dist/agent-src/rules/token-optimizer-maintenance.md +2 -0
- package/dist/agent-src/rules/tool-safety.md +7 -1
- package/dist/agent-src/rules/ui-audit-gate.md +4 -0
- package/dist/agent-src/rules/untrusted-input-defense.md +2 -0
- package/dist/agent-src/rules/upstream-proposal.md +2 -0
- package/dist/agent-src/rules/user-interaction.md +2 -0
- package/dist/agent-src/rules/user-interrupt-priority.md +2 -0
- package/dist/agent-src/skills/adversarial-review/SKILL.md +1 -1
- package/dist/agent-src/skills/analysis-autonomous-mode/SKILL.md +1 -1
- package/dist/agent-src/skills/performance-analysis/SKILL.md +1 -1
- package/dist/agent-src/skills/persona-improvement/SKILL.md +1 -1
- package/dist/agent-src/skills/project-analyzer/SKILL.md +1 -1
- package/dist/agent-src/skills/security-audit/SKILL.md +1 -1
- package/dist/agent-src/skills/sequential-thinking/SKILL.md +1 -1
- package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +1 -1
- package/dist/agent-src/skills/universal-project-analysis/SKILL.md +1 -1
- package/dist/agent-src/templates/AGENTS.md +6 -10
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +9 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-22GCIJ2O.js +270 -0
- package/dist/cli-delegate/{chunk-BSLUHV2F.js → chunk-2BE5I2MN.js} +156 -141
- package/dist/cli-delegate/{chunk-AIIUU73N.js → chunk-7OMQCH4V.js} +1 -1
- package/dist/cli-delegate/{chunk-HJ6DAJHR.js → chunk-GB2FSUEE.js} +57 -7
- package/dist/cli-delegate/{chunk-WR6GTRHJ.js → chunk-JXARCGVN.js} +3 -3
- package/dist/cli-delegate/{chunk-SJQMJI7M.js → chunk-PQSAQKHW.js} +467 -305
- package/dist/cli-delegate/chunk-REKCMYOU.js +2991 -0
- package/dist/cli-delegate/cmd_analyze_session.js +1 -1
- package/dist/cli-delegate/cmd_brand_status.js +134 -0
- package/dist/cli-delegate/cmd_conformance.js +7 -7
- package/dist/cli-delegate/cmd_converge.js +3 -3
- package/dist/cli-delegate/cmd_doctor.js +4 -4
- package/dist/cli-delegate/cmd_explain.js +80 -330
- package/dist/cli-delegate/cmd_export.js +5 -5
- package/dist/cli-delegate/cmd_fleet.js +3 -3
- package/dist/cli-delegate/cmd_handoff.js +2 -2
- package/dist/cli-delegate/cmd_mcp_available.js +433 -0
- package/dist/cli-delegate/cmd_migrate.js +3 -3
- package/dist/cli-delegate/cmd_packs_active.js +153 -0
- package/dist/cli-delegate/cmd_preflight.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +5 -5
- package/dist/cli-delegate/cmd_route_audit.js +8 -8
- package/dist/cli-delegate/cmd_route_explain.js +4 -4
- package/dist/cli-delegate/cmd_settings_get.js +272 -0
- package/dist/cli-delegate/cmd_settings_migrate.js +5 -5
- package/dist/cli-delegate/cmd_settings_set.js +7 -2967
- package/dist/cli-delegate/cmd_sync.js +5 -5
- package/dist/cli-delegate/cmd_uninstall.js +5 -5
- package/dist/cli-delegate/cmd_update.js +4 -4
- package/dist/cli-delegate/cmd_upgrade.js +4 -4
- package/dist/cli-delegate/cmd_validate.js +5 -5
- package/dist/cli-delegate/cmd_versions.js +4 -4
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +184 -124
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +7 -5
- package/dist/discovery/trust-report.md +11 -11
- package/dist/discovery/workspaces.json +6 -4
- package/dist/hooks/dispatch.js +9080 -5701
- package/dist/install/install.mjs +698 -508
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +698 -437
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/agent_settings.js +80 -15
- package/dist/scripts/_lib/agent_settings.js.map +1 -1
- package/dist/scripts/condense.js +33 -2
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/io/yamlIO.js +85 -0
- package/dist/server/io/yamlIO.js.map +1 -1
- package/dist/server/routes/settings.js +1 -12
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/server/routes/wizard.js +52 -2
- package/dist/server/routes/wizard.js.map +1 -1
- package/dist/shared/settingsCarveOut.js +118 -0
- package/dist/shared/settingsCarveOut.js.map +1 -0
- package/dist/shared/settingsSurface.js +7 -1
- package/dist/shared/settingsSurface.js.map +1 -1
- package/dist/ui/assets/index-DmR0mbk4.js.map +1 -1
- package/docs/contracts/settings-classes.md +29 -8
- package/docs/guidelines/agent-infra/memory-access.md +14 -3
- package/package.json +1 -1
- package/src/agent-src/templates/AGENTS.md +6 -10
- package/src/config/budgets.yml +29 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +26 -0
- package/src/scripts/_cli/cmd_brand_status.ts +174 -0
- package/src/scripts/_cli/cmd_doctor.ts +6 -21
- package/src/scripts/_cli/cmd_mcp_available.ts +266 -0
- package/src/scripts/_cli/cmd_packs_active.ts +225 -0
- package/src/scripts/_cli/cmd_settings_get.ts +324 -0
- package/src/scripts/_dispatch.bash +75 -0
- package/src/scripts/_lib/agent_settings.ts +82 -16
- package/src/scripts/_lib/carrier_divergence.ts +135 -0
- package/src/scripts/_lib/git_common_dir.ts +128 -0
- package/src/scripts/_lib/host_capability.ts +84 -1
- package/src/scripts/_lib/obligation_frequency.ts +391 -0
- package/src/scripts/_lib/prompt_shape.ts +88 -0
- package/src/scripts/_lib/review_skipped_record.ts +102 -0
- package/src/scripts/_lib/rule_layer_overlap.ts +254 -0
- package/src/scripts/_lib/self_repair.ts +342 -0
- package/src/scripts/_lib/self_repair_store.ts +100 -0
- package/src/scripts/_lib/session_register.ts +361 -0
- package/src/scripts/_lib/transcript_entry.ts +80 -0
- package/src/scripts/check_branch_freshness.ts +255 -0
- package/src/scripts/check_council_config_location.ts +125 -1
- package/src/scripts/check_enforcement_coverage.ts +192 -7
- package/src/scripts/check_rule_projection_integrity.ts +364 -0
- package/src/scripts/check_standing_rule_delivery.ts +314 -0
- package/src/scripts/condense.ts +36 -2
- package/src/scripts/config/profiles.ts +13 -3
- package/src/scripts/conformance_scan.ts +307 -24
- package/src/scripts/council_availability_hook.ts +188 -0
- package/src/scripts/council_cli.ts +126 -7
- package/src/scripts/generate_settings_reference.ts +232 -0
- package/src/scripts/hook_manifest.yaml +155 -33
- package/src/scripts/hooks/block_no_verify.ts +95 -3
- package/src/scripts/hooks/block_unauthorized_git.ts +217 -2
- package/src/scripts/hooks/concern_registry.ts +12 -0
- package/src/scripts/hooks/delegation_nudge_hook.ts +450 -0
- package/src/scripts/hooks/end_review_nudge_hook.ts +726 -0
- package/src/scripts/hooks/orchestration_record_hook.ts +342 -0
- package/src/scripts/install.ts +151 -0
- package/src/scripts/language_mirror_hook.ts +49 -6
- package/src/scripts/measure_scope_dedup.ts +19 -13
- package/src/scripts/onboarding_gate_hook.ts +40 -1
- package/src/scripts/orchestration_backfill.ts +354 -0
- package/src/scripts/orchestration_record.ts +4 -1
- package/src/scripts/preamble_byte_census.ts +9 -20
- package/src/scripts/release.ts +142 -36
- package/src/scripts/report_carrier_divergence.ts +364 -0
- package/src/scripts/report_skill_activation.ts +257 -0
- package/src/scripts/report_skill_obligation_violations.ts +579 -0
- package/src/scripts/routing_doctor.ts +77 -6
- package/src/scripts/schemas/rule.schema.json +5 -0
- package/src/scripts/self_repair_cli.ts +240 -0
- package/src/scripts/self_repair_hook.ts +241 -0
- package/src/scripts/session_canary_hook.ts +35 -3
- package/src/scripts/session_register_hook.ts +288 -0
- package/src/scripts/sessions_cli.ts +140 -0
- package/src/scripts/validate_frontmatter.ts +68 -0
- package/src/server/io/yamlIO.ts +87 -0
- package/src/shared/settingsCarveOut.ts +143 -0
- package/src/shared/settingsSurface.ts +7 -1
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared per-session register — so a starting session can see that another
|
|
3
|
+
* session is already live on this repo, on which branch, and on which roadmap.
|
|
4
|
+
*
|
|
5
|
+
* ## Where it lives, and why that needs no new machinery
|
|
6
|
+
*
|
|
7
|
+
* `<git-common-dir>/agent-sessions/<session_id>.json`. The common git directory
|
|
8
|
+
* resolves to the **same** directory from every worktree of a repo, so the
|
|
9
|
+
* register is shared by construction: no new directory to create, no sync step,
|
|
10
|
+
* no generator, and never tracked by git. Resolution is
|
|
11
|
+
* `_lib/git_common_dir.ts` — reused, not re-implemented, because a repo with two
|
|
12
|
+
* different answers to "where is the common dir" is a bug waiting for a
|
|
13
|
+
* symlinked parent.
|
|
14
|
+
*
|
|
15
|
+
* Runtime state under `agents/runtime/state/` cannot serve this purpose:
|
|
16
|
+
* concerns run with `CWD = envelope.workspace_root`, so in a worktree that path
|
|
17
|
+
* is the worktree's own. Measured 2026-08-07 — the main checkout's state
|
|
18
|
+
* directory was populated and a fresh worktree's did not exist at all. Two
|
|
19
|
+
* sessions in two worktrees share nothing there.
|
|
20
|
+
*
|
|
21
|
+
* ## One file per session — one writer each
|
|
22
|
+
*
|
|
23
|
+
* Each session owns exactly one file and is its only writer, so there is no
|
|
24
|
+
* concurrent-write case to solve: atomicity comes from write-temp + rename
|
|
25
|
+
* (`_lib/fs_atomic.ts`), the file never grows, compaction does not exist, the
|
|
26
|
+
* reader does a `readdir` plus N small reads, and cleanup is an `unlink` of
|
|
27
|
+
* expired files during the `session_start` read.
|
|
28
|
+
*
|
|
29
|
+
* The append-only JSONL alternative buys concurrency safety this layout does not
|
|
30
|
+
* need and pays for it with unbounded growth (one record per turn per session
|
|
31
|
+
* once heartbeating), a rotation problem (rotating under live appenders loses
|
|
32
|
+
* heartbeats to the unlinked inode — the classic logrotate defect), and
|
|
33
|
+
* fold-the-whole-file reads.
|
|
34
|
+
*
|
|
35
|
+
* ## Liveness is a heartbeat inside the record — never file mtime
|
|
36
|
+
*
|
|
37
|
+
* `last_seen` is rewritten every turn by a hook, and expiry compares that field.
|
|
38
|
+
* File mtime is explicitly ruled out: `worktree_cleanup_check.ts:65-68` records
|
|
39
|
+
* the failure — plain `git status` refreshes the on-disk index and bumps the very
|
|
40
|
+
* mtime being read as liveness, which moved 10 worktrees from safe to live
|
|
41
|
+
* between two consecutive runs. A field only this concern writes has no such
|
|
42
|
+
* coupling.
|
|
43
|
+
*
|
|
44
|
+
* ## Two accepted limits — declared, not hidden
|
|
45
|
+
*
|
|
46
|
+
* 1. **Idle is indistinguishable from crashed.** A session left open over lunch
|
|
47
|
+
* stops heartbeating, expires, and releases its claim although the user
|
|
48
|
+
* returns. No hook-based heartbeat can tell that apart from a crash. This is
|
|
49
|
+
* accepted, and it belongs in user-facing documentation: walking away for
|
|
50
|
+
* longer than the TTL means another session may claim your branch.
|
|
51
|
+
* 2. **This is advisory, not a mutex.** Two sessions can claim in the same
|
|
52
|
+
* millisecond. Nothing here provides exclusion, and no later feature may be
|
|
53
|
+
* built on it as if it did.
|
|
54
|
+
*
|
|
55
|
+
* ## Failure is always open
|
|
56
|
+
*
|
|
57
|
+
* Every function here swallows its errors and degrades to "no register": a
|
|
58
|
+
* session that cannot read or write it still starts, and behaves exactly as
|
|
59
|
+
* sessions did before this existed.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import fs from 'node:fs';
|
|
63
|
+
import path from 'node:path';
|
|
64
|
+
|
|
65
|
+
import { write_atomic } from './fs_atomic.js';
|
|
66
|
+
import { git_common_dir } from './git_common_dir.js';
|
|
67
|
+
|
|
68
|
+
/** Directory name under the common git dir. Never tracked; git ignores unknown
|
|
69
|
+
* directories inside `.git` by construction. */
|
|
70
|
+
export const REGISTER_DIRNAME = 'agent-sessions';
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Per-host TTL in seconds — how long a record may go without a heartbeat before
|
|
74
|
+
* a reader treats the session as gone.
|
|
75
|
+
*
|
|
76
|
+
* **Derived from data for exactly one host, and honest about the rest.** The
|
|
77
|
+
* chat-history corpus in this repo holds 71 records across 5 sessions over 24.5
|
|
78
|
+
* hours, and every one of them is `claude`; 77 % of the gaps come from a single
|
|
79
|
+
* session. The per-host split the design asked for is **not measurable** from
|
|
80
|
+
* that, so no interpolated values are shipped for hosts nobody observed. Full
|
|
81
|
+
* measurement record: `agents/evidence/analysis/parallel-session-register-phase1.md`.
|
|
82
|
+
*
|
|
83
|
+
* `claude` is taken from the **raw, unfiltered** p99 (13 705 s) and the raw
|
|
84
|
+
* user→user max (14 199 s), rounded up to the next hour. Deliberately *not* the
|
|
85
|
+
* idle-filtered p95: filtering gaps > 30 min characterises turn cadence
|
|
86
|
+
* correctly and is wrong as an expiry basis, because those gaps occur **inside**
|
|
87
|
+
* live sessions — 35 % of user→user gaps exceed 30 minutes while the session is
|
|
88
|
+
* demonstrably still working. A TTL from the filtered p95 would expire a live
|
|
89
|
+
* claim on roughly a third of turns, which is the collision this register exists
|
|
90
|
+
* to prevent, re-created by its own mitigation.
|
|
91
|
+
*/
|
|
92
|
+
export const TTL_MEASURED_SECONDS: Readonly<Record<string, number>> = Object.freeze({
|
|
93
|
+
claude: 14_400, // 4 h — raw p99 13 705 s / raw user→user max 14 199 s, rounded up
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* TTL for a host with **no** measurements: `3 ×` the one measured p99.
|
|
98
|
+
*
|
|
99
|
+
* The name says `MEASURED` above and `DEFAULT` here on purpose (council
|
|
100
|
+
* 2026-08-07, both members): a map holding one real entry plus a fallback is
|
|
101
|
+
* honest only if it is *named* as that, rather than presented as a populated
|
|
102
|
+
* per-host table. Six empty slots would be speculation wearing a data structure.
|
|
103
|
+
*
|
|
104
|
+
* The council also moved this number down from a proposed 24 h. The argument
|
|
105
|
+
* against 24 h: the evidence for a wide spread is that the same host's raw p99
|
|
106
|
+
* in another project corpus is 53 903 s, i.e. a 4× range — which does not
|
|
107
|
+
* justify a 6× extrapolation, and a full day makes the register useless on an
|
|
108
|
+
* unmeasured host, since a crashed session blocks a roadmap claim until
|
|
109
|
+
* tomorrow. 12 h stays comfortably above every observed live-session gap while
|
|
110
|
+
* keeping the claim recoverable within a working day.
|
|
111
|
+
*/
|
|
112
|
+
export const TTL_DEFAULT_SECONDS = 43_200; // 12 h
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolve the TTL for a record, from **the record's own platform** — never the
|
|
116
|
+
* reader's. Session B on one host reading session A's record must apply A's
|
|
117
|
+
* cadence, because the TTL describes how often A heartbeats.
|
|
118
|
+
*/
|
|
119
|
+
export function ttl_seconds_for(platform: string | null | undefined): number {
|
|
120
|
+
const key = String(platform ?? '').trim().toLowerCase();
|
|
121
|
+
return TTL_MEASURED_SECONDS[key] ?? TTL_DEFAULT_SECONDS;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** True when this platform's TTL is a measured value rather than the fallback. */
|
|
125
|
+
export function ttl_is_measured(platform: string | null | undefined): boolean {
|
|
126
|
+
const key = String(platform ?? '').trim().toLowerCase();
|
|
127
|
+
return Object.prototype.hasOwnProperty.call(TTL_MEASURED_SECONDS, key);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Platforms whose `stop` slot means **the task really ended**, so the register
|
|
132
|
+
* entry is deleted there instead of merely refreshed.
|
|
133
|
+
*
|
|
134
|
+
* The general rule is the opposite and stays the default: `stop` is a *second
|
|
135
|
+
* heartbeat carrier*, never a deregistration trigger, because on Claude Code the
|
|
136
|
+
* native `Stop` fires after **every assistant reply** — deregistering there
|
|
137
|
+
* would kill a live session's own record after its first reply.
|
|
138
|
+
*
|
|
139
|
+
* Cline is the one host where that reading is wrong. Its `stop` is mapped from
|
|
140
|
+
* `TaskCancel` (`hook_manifest.yaml:473`) and is typed `per-event` rather than
|
|
141
|
+
* `per-turn` (`_lib/obligation_frequency.ts:269-271`), while its `session_end`
|
|
142
|
+
* is mapped from `TaskComplete` (`:472`). A **cancelled** Cline task therefore
|
|
143
|
+
* reaches neither deregistration path and leaks its claim until the TTL — and
|
|
144
|
+
* cancel-and-restart is a routine Cline workflow, not an edge case.
|
|
145
|
+
*
|
|
146
|
+
* **This is an explicit allow-list, not a computed condition** (council
|
|
147
|
+
* 2026-08-07, both members). Deriving it from `slot_frequency(...) ===
|
|
148
|
+
* 'per-event'` would mean a future platform silently acquires deregistration
|
|
149
|
+
* behaviour the moment someone types it that way, in a code path where the
|
|
150
|
+
* failure mode is "a live session deleted its own claim". A human adds a line
|
|
151
|
+
* here, deliberately, or the platform keeps the safe default.
|
|
152
|
+
*
|
|
153
|
+
* The safety model is unchanged either way: TTL remains the correctness basis on
|
|
154
|
+
* every platform including Cline. This only frees the claim sooner.
|
|
155
|
+
*/
|
|
156
|
+
export const DEREGISTER_ON_STOP_PLATFORMS: ReadonlySet<string> = new Set(['cline']);
|
|
157
|
+
|
|
158
|
+
/** True when `stop` on this platform means the task ended, not "a reply landed". */
|
|
159
|
+
export function stop_means_session_end(platform: string | null | undefined): boolean {
|
|
160
|
+
return DEREGISTER_ON_STOP_PLATFORMS.has(String(platform ?? '').trim().toLowerCase());
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Platforms where a per-turn heartbeat actually fires today.
|
|
165
|
+
*
|
|
166
|
+
* Deliberately **not** derived from the frequency lattice. That instrument
|
|
167
|
+
* models *slot presence*, and two hosts have a slot that never fires:
|
|
168
|
+
*
|
|
169
|
+
* - `cursor` — `user_prompt_submit` and `stop` are **IDE-only**; the CLI fires
|
|
170
|
+
* only shell-execution hooks (`hook_manifest.yaml:365-366`,
|
|
171
|
+
* `chat-history-platform-hooks.md:214`). The lattice has no IDE/CLI dimension,
|
|
172
|
+
* so it reports cursor covered.
|
|
173
|
+
* - `cowork` — slots are structurally wired but lifecycle events do not fire
|
|
174
|
+
* (`hook_manifest.yaml:346-351`).
|
|
175
|
+
* - `copilot` — no hook surface at all (`hook_manifest.yaml:428-429`), excluded
|
|
176
|
+
* from the join by declaration.
|
|
177
|
+
*
|
|
178
|
+
* A session on an excluded host still *registers* if a `session_start` reaches
|
|
179
|
+
* it, and simply stops being visible after its TTL. It is never reported as
|
|
180
|
+
* covered.
|
|
181
|
+
*/
|
|
182
|
+
export const HEARTBEAT_REACHABLE_PLATFORMS: ReadonlySet<string> = new Set([
|
|
183
|
+
'claude',
|
|
184
|
+
'augment', // post-reply `stop` only — sufficient for a liveness stamp
|
|
185
|
+
'cline', // pre-reply `user_prompt_submit` only
|
|
186
|
+
'windsurf',
|
|
187
|
+
'gemini',
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
/** One live session, as seen by every other session on the repo. */
|
|
191
|
+
export interface SessionRecord {
|
|
192
|
+
/** Host-provided session id; also the filename stem. */
|
|
193
|
+
session_id: string;
|
|
194
|
+
/** Host platform — decides which TTL applies to THIS record. */
|
|
195
|
+
platform: string;
|
|
196
|
+
/** Absolute path of the worktree this session is working in. */
|
|
197
|
+
worktree: string;
|
|
198
|
+
/** Branch at the last heartbeat. Re-read every beat; sessions check out. */
|
|
199
|
+
branch: string | null;
|
|
200
|
+
/** Roadmap slug this session claims, or null when it has not picked one. */
|
|
201
|
+
roadmap_slug: string | null;
|
|
202
|
+
/** ISO-8601 UTC, set once at registration. */
|
|
203
|
+
started_at: string;
|
|
204
|
+
/** ISO-8601 UTC, rewritten every heartbeat. The liveness signal. */
|
|
205
|
+
last_seen: string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Filename-safe stem for a session id. Path separators and `..` collapse to `_`
|
|
210
|
+
* so a hostile or merely odd id cannot escape the register directory — the same
|
|
211
|
+
* traversal guard the dispatcher applies to its feedback directory.
|
|
212
|
+
*
|
|
213
|
+
* The fallback fires when the sanitised stem carries **no alphanumeric
|
|
214
|
+
* character**, not merely when it is empty. `'///'` sanitises to `'___'`, which
|
|
215
|
+
* is a legal filename and a useless one: it distinguishes nothing, so two
|
|
216
|
+
* different odd session ids would silently share a record. Requiring one
|
|
217
|
+
* alphanumeric makes the degenerate case land on the explicit marker instead.
|
|
218
|
+
*/
|
|
219
|
+
export function safe_stem(session_id: string): string {
|
|
220
|
+
const collapsed = String(session_id ?? '')
|
|
221
|
+
.replace(/\.\./g, '_')
|
|
222
|
+
.replace(/[/\\]/g, '_')
|
|
223
|
+
.replace(/[^A-Za-z0-9._-]/g, '_')
|
|
224
|
+
.slice(0, 128);
|
|
225
|
+
return /[A-Za-z0-9]/.test(collapsed) ? collapsed : 'unknown-session';
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The register directory for a workspace, or `null` when there is no git common
|
|
230
|
+
* dir to anchor it to. Does **not** create the directory — writers do.
|
|
231
|
+
*/
|
|
232
|
+
export function register_dir(workspace_root: string): string | null {
|
|
233
|
+
const common = git_common_dir(workspace_root);
|
|
234
|
+
return common === null ? null : path.join(common, REGISTER_DIRNAME);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** ISO-8601 UTC at seconds precision, matching the chat-history stamp format. */
|
|
238
|
+
export function iso_now(now: Date = new Date()): string {
|
|
239
|
+
return now.toISOString().replace(/\.\d{3}Z$/, '+00:00');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Parse an ISO stamp to epoch ms; `null` when unparseable. */
|
|
243
|
+
function epoch_ms(stamp: string | null | undefined): number | null {
|
|
244
|
+
if (typeof stamp !== 'string' || stamp.length === 0) return null;
|
|
245
|
+
const ms = Date.parse(stamp);
|
|
246
|
+
return Number.isFinite(ms) ? ms : null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** True when `rec` has not been heard from within its own platform's TTL. */
|
|
250
|
+
export function is_expired(rec: SessionRecord, now: Date = new Date()): boolean {
|
|
251
|
+
const seen = epoch_ms(rec.last_seen);
|
|
252
|
+
if (seen === null) return true; // unreadable stamp → not evidence of life
|
|
253
|
+
const age_s = (now.getTime() - seen) / 1000;
|
|
254
|
+
return age_s > ttl_seconds_for(rec.platform);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function is_record(v: unknown): v is SessionRecord {
|
|
258
|
+
if (typeof v !== 'object' || v === null) return false;
|
|
259
|
+
const o = v as Record<string, unknown>;
|
|
260
|
+
return typeof o['session_id'] === 'string' && typeof o['last_seen'] === 'string';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Write (or overwrite) this session's own record. Returns `true` on success.
|
|
265
|
+
* Never throws — a session that cannot write the register still starts.
|
|
266
|
+
*/
|
|
267
|
+
export function write_record(dir: string, rec: SessionRecord): boolean {
|
|
268
|
+
try {
|
|
269
|
+
const target = path.join(dir, `${safe_stem(rec.session_id)}.json`);
|
|
270
|
+
write_atomic(target, `${JSON.stringify(rec, null, 2)}\n`);
|
|
271
|
+
return true;
|
|
272
|
+
} catch {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Read this session's own record back, or `null` when it is absent/unreadable. */
|
|
278
|
+
export function read_own_record(dir: string, session_id: string): SessionRecord | null {
|
|
279
|
+
try {
|
|
280
|
+
const target = path.join(dir, `${safe_stem(session_id)}.json`);
|
|
281
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(target, 'utf-8'));
|
|
282
|
+
return is_record(parsed) ? parsed : null;
|
|
283
|
+
} catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Remove this session's own record. Best-effort; a failure is not an error. */
|
|
289
|
+
export function delete_record(dir: string, session_id: string): boolean {
|
|
290
|
+
try {
|
|
291
|
+
fs.unlinkSync(path.join(dir, `${safe_stem(session_id)}.json`));
|
|
292
|
+
return true;
|
|
293
|
+
} catch {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export interface ReadLiveOptions {
|
|
299
|
+
/** Unlink expired records as they are encountered. Default `false`. */
|
|
300
|
+
prune?: boolean;
|
|
301
|
+
/** Clock injection for tests. */
|
|
302
|
+
now?: Date;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Every live record in the register, optionally pruning expired ones as it goes.
|
|
307
|
+
*
|
|
308
|
+
* Expiry is evaluated per record against **its own** platform's TTL. A file that
|
|
309
|
+
* does not parse as a record is treated as expired: it carries no evidence of
|
|
310
|
+
* life, and leaving it would make the register grow without bound on a host that
|
|
311
|
+
* writes garbage.
|
|
312
|
+
*
|
|
313
|
+
* Returns `[]` on any failure — including "the directory does not exist", which
|
|
314
|
+
* is the normal state before the first session registers.
|
|
315
|
+
*/
|
|
316
|
+
export function read_live_records(dir: string, options: ReadLiveOptions = {}): SessionRecord[] {
|
|
317
|
+
const now = options.now ?? new Date();
|
|
318
|
+
const prune = options.prune === true;
|
|
319
|
+
let names: string[];
|
|
320
|
+
try {
|
|
321
|
+
names = fs.readdirSync(dir).filter((n) => n.endsWith('.json'));
|
|
322
|
+
} catch {
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
const live: SessionRecord[] = [];
|
|
326
|
+
for (const name of names) {
|
|
327
|
+
const full = path.join(dir, name);
|
|
328
|
+
let rec: SessionRecord | null = null;
|
|
329
|
+
try {
|
|
330
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(full, 'utf-8'));
|
|
331
|
+
rec = is_record(parsed) ? parsed : null;
|
|
332
|
+
} catch {
|
|
333
|
+
rec = null;
|
|
334
|
+
}
|
|
335
|
+
if (rec === null || is_expired(rec, now)) {
|
|
336
|
+
if (prune) {
|
|
337
|
+
try {
|
|
338
|
+
fs.unlinkSync(full);
|
|
339
|
+
} catch {
|
|
340
|
+
/* another session may have pruned it first — fine */
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
live.push(rec);
|
|
346
|
+
}
|
|
347
|
+
return live;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Live records belonging to sessions other than `session_id` — the set a
|
|
352
|
+
* starting session actually cares about.
|
|
353
|
+
*/
|
|
354
|
+
export function foreign_live_records(
|
|
355
|
+
dir: string,
|
|
356
|
+
session_id: string,
|
|
357
|
+
options: ReadLiveOptions = {},
|
|
358
|
+
): SessionRecord[] {
|
|
359
|
+
const own = safe_stem(session_id);
|
|
360
|
+
return read_live_records(dir, options).filter((r) => safe_stem(r.session_id) !== own);
|
|
361
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One reader for a transcript entry's text — the shape half, shared.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS, measured rather than anticipated. A Claude Code transcript
|
|
5
|
+
* carries a user entry's `message.content` in TWO shapes: a bare string, and an
|
|
6
|
+
* array of content blocks. `conformance_scan` handled both; a second reader
|
|
7
|
+
* written against the same field handled only the string, and the store says why
|
|
8
|
+
* that is fatal rather than cosmetic:
|
|
9
|
+
*
|
|
10
|
+
* user entries in one 30-session store: 1 440 string · 23 907 array
|
|
11
|
+
* injected skill bodies among them: 0 via string · 41 via array
|
|
12
|
+
*
|
|
13
|
+
* So the narrower reader saw **none** of them, and the failure was invisible:
|
|
14
|
+
* a detector whose loaded-set is empty returns no findings, which is
|
|
15
|
+
* indistinguishable from compliance. `grep` over the raw JSONL finds the marker
|
|
16
|
+
* either way — reading raw text is not evidence about the parse path, and that
|
|
17
|
+
* is the mistake this module removes the opportunity to repeat.
|
|
18
|
+
*
|
|
19
|
+
* Scope is deliberately just the shape, plus the sidechain exclusion. Semantic
|
|
20
|
+
* filters (a `<system-reminder>` is not a chat message, an injected body is not a
|
|
21
|
+
* user turn) stay with the caller that needs them: they differ per question, and
|
|
22
|
+
* folding them in here would make one module answer two.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** A parsed transcript line. */
|
|
26
|
+
export type TranscriptEntry = Record<string, unknown>;
|
|
27
|
+
|
|
28
|
+
function _isObject(v: unknown): v is Record<string, unknown> {
|
|
29
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A subagent's own turns. Excluded by every reader in this tree: a sidechain turn
|
|
34
|
+
* did not happen in the main thread, so counting it lets a skill loaded in one
|
|
35
|
+
* context be paired with an act taken in another.
|
|
36
|
+
*/
|
|
37
|
+
export function isSidechain(entry: TranscriptEntry): boolean {
|
|
38
|
+
return entry['isSidechain'] === true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Flatten `message.content` to text, whichever shape it arrived in.
|
|
43
|
+
*
|
|
44
|
+
* Returns `''` when there is no text (a tool-result-only entry, an unparseable
|
|
45
|
+
* shape) — never `null`, so a caller cannot forget the second branch.
|
|
46
|
+
*/
|
|
47
|
+
export function entryText(entry: TranscriptEntry): string {
|
|
48
|
+
const msg = entry['message'];
|
|
49
|
+
const content = _isObject(msg) ? msg['content'] : undefined;
|
|
50
|
+
if (typeof content === 'string') {
|
|
51
|
+
return content;
|
|
52
|
+
}
|
|
53
|
+
if (!Array.isArray(content)) {
|
|
54
|
+
return '';
|
|
55
|
+
}
|
|
56
|
+
return content
|
|
57
|
+
.filter((b) => _isObject(b) && b['type'] === 'text')
|
|
58
|
+
.map((b) => String((b as Record<string, unknown>)['text'] ?? ''))
|
|
59
|
+
.join('\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Tool calls in an entry, flattened to name + input. Both shapes are arrays. */
|
|
63
|
+
export function toolUses(entry: TranscriptEntry): Array<{ name: string; input: Record<string, unknown> }> {
|
|
64
|
+
const msg = entry['message'];
|
|
65
|
+
const content = _isObject(msg) ? msg['content'] : undefined;
|
|
66
|
+
if (!Array.isArray(content)) {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
const out: Array<{ name: string; input: Record<string, unknown> }> = [];
|
|
70
|
+
for (const part of content) {
|
|
71
|
+
if (!_isObject(part) || part['type'] !== 'tool_use') {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
out.push({
|
|
75
|
+
name: typeof part['name'] === 'string' ? part['name'] : '',
|
|
76
|
+
input: _isObject(part['input']) ? part['input'] : {},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|