session-orchestrator 5.10.0 → 5.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +12 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +51 -0
- package/README.md +22 -11
- package/SECURITY.md +1 -1
- package/agents/code-implementer.md +7 -1
- package/commands/session.md +3 -1
- package/docs/agent-authoring.md +2 -0
- package/docs/events-schema.md +2 -1
- package/docs/fleet-checkin-audit.md +172 -0
- package/docs/install.md +12 -8
- package/docs/role-profile-contract.md +139 -0
- package/docs/scope-collision-guard.md +3 -0
- package/docs/session-config-reference.md +7 -7
- package/docs/session-config-template.md +2 -2
- package/evals/instruction-ablation/run.mjs +11 -6
- package/hooks/_lib/hook-import-set.json +27 -2
- package/hooks/_lib/orchestrator-ignore.mjs +112 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-end.mjs +8 -1
- package/hooks/on-session-start.mjs +26 -10
- package/hooks/post-bash-write-verify.mjs +16 -3
- package/hooks/pre-task-scope-disjoint.mjs +12 -0
- package/hooks/subagent-telemetry.mjs +8 -3
- package/hooks/wave-scope-commit-guard.mjs +4 -1
- package/package.json +1 -2
- package/rules/opt-in-stack/security-web.md +1 -1
- package/scripts/emit-session.mjs +13 -0
- package/scripts/fleet-checkin-audit.mjs +138 -0
- package/scripts/fleet-checkin.mjs +61 -3
- package/scripts/lib/common.mjs +2 -4
- package/scripts/lib/config/remote-hosts.mjs +2 -2
- package/scripts/lib/config.mjs +1 -1
- package/scripts/lib/context-overhead-format.mjs +57 -0
- package/scripts/lib/fleet-checkin-audit.mjs +244 -0
- package/scripts/lib/fleet-protocol.mjs +17 -0
- package/scripts/lib/memory-proposals/sink.mjs +79 -17
- package/scripts/lib/memory-proposals/store.mjs +245 -9
- package/scripts/lib/project-hygiene.mjs +37 -0
- package/scripts/lib/role-profile/schema.mjs +219 -0
- package/scripts/lib/session-end/vault-orphans.mjs +205 -0
- package/scripts/lib/session-invocation.mjs +14 -3
- package/scripts/lib/session-lock.mjs +57 -10
- package/scripts/lib/session-record-repair.mjs +63 -30
- package/scripts/lib/session-registry.mjs +14 -4
- package/scripts/lib/session-schema/constants.mjs +8 -0
- package/scripts/lib/session-schema/validator.mjs +33 -0
- package/scripts/lib/so-hud/collect.mjs +130 -0
- package/scripts/lib/so-hud/runtime.mjs +76 -0
- package/scripts/lib/state-md/idle-reset.mjs +239 -0
- package/scripts/lib/state-md/recommendations.mjs +26 -4
- package/scripts/lib/subagents-schema.mjs +9 -0
- package/scripts/lib/telemetry/pricing.mjs +11 -0
- package/scripts/lib/vault-mirror/namespace.mjs +3 -2
- package/scripts/lib/vault-mirror/process.mjs +143 -40
- package/scripts/lib/vault-mirror/render-learnings.mjs +6 -5
- package/scripts/lib/vault-mirror/utils.mjs +27 -13
- package/scripts/lib/vault-status/board-writer.mjs +32 -4
- package/scripts/measure-context-overhead.sh +4 -21
- package/scripts/release.mjs +359 -11
- package/scripts/resolve-session-invocation.mjs +64 -8
- package/scripts/run-quality-gate.mjs +47 -0
- package/scripts/so-hud.mjs +45 -0
- package/scripts/state-md-idle-reset.mjs +81 -0
- package/scripts/validate-wave-scope.mjs +59 -6
- package/scripts/vault-orphans.mjs +171 -0
- package/skills/_shared/fleet-protocol.md +24 -2
- package/skills/_shared/state-ownership.md +3 -3
- package/skills/autopilot/SKILL.md +3 -2
- package/skills/claude-md-drift-check/checker.mjs +8 -6
- package/skills/npm-publish/SKILL.md +3 -3
- package/skills/release/SKILL.md +2 -2
- package/skills/repo-audit/SKILL.md +1 -1
- package/skills/session-end/SKILL.md +3 -2
- package/skills/session-end/metrics-collection.md +2 -2
- package/skills/session-end/references/phase-3-7e-vault-orphans.md +63 -0
- package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
- package/skills/session-end/references/session-summary-template.md +4 -0
- package/skills/session-end/session-metrics-write.md +1 -1
- package/skills/session-start/SKILL.md +1 -1
- package/skills/session-start/phase-7-5-mode-selector.md +3 -1
- package/skills/session-start/references/phase-1-2-session-lock.md +1 -1
- package/skills/session-start/references/phase-1-5-session-continuity.md +12 -4
- package/skills/wave-executor/circuit-breaker.md +1 -0
- package/AGENTS.md +0 -205
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Role-profile evidence contract v1
|
|
2
|
+
|
|
3
|
+
Refs #1469. This additive offline contract records role-selection evidence; it does not
|
|
4
|
+
select a model, dispatch an agent, load a registry, start an adapter, or change a pin.
|
|
5
|
+
The test-profile registry under `scripts/lib/profiles/` is a separate contract.
|
|
6
|
+
|
|
7
|
+
## API and meaning
|
|
8
|
+
|
|
9
|
+
`scripts/lib/role-profile/schema.mjs` exports `ROLE_PROFILE_CONTRACT_VERSION` (1)
|
|
10
|
+
and `roleProfileEvidenceSchema.safeParse(value)`:
|
|
11
|
+
|
|
12
|
+
- Success: `{ success: true, data, diagnostics }`. `data` is the original input,
|
|
13
|
+
unchanged, without defaults, normalization or alias resolution.
|
|
14
|
+
- Failure: `{ success: false, error: { issues } }`. Each issue has a fixed `code`,
|
|
15
|
+
field-addressing `path` array and `message`. Messages never interpolate raw input.
|
|
16
|
+
- Codes: `INVALID_TYPE`, `MISSING_FIELD`, `UNKNOWN_FIELD`, `INVALID_VALUE`,
|
|
17
|
+
`INVALID_RELATION`, `UNSUPPORTED_VERSION`. Diagnostics can contain `UNSUPPORTED_EFFORT`.
|
|
18
|
+
|
|
19
|
+
Success means internal structural consistency only. A source reference is a claim
|
|
20
|
+
by the recorder: the validator does not read it, authenticate it, check revocation,
|
|
21
|
+
verify permissions, or confirm that the harness actually used the recorded model.
|
|
22
|
+
No result asserts host availability, account access, quota, cost, quality, or model
|
|
23
|
+
acceptance. An `authorized` record is not an execution authorization issued by this API.
|
|
24
|
+
No I/O, environment lookup, current-clock read, provider call or input mutation occurs.
|
|
25
|
+
|
|
26
|
+
## Fields
|
|
27
|
+
|
|
28
|
+
All fields below are required, including null-valued fields. Objects must have only
|
|
29
|
+
the specified own data properties; arrays, accessors and class instances are rejected.
|
|
30
|
+
Strings must contain a non-whitespace character but are preserved byte for byte.
|
|
31
|
+
IDs and version strings are opaque; unlisted model IDs are accepted without substitution.
|
|
32
|
+
|
|
33
|
+
| Field | Shape and relationship |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `contractVersion` | Integer literal `1`; separate from harness/profile/registry versions |
|
|
36
|
+
| `role`, `taskClass` | Nonempty role name and task classification; no role registry lookup |
|
|
37
|
+
| `provider`, `transport` | Separate nonempty IDs; never inferred from a model name |
|
|
38
|
+
| `binding` | `{status, sourceRef, reason}`; known bindings in v1 are `openai`/`codex-cli` and `anthropic`/`claude-code` |
|
|
39
|
+
| `model` | `{requested, observed, resolution}`; details below |
|
|
40
|
+
| `effort` | `{requested, effective, support}`; details below |
|
|
41
|
+
| `harness` | `{name, version}`; name is a string, version is evidence with `value` |
|
|
42
|
+
| `profile` | `{id, version}`; id is a string, version is evidence with `value` |
|
|
43
|
+
| `registryVersion` | Evidence with `value`; records provenance without creating a registry |
|
|
44
|
+
| `selection` | `{mode, escalationReason}`; mode `profile`, `owner-override`, or `escalation` |
|
|
45
|
+
| `authorization` | `{status, authority, sourceRef, scope, reason}`; details below |
|
|
46
|
+
| `evidence` | Evidence with `timestamp`; known timestamps are real calendar dates in UTC, exactly `YYYY-MM-DDTHH:mm:ss.sssZ` |
|
|
47
|
+
|
|
48
|
+
An evidence object has `{status, <field>, sourceRef, reason}`. With `known`, the
|
|
49
|
+
value and source reference are nonempty strings and reason is null. With `unknown`,
|
|
50
|
+
value and source reference are null and reason is nonempty. Missing evidence is not
|
|
51
|
+
an unknown state. Binding uses the same source/reason convention without a value.
|
|
52
|
+
Unrecognized provider/transport pairs require binding `unknown`, retaining the raw IDs;
|
|
53
|
+
known contradictory pairs are rejected. Binding validity is not transport availability.
|
|
54
|
+
|
|
55
|
+
## Model request, observation and resolution
|
|
56
|
+
|
|
57
|
+
`requested` is `{kind, value}`: `id` or `alias` requires the original string;
|
|
58
|
+
`inherit` requires null. `observed` is evidence with `id`, independently collected.
|
|
59
|
+
Unknown observation requires resolution `unknown`; no requested ID is copied into it.
|
|
60
|
+
A known observation may still have unknown resolution when the relationship is unproven.
|
|
61
|
+
|
|
62
|
+
`resolution` has `{status, baselineId, parent, sourceRef, reason}`:
|
|
63
|
+
|
|
64
|
+
| Status | Required relationship |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `unknown` | baselineId, parent and sourceRef null; reason nonempty |
|
|
67
|
+
| `exact` | Requested kind id, requested value equals observed ID |
|
|
68
|
+
| `alias-resolved` | Requested kind alias, known observation; no drift assertion |
|
|
69
|
+
| `alias-drift` | Requested kind alias, known observation, nonempty baselineId different from observed ID; sourceRef must identify the resolution/baseline evidence |
|
|
70
|
+
| `parent-inherit` | Requested kind inherit; parent `{id, sourceRef}` names the observed ID with separate parent evidence |
|
|
71
|
+
|
|
72
|
+
All non-unknown resolutions require known observation, nonempty sourceRef and null
|
|
73
|
+
reason. baselineId is null outside alias-drift; parent is null outside parent-inherit.
|
|
74
|
+
A changed alias without a documented baseline is not proof of drift. Inheritance
|
|
75
|
+
without parent evidence remains unknown. The validator checks equality and structure,
|
|
76
|
+
not whether any source genuinely proves the relationship.
|
|
77
|
+
|
|
78
|
+
## Effort and capability evidence
|
|
79
|
+
|
|
80
|
+
`requested` is `{provider, dialect, value}`; value is a nonempty string or null
|
|
81
|
+
(no explicit effort requested). `effective` is `{provider, dialect, status, value,
|
|
82
|
+
sourceRef, reason}` with the known/unknown evidence rules above. Both providers must
|
|
83
|
+
match the record provider. Recognized dialects are `openai-reasoning` for openai and
|
|
84
|
+
`anthropic-effort` for anthropic. Unrecognized dialects require both support and
|
|
85
|
+
effective evidence to be unknown, preserving requested values.
|
|
86
|
+
|
|
87
|
+
`support` is `{status, sourceRef, reason}` with `supported`, `unsupported`, or
|
|
88
|
+
`unknown`. The first two require a source and null reason; unknown requires null
|
|
89
|
+
source and a reason. This describes support for the requested effort, as asserted
|
|
90
|
+
by the source, not a live capability table maintained by this validator. Values
|
|
91
|
+
are never translated or ranked numerically across vendors. A familiar label such
|
|
92
|
+
as medium alone proves no support on a particular harness/version/model.
|
|
93
|
+
|
|
94
|
+
Unsupported requests emit `UNSUPPORTED_EFFORT`. An identical value cannot also be
|
|
95
|
+
claimed effective; explicitly unknown effective effort or a different documented
|
|
96
|
+
value can be recorded. Unknown support does not become supported merely because
|
|
97
|
+
a syntactically valid record contains a known effective value.
|
|
98
|
+
|
|
99
|
+
## Escalation and owner authorization
|
|
100
|
+
|
|
101
|
+
Escalation requires a nonempty escalationReason; other selection modes require null.
|
|
102
|
+
Owner-override and escalation require authorization status `authorized`, authority
|
|
103
|
+
`owner`, nonempty sourceRef, null reason and explicit scope:
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{
|
|
107
|
+
"role": "worker", "provider": "openai", "transport": "codex-cli",
|
|
108
|
+
"model": {"kind": "id", "value": "unlisted-model/2030"},
|
|
109
|
+
"effort": {"provider": "openai", "dialect": "openai-reasoning", "value": "medium"}
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Every scope value must match the record's role, provider, transport and original
|
|
114
|
+
model/effort requests. This is an internal coverage comparison, not external policy
|
|
115
|
+
verification. Profile selection can record `unknown` or `not-required`: authority,
|
|
116
|
+
sourceRef and scope are null and reason is nonempty. Neither status grants permission.
|
|
117
|
+
Any authorized record, including profile mode, must carry the full matching scope.
|
|
118
|
+
Historical narratives, cost labels or model preferences never supply missing evidence.
|
|
119
|
+
References must not include tokens, account names, mail addresses or other secrets.
|
|
120
|
+
|
|
121
|
+
## Unknown example and versioning
|
|
122
|
+
|
|
123
|
+
The following fragments belong to an otherwise complete v1 record:
|
|
124
|
+
|
|
125
|
+
```json
|
|
126
|
+
{
|
|
127
|
+
"requested": {"kind": "id", "value": "unlisted-model/2030"},
|
|
128
|
+
"observed": {"status": "unknown", "id": null, "sourceRef": null, "reason": "Not measured"},
|
|
129
|
+
"resolution": {"status": "unknown", "baselineId": null, "parent": null, "sourceRef": null, "reason": "Not resolved"}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
This can be contract-valid; it never proves successful resolution or model acceptance.
|
|
134
|
+
Inline executable complete records live in `tests/lib/role-profile/schema.test.mjs`.
|
|
135
|
+
Unknown contract versions are rejected before interpreting nested evidence. Unknown
|
|
136
|
+
field names are rejected; unknown opaque IDs are retained. New fields or changed
|
|
137
|
+
semantics require a new contract version, explicit handling and migration policy;
|
|
138
|
+
there is no automatic upgrade. This offline slice leaves runtime smokes, dispatch
|
|
139
|
+
integration, precedence measurement, billing, access and rollout acceptance open.
|
|
@@ -211,6 +211,7 @@ Complete list of what this guard does **not** see, or sees only approximately:
|
|
|
211
211
|
9. **The session binding is self-declared** (§ 2.3). `session` is a plain field in a file any process in this working copy can write, so writing a foreign id into it turns the write gate off for that manifest. Named rather than hidden: it is the SAME power `enforcement: "off"` already grants in the same file, so Gate 3b adds no new authority — the manifest is the coordinator's own artefact either way.
|
|
212
212
|
10. **The manifest binding is read by the WRITE gates, not by the dispatch hook.** `enforce-scope`, `enforce-commands`, `post-bash-write-verify` and — since #1493.1 — the git pre-commit `wave-scope-commit-guard` (env-tier identity only, it has no hook payload) classify the manifest via `classifyManifestSession()` and stand down on a foreign one. The dispatch ledger of § 4 takes its session from the harness's own `input.session_id` instead — in its file name since #1493 and in `waveKeyOf(projectDir, sessionId, …)` — and reads only `wave` and `role` out of `wave-scope.json`; the `session` field is not consulted there at all. So a peer session's manifest cannot bind this session's writes or commits, but the hooks reach that property by different routes, and a change to one does not carry to the other.
|
|
213
213
|
11. **A session that published a peer's id reads its OWN manifest as `foreign` (#1194) — the writer's defense is now mechanical (#1207).** Dropping the lock tier (§ 2.3) moved the cost to the other side of the trade: with a raw `sessionAttribution()` read, a session that lost the `bootstrapLock()` race could get the peer's id and write it into its own manifest, and Gate 3b would then classify the manifest `foreign`, standing its own write guard down. Since #1207 the writer calls `attributionForRecord(repoRoot)` instead — `skills/wave-executor/wave-loop.md` § Scope Manifest 1 — which confirms the lock's raw `session_id` against `readProcessLocalSessionIds()` before returning anything and yields `{}` on any mismatch, so the coordinator performs no manual comparison and STATE.md plays no part in it (a shared working-copy artefact written by whichever session holds the lock, not a process-local witness). A filled binding is therefore provably this session's own; unbound (`{}`) = ENFORCE. CEILING (BV-004): on a harness that exports no session env var and puts no `session_id` in the hook payload (Codex CLI, Cursor today) both tiers are empty, so G3b is permanently `unknown` = enforce = pre-#1123 behaviour there. Revisit when Codex/Cursor hook payloads carry a session id.
|
|
214
|
+
12. **The commit guard's identity is whatever the committing shell exports (#1504 point 7b).** `wave-scope-commit-guard` runs inside `git commit` and gets no hook payload, so `readProcessLocalSessionIds()` reads the env only. `CLAUDE_CODE_SESSION_ID=<foreign id> git commit` therefore makes this session's own manifest read as `foreign` and the guard stands down. Named, not fixed (owner decision 2026-10-09): it is the same power `git commit --no-verify` already grants to the same shell, so reading identity from `session.lock` instead would add a failure mode (lock absent or reaped) without closing the bypass. The guard protects against accidents, not against intent. Revisit if `--no-verify` is ever blocked for coordinator sessions.
|
|
214
215
|
|
|
215
216
|
## 7. Debugging
|
|
216
217
|
|
|
@@ -228,6 +229,8 @@ node scripts/validate-wave-scope.mjs --assert-disjoint "$WAVE_SCOPES_SIDECAR" <
|
|
|
228
229
|
|
|
229
230
|
Exit 1 prints one `ERROR:` line per collision (`agents "A" and "B" both claim [...]`) and one per duplicate id. Exit 0 — nothing on stderr, the manifest echoed back on stdout — means the declared scopes really are disjoint and the hook was right to allow; the divergence is then in the prompt, not in the plan. No manifest yet (e.g. `/plan`, before any `wave-scope.json` exists)? Add `--no-manifest` and drop the stdin redirect: the sidecar is checked alone and stdout stays empty — without the flag an empty stdin is still an error, and the flag refuses `--union` / `--assert-subset`, which need the manifest (#1510 f).
|
|
230
231
|
|
|
232
|
+
**`--assert-disjoint` reports a collision with an agent that has already finished (#1026 point 1).** Expected: the CLI checks the declared plan and has no notion of liveness. That keeps its verdict reproducible. The hook (§ 5) is the gate that knows a partner finished. When the coordinator (or a fix agent) takes over a finished agent's file, move the file from the finished agent's record to the new owner's record in the canonical `{id, files}` array, pipe that array into `materialize-wave-scope.mjs --state-dir <state-dir> --wave <N>` again (it rewrites both shapes), then re-assert. Do not mark records as done or bypass the check: an ownership change is a plan change, and the plan is what the CLI certifies (owner decision 2026-10-09).
|
|
233
|
+
|
|
231
234
|
**Reset.** Delete this session's `.orchestrator/wave-dispatch-scopes.<session>.json` (and its `.lock` if a dead holder is suspected) — never a peer's. Both are gitignored here (in a consumer repo only once bootstrap's `store-lock-ignore` step has run, #1495), both are rebuilt on the next dispatch, and neither is shared with any other mechanism or session. After the final wave, `<state-dir>/filescopes/` is deleted along with `wave-scope.json` and this session's ledger file (`skills/wave-executor/references/wave-loop-scope-manifest.md` step 7, session-end 3.0); a crashed session's file stays behind, inert — no other session reads it — a stale `wave-<N>/` directory left behind is a scope claim nobody re-verified.
|
|
232
235
|
|
|
233
236
|
**Disable.** The hook honours the repo's profile gate (`shouldRunHook('pre-task-scope-disjoint')`, row 1) — a silent exit 0, no decision at all.
|
|
@@ -915,7 +915,7 @@ node scripts/memory-propose.mjs \
|
|
|
915
915
|
|
|
916
916
|
A successful dry-run exits `0` with stdout status `dry-run-ok` (see updated exit-codes table below) and never appends to `proposals.jsonl`.
|
|
917
917
|
|
|
918
|
-
`--type` accepts one of the `PROPOSAL_TYPES` enum values (the agent-writable subset of the learnings schema): `mode-selector-accuracy`, `hardware-pattern`, `fragile-file`, `effective-sizing`, `recurring-issue`, `workflow-pattern`, `proven-pattern`, `anti-pattern`, `autopilot-effectiveness`, `domain-regression`, `convention`, `architecture-pattern`, `design-pattern`. Analyzer-only learning types such as `autonomy-verdict` are intentionally excluded because their evidence gates are enforced by `/evolve` analyzers, not by agent proposals. Strings with embedded quotes must be shell-escaped per usual conventions. The CLI appends one JSONL line to `.orchestrator/metrics/proposals.jsonl` (atomic via O_APPEND under the `.orchestrator/metrics/proposals-write.lock` mutex) and updates a per-wave summary at `.orchestrator/metrics/proposals-summary-<wave-id>.json` (counters: queued / dropped / below_floor / fs_error). The coordinator surfaces both files at session-end Phase 3.6.3 to render the AUQ multiSelect; approved entries promote into `.orchestrator/metrics/learnings.jsonl` with `_provenance: agent-proposed@<wave-id>`; rejected entries archive to `.orchestrator/proposals.rejected.log`. Privacy: `proposed_by_agent` is captured in the audit hook (`events.jsonl`) only and is stripped before promotion to learnings.jsonl.
|
|
918
|
+
`--type` accepts one of the `PROPOSAL_TYPES` enum values (the agent-writable subset of the learnings schema): `mode-selector-accuracy`, `hardware-pattern`, `fragile-file`, `effective-sizing`, `recurring-issue`, `workflow-pattern`, `proven-pattern`, `anti-pattern`, `autopilot-effectiveness`, `domain-regression`, `convention`, `architecture-pattern`, `design-pattern`. Analyzer-only learning types such as `autonomy-verdict` are intentionally excluded because their evidence gates are enforced by `/evolve` analyzers, not by agent proposals. Strings with embedded quotes must be shell-escaped per usual conventions. The CLI appends one JSONL line to `.orchestrator/metrics/proposals.jsonl` (atomic via O_APPEND under the `.orchestrator/metrics/proposals-write.lock` mutex) and updates a per-wave summary at `.orchestrator/metrics/proposals-summary-<wave-id>.json` (counters: queued / dropped / below_floor / fs_error). A proposal the quota rejects (`dropped`) is not discarded: its full record is appended to `.orchestrator/metrics/proposals-overflow.jsonl` under the same lock — never counted against the quota, not read by the collector, not cleared at wave end (#1027); if that append fails the CLI exits `4` instead of `1`. The coordinator surfaces both files at session-end Phase 3.6.3 to render the AUQ multiSelect; approved entries promote into `.orchestrator/metrics/learnings.jsonl` with `_provenance: agent-proposed@<wave-id>`; rejected entries archive to `.orchestrator/proposals.rejected.log`. Privacy: `proposed_by_agent` is captured in the audit hook (`events.jsonl`) only and is stripped before promotion to learnings.jsonl.
|
|
919
919
|
|
|
920
920
|
### Exit codes
|
|
921
921
|
|
|
@@ -1357,7 +1357,7 @@ Opt-in declaration of ssh-reachable hosts a heavy wave role may be OFFLOADED to
|
|
|
1357
1357
|
|
|
1358
1358
|
```yaml
|
|
1359
1359
|
remote-hosts:
|
|
1360
|
-
- alias:
|
|
1360
|
+
- alias: build-host # required, SAFE slug; reaches argv as `-H <alias>`
|
|
1361
1361
|
roles-allowed: [test, ui, perf] # subset of test|ui|perf (default: all three)
|
|
1362
1362
|
repo-path: ~/Projects/Alice # optional; SAFE path; default null
|
|
1363
1363
|
claude-path: ~/.local/bin/claude # optional; SAFE path; default null
|
|
@@ -1373,7 +1373,7 @@ remote-hosts:
|
|
|
1373
1373
|
|
|
1374
1374
|
**Two enums, never conflated.** `roles-allowed` holds `agent-mapping` roles (`test`, `ui`, `perf`) — NOT wave roles (`Impl-Core`, `Quality`, …). The wave→role translation is `OFFLOADABLE_WAVE_ROLES` in `scripts/lib/wave-resource-gate.mjs`; a wave role absent from that map is local-only by default.
|
|
1375
1375
|
|
|
1376
|
-
**Placement contract.** The gate applies its offload arm only after the HR-004 heavy-repo cap, and only when the resource verdict was `reduce` or `coordinator-direct` — or, with `offload-first: true`, on `proceed` as well (see § `offload-first` below). It does NOT probe the network: the coordinator supplies a readiness witness (`remoteReady: {
|
|
1376
|
+
**Placement contract.** The gate applies its offload arm only after the HR-004 heavy-repo cap, and only when the resource verdict was `reduce` or `coordinator-direct` — or, with `offload-first: true`, on `proceed` as well (see § `offload-first` below). It does NOT probe the network: the coordinator supplies a readiness witness (`remoteReady: { 'build-host': true }`, or an async `probeFn`). With no witness, no host counts as ready and the decision stays local — the gate fails toward local, never toward an unverified host. A role in `NEVER_FOREIGN_ROLES` (`scripts/lib/wave-executor/dispatch-common.mjs`) is never offloaded regardless.
|
|
1377
1377
|
|
|
1378
1378
|
**agent-mapping interaction.** A declared alias is what an `agent-mapping` value of the form `<role>: ssh:<alias>` validates against; naming an undeclared host throws at parse time, naming the `ssh` channel with no target throws as for any other channel.
|
|
1379
1379
|
|
|
@@ -1396,7 +1396,7 @@ offload-first: false # true = offloadable roles go to a witnessed-ready remote
|
|
|
1396
1396
|
**Behaviour with `offload-first: true`.**
|
|
1397
1397
|
|
|
1398
1398
|
- Only roles in `OFFLOADABLE_WAVE_ROLES` (`scripts/lib/wave-resource-gate.mjs`) move: wave roles `quality` / `test` → `test`, `ui` → `ui`, `perf` → `perf`. The table is NOT extended by this switch — build, lint and audit work is covered by the Quality wave's `test` role. `impl-core`, `security-review`, `migration`, `release`, `secrets`, `incident` and `refactor-crosscut` never leave the local host.
|
|
1399
|
-
- The target is the FIRST declared `remote-hosts` entry whose `roles-allowed` carries the mapped role AND that is witnessed ready (`remoteReady: {
|
|
1399
|
+
- The target is the FIRST declared `remote-hosts` entry whose `roles-allowed` carries the mapped role AND that is witnessed ready (`remoteReady: { 'build-host': true }` or an async `probeFn`). The gate still never probes the network itself, so the witness is mandatory for the switch to have any effect: without one, nothing offloads even with the switch on.
|
|
1400
1400
|
- The HR-004 heavy-repo cap still binds the offloaded agent count.
|
|
1401
1401
|
- No ready host for the role → the verdict is unchanged (`proceed`, or the `reduce`/`coordinator-direct` the resource rules computed) and `reasons` carries `offload-first: no ready host for '<role>' — staying local`.
|
|
1402
1402
|
- Switch on but no `remote-hosts` declared → the verdict is unchanged and `reasons` carries `offload-first: true but no remote-hosts declared — staying local`. `validateSessionConfig()` (`scripts/lib/config-schema.mjs`) additionally emits a warn-level cross-field finding `offload-first-cross-field` for that combination; it never flips `ok`.
|
|
@@ -1531,7 +1531,7 @@ Implementation: `scripts/lib/recommendations-v0.mjs` → `computeV0Recommendatio
|
|
|
1531
1531
|
|
|
1532
1532
|
- Pre-v1.1 STATE.md files (produced by sessions before Epic #271 shipped) simply do not contain these fields. The parser returns `null` from `parseRecommendations`; the banner silently no-ops.
|
|
1533
1533
|
- `schema-version` is NOT bumped to `1.1` in the frontmatter — the existing `schema-version: 1` remains canonical. The v1.1 label is documentation-only, describing the additive surface.
|
|
1534
|
-
- Idle Reset (session-start Phase 1.5)
|
|
1534
|
+
- Idle Reset (session-start Phase 1.5, `scripts/state-md-idle-reset.mjs`) copies these fields into the `## Previous Session` body block of STATE.md as a human-readable block and KEEPS them in the frontmatter: they are the handoff the Phase 7.5 Mode-Selector reads after the reset (#1560). Deleting them, as the reset did before #1560, left the selector at confidence 0 after every clean `/close`. The next session-end Phase 3.7a overwrites them.
|
|
1535
1535
|
|
|
1536
1536
|
### Reader behavior
|
|
1537
1537
|
|
|
@@ -1552,8 +1552,8 @@ The banner renders with one of the following shapes:
|
|
|
1552
1552
|
|
|
1553
1553
|
- **Writer:** `skills/session-end/SKILL.md` § Phase 3.7a "Compute and Write Recommendations" (runs after Phase 3.7 sessions.jsonl write, before Phase 3.4 `status: completed` setting).
|
|
1554
1554
|
- **Reader:** `skills/session-start/SKILL.md` § Phase 1.5 "Recommendations Banner" (renders on `status: completed` branch only).
|
|
1555
|
-
- **Archival:** `skills/session-start/
|
|
1556
|
-
- **
|
|
1555
|
+
- **Archival:** `skills/session-start/references/phase-1-5-session-continuity.md` § Idle Reset rule 6 (keeps the fields in the frontmatter, prepends a readable block to `## Previous Session`).
|
|
1556
|
+
- **Mode-Selector:** `skills/session-start/phase-7-5-mode-selector.md` reads these fields through `buildLiveSignals()` (`scripts/lib/build-live-signals.mjs`) as its primary input.
|
|
1557
1557
|
|
|
1558
1558
|
## Frontend-Slop Hook (#684) — deprecated
|
|
1559
1559
|
|
|
@@ -393,7 +393,7 @@ Opt-in declaration of ssh-reachable hosts that heavy wave roles may be OFFLOADED
|
|
|
393
393
|
|
|
394
394
|
```yaml
|
|
395
395
|
remote-hosts:
|
|
396
|
-
- alias:
|
|
396
|
+
- alias: build-host # required, SAFE slug ([A-Za-z0-9._-]); reaches argv as `-H <alias>`
|
|
397
397
|
roles-allowed: [test, ui, perf] # subset of test|ui|perf (default: all three)
|
|
398
398
|
repo-path: ~/Projects/Alice # optional SAFE path (default: null)
|
|
399
399
|
claude-path: ~/.local/bin/claude # optional SAFE path (default: null)
|
|
@@ -890,7 +890,7 @@ custom-phases:
|
|
|
890
890
|
|
|
891
891
|
# Remote hosts — ssh-reachable offload targets for heavy roles (#1160)
|
|
892
892
|
remote-hosts:
|
|
893
|
-
- alias:
|
|
893
|
+
- alias: build-host # required, SAFE slug; reaches argv as `-H <alias>`
|
|
894
894
|
roles-allowed: [test, ui, perf] # subset of test|ui|perf (default: all three)
|
|
895
895
|
repo-path: ~/Projects/Alice # optional SAFE path (default: null)
|
|
896
896
|
claude-path: ~/.local/bin/claude # optional SAFE path (default: null)
|
|
@@ -243,7 +243,7 @@ function runCell(variant, kase, run) {
|
|
|
243
243
|
if (DRY) return { variant: variant.id, case: kase.id, run, dry: true, workdir: wd };
|
|
244
244
|
|
|
245
245
|
let usage = {};
|
|
246
|
-
let cost =
|
|
246
|
+
let cost = null;
|
|
247
247
|
let text = '';
|
|
248
248
|
let error = null;
|
|
249
249
|
try {
|
|
@@ -255,7 +255,9 @@ function runCell(variant, kase, run) {
|
|
|
255
255
|
});
|
|
256
256
|
const j = JSON.parse(out);
|
|
257
257
|
usage = j.usage || {};
|
|
258
|
-
cost
|
|
258
|
+
// Missing or non-numeric cost stays unknown; a reported 0 stays 0 (#1547).
|
|
259
|
+
const c = j.total_cost_usd;
|
|
260
|
+
cost = typeof c === 'number' && Number.isFinite(c) && c >= 0 ? c : null;
|
|
259
261
|
text = typeof j.result === 'string' ? j.result : '';
|
|
260
262
|
} catch (e) {
|
|
261
263
|
error = String(e.message || e).slice(0, 300);
|
|
@@ -275,7 +277,7 @@ function runCell(variant, kase, run) {
|
|
|
275
277
|
error,
|
|
276
278
|
context_tokens: ctx,
|
|
277
279
|
output_tokens: usage.output_tokens || 0,
|
|
278
|
-
cost_usd: Number(cost.toFixed(4)),
|
|
280
|
+
cost_usd: cost === null ? null : Number(cost.toFixed(4)),
|
|
279
281
|
oracles,
|
|
280
282
|
reply_excerpt: text.slice(0, 300),
|
|
281
283
|
workdir: wd,
|
|
@@ -300,7 +302,7 @@ for (const kase of cases) {
|
|
|
300
302
|
const rec = runCell(variant, kase, run);
|
|
301
303
|
records.push(rec);
|
|
302
304
|
if (!DRY) appendFileSync(outFile, JSON.stringify(rec) + '\n');
|
|
303
|
-
process.stderr.write(rec.dry ? 'built\n' : `${rec.pass ? 'PASS' : 'FAIL'} (${rec.cost_usd ??
|
|
305
|
+
process.stderr.write(rec.dry ? 'built\n' : `${rec.pass ? 'PASS' : 'FAIL'} (${rec.cost_usd ?? 'unknown'} USD)\n`);
|
|
304
306
|
}
|
|
305
307
|
}
|
|
306
308
|
}
|
|
@@ -324,13 +326,16 @@ for (const kase of cases) {
|
|
|
324
326
|
console.log(row.join('\t'));
|
|
325
327
|
}
|
|
326
328
|
|
|
327
|
-
|
|
329
|
+
// One unknown cell makes the total unknown; a partial sum would read as complete.
|
|
330
|
+
const spend = records.some((r) => r.cost_usd === null)
|
|
331
|
+
? 'unknown'
|
|
332
|
+
: records.reduce((a, r) => a + r.cost_usd, 0).toFixed(2);
|
|
328
333
|
const ctxAvg = (id) => {
|
|
329
334
|
const c = records.filter((r) => r.variant === id && r.context_tokens);
|
|
330
335
|
return c.length ? Math.round(c.reduce((a, r) => a + r.context_tokens, 0) / c.length) : 0;
|
|
331
336
|
};
|
|
332
337
|
console.log('\n=== mean context tokens by variant ===');
|
|
333
338
|
for (const v of variants) console.log(`${v.id}\t${ctxAvg(v.id)}`);
|
|
334
|
-
console.log(`\ntotal spend: USD ${spend
|
|
339
|
+
console.log(`\ntotal spend: USD ${spend}`);
|
|
335
340
|
console.log(`results: ${outFile}`);
|
|
336
341
|
console.log(`workdirs: ${ROOT} (throwaway; inspect failures there, remove when done)`);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generated_at": "2026-10-
|
|
3
|
-
"head": "
|
|
2
|
+
"generated_at": "2026-10-10T12:04:56.821Z",
|
|
3
|
+
"head": "f4f6200c067d28025bf70e34296cd5ebabcfc7f8",
|
|
4
4
|
"entries": [
|
|
5
5
|
{
|
|
6
6
|
"file": "hooks/_lib/atomic-json.mjs",
|
|
@@ -34,6 +34,12 @@
|
|
|
34
34
|
"on-session-end.mjs"
|
|
35
35
|
]
|
|
36
36
|
},
|
|
37
|
+
{
|
|
38
|
+
"file": "hooks/_lib/orchestrator-ignore.mjs",
|
|
39
|
+
"reachable_from": [
|
|
40
|
+
"on-session-start.mjs"
|
|
41
|
+
]
|
|
42
|
+
},
|
|
37
43
|
{
|
|
38
44
|
"file": "hooks/_lib/profile-gate.mjs",
|
|
39
45
|
"reachable_from": [
|
|
@@ -1158,6 +1164,19 @@
|
|
|
1158
1164
|
"post-edit-validate.mjs"
|
|
1159
1165
|
]
|
|
1160
1166
|
},
|
|
1167
|
+
{
|
|
1168
|
+
"file": "scripts/lib/native-command.mjs",
|
|
1169
|
+
"reachable_from": [
|
|
1170
|
+
"on-session-end.mjs",
|
|
1171
|
+
"on-session-start.mjs",
|
|
1172
|
+
"on-stop.mjs",
|
|
1173
|
+
"post-bash-issue-budget-refund.mjs",
|
|
1174
|
+
"post-edit-validate.mjs",
|
|
1175
|
+
"post-subagent-discovery-validator.mjs",
|
|
1176
|
+
"pre-bash-issue-budget.mjs",
|
|
1177
|
+
"pre-bash-templates-first.mjs"
|
|
1178
|
+
]
|
|
1179
|
+
},
|
|
1161
1180
|
{
|
|
1162
1181
|
"file": "scripts/lib/orphan-reaper/defaults.mjs",
|
|
1163
1182
|
"reachable_from": [
|
|
@@ -1633,6 +1652,12 @@
|
|
|
1633
1652
|
"skill-invocation-telemetry.mjs"
|
|
1634
1653
|
]
|
|
1635
1654
|
},
|
|
1655
|
+
{
|
|
1656
|
+
"file": "scripts/lib/telemetry/pricing.mjs",
|
|
1657
|
+
"reachable_from": [
|
|
1658
|
+
"subagent-telemetry.mjs"
|
|
1659
|
+
]
|
|
1660
|
+
},
|
|
1636
1661
|
{
|
|
1637
1662
|
"file": "scripts/lib/telemetry/queue.mjs",
|
|
1638
1663
|
"reachable_from": [
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* orchestrator-ignore.mjs — keep the plugin's runtime state out of `git status`
|
|
3
|
+
* in a repo that has never been bootstrapped (#1515 Pkt 5).
|
|
4
|
+
*
|
|
5
|
+
* Measured 2026-10-08 @ 644743cc: one SessionStart in a fresh `git init` repo
|
|
6
|
+
* left five untracked files under `.orchestrator/` (session.lock,
|
|
7
|
+
* current-session.json, host.json, runtime/lock-owner-proof.json,
|
|
8
|
+
* metrics/events.jsonl) and nothing ignored them. Bootstrapped repos get their
|
|
9
|
+
* runtime ignore lines from `skills/bootstrap/_shared-template.md`
|
|
10
|
+
* § store-lock-ignore; an un-bootstrapped one gets nothing.
|
|
11
|
+
*
|
|
12
|
+
* Root, not per writer (BV-003): 23 hook-reachable modules call mkdir, and
|
|
13
|
+
* `.orchestrator/` is created by whichever runs first. SessionStart is the first
|
|
14
|
+
* plugin hook of every session (hooks/hooks.json), so the one call in
|
|
15
|
+
* on-session-start.mjs covers the session. Because SessionStart runs async, a
|
|
16
|
+
* PreToolUse hook MAY have created the directory a moment earlier — which is
|
|
17
|
+
* why the decision keys on "nothing under .orchestrator/ is tracked" (one
|
|
18
|
+
* `git ls-files`), not on "the directory was just created".
|
|
19
|
+
*
|
|
20
|
+
* Invariants:
|
|
21
|
+
* - A repo WITH `.orchestrator/bootstrap.lock` is never given the file. If it
|
|
22
|
+
* carries a file this helper wrote (byte-identical), the file is removed:
|
|
23
|
+
* after bootstrap, steering/, peers/, policy/ and the ledgers are durable
|
|
24
|
+
* project data and must not stay ignored.
|
|
25
|
+
* - An existing `.orchestrator/.gitignore` is never overwritten.
|
|
26
|
+
* - Any tracked path under `.orchestrator/`, or git failing (no repo, no git
|
|
27
|
+
* binary), means: write nothing.
|
|
28
|
+
* - Never throws; returns a reason string for tests and diagnostics.
|
|
29
|
+
*
|
|
30
|
+
* The negations are the exact `.orchestrator/` paths a bootstrap run stages
|
|
31
|
+
* with `git add -- <file>` (BOOTSTRAP_FILES in skills/bootstrap/*-template.md,
|
|
32
|
+
* measured 2026-10-08). The Bootstrap Gate runs bootstrap in the SAME session
|
|
33
|
+
* this file was written, before the next SessionStart can remove it, and git
|
|
34
|
+
* refuses `git add` on an explicitly named ignored path. NAMED CEILING
|
|
35
|
+
* (BV-004): the list is hand-copied; revisit when a template adds a new
|
|
36
|
+
* `BOOTSTRAP_FILES+=(.orchestrator/...)` entry.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { readFile, writeFile, link, unlink, mkdir } from 'node:fs/promises';
|
|
40
|
+
import { existsSync, lstatSync } from 'node:fs';
|
|
41
|
+
import { execFile } from 'node:child_process';
|
|
42
|
+
import path from 'node:path';
|
|
43
|
+
|
|
44
|
+
/** Directories that hold durable project data, never plugin runtime output. */
|
|
45
|
+
const PROJECT_DATA_DIRS = ['steering', 'peers', 'policy'];
|
|
46
|
+
|
|
47
|
+
export const GENERATED_IGNORE = [
|
|
48
|
+
'# Written by session-orchestrator (#1515): this repo has no .orchestrator/bootstrap.lock,',
|
|
49
|
+
'# so the plugin runtime state below stays out of git status. Removed automatically',
|
|
50
|
+
'# at the first session start after /bootstrap.',
|
|
51
|
+
'*',
|
|
52
|
+
'!*/',
|
|
53
|
+
'!/bootstrap.lock',
|
|
54
|
+
'!/metrics/learnings.jsonl',
|
|
55
|
+
'!/metrics/sessions.jsonl',
|
|
56
|
+
'!/metrics/README.md',
|
|
57
|
+
'!/policy/quality-gates.json',
|
|
58
|
+
'',
|
|
59
|
+
].join('\n');
|
|
60
|
+
|
|
61
|
+
function trackedUnderOrchestrator(repoRoot) {
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
execFile('git', ['ls-files', '-z', '--', '.orchestrator'], { cwd: repoRoot, timeout: 2000 }, (err, stdout) => {
|
|
64
|
+
if (err) return resolve(null); // unknown → caller writes nothing
|
|
65
|
+
resolve(stdout.length > 0);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {string} repoRoot
|
|
72
|
+
* @returns {Promise<'written'|'removed'|'bootstrapped'|'present'|'project-data'|'symlink'|'tracked'|'no-git'|'error'>}
|
|
73
|
+
*/
|
|
74
|
+
export async function ensureOrchestratorIgnore(repoRoot) {
|
|
75
|
+
try {
|
|
76
|
+
const dir = path.join(repoRoot, '.orchestrator');
|
|
77
|
+
const target = path.join(dir, '.gitignore');
|
|
78
|
+
if (existsSync(path.join(dir, 'bootstrap.lock'))) {
|
|
79
|
+
if (existsSync(target)) {
|
|
80
|
+
const current = await readFile(target, 'utf8').catch(() => null);
|
|
81
|
+
if (current === GENERATED_IGNORE) {
|
|
82
|
+
await unlink(target);
|
|
83
|
+
return 'removed';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return 'bootstrapped';
|
|
87
|
+
}
|
|
88
|
+
if (existsSync(target)) return 'present';
|
|
89
|
+
// Durable project data (bootstrap/_shared-template.md #store-lock-ignore)
|
|
90
|
+
// the user may hold untracked on purpose: ignoring it would hide it from
|
|
91
|
+
// git status without a word. Only plugin runtime output may be swept.
|
|
92
|
+
if (PROJECT_DATA_DIRS.some((d) => existsSync(path.join(dir, d)))) return 'project-data';
|
|
93
|
+
// A symlinked .orchestrator would put the file outside the repo.
|
|
94
|
+
if (existsSync(dir) && lstatSync(dir).isSymbolicLink()) return 'symlink';
|
|
95
|
+
const tracked = await trackedUnderOrchestrator(repoRoot);
|
|
96
|
+
if (tracked === null) return 'no-git';
|
|
97
|
+
if (tracked) return 'tracked';
|
|
98
|
+
await mkdir(dir, { recursive: true });
|
|
99
|
+
// tmp + link: atomic, and link() fails with EEXIST instead of replacing a
|
|
100
|
+
// .gitignore another process created in between (rename would overwrite).
|
|
101
|
+
const tmp = `${target}.tmp-${process.pid}`;
|
|
102
|
+
await writeFile(tmp, GENERATED_IGNORE, 'utf8');
|
|
103
|
+
try {
|
|
104
|
+
await link(tmp, target);
|
|
105
|
+
} finally {
|
|
106
|
+
await unlink(tmp).catch(() => {});
|
|
107
|
+
}
|
|
108
|
+
return 'written';
|
|
109
|
+
} catch (err) {
|
|
110
|
+
return err && err.code === 'EEXIST' ? 'present' : 'error';
|
|
111
|
+
}
|
|
112
|
+
}
|
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.12.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.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"hooks": [
|
|
7
7
|
{
|
|
8
8
|
"type": "command",
|
|
9
|
-
"command": "echo '🎯 Session Orchestrator v5.
|
|
9
|
+
"command": "echo '🎯 Session Orchestrator v5.12.0 — /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'",
|
|
10
10
|
"async": false
|
|
11
11
|
},
|
|
12
12
|
{
|
package/hooks/on-session-end.mjs
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
release,
|
|
44
44
|
loadOwnerProof,
|
|
45
45
|
OWNER_PROOF_RELPATH,
|
|
46
|
+
isLockOwnedByProof,
|
|
46
47
|
} from '../scripts/lib/session-lock.mjs';
|
|
47
48
|
import { parseSessionId } from '../scripts/lib/session-id.mjs';
|
|
48
49
|
import { isMainModule } from '../scripts/lib/is-main-module.mjs';
|
|
@@ -789,8 +790,14 @@ async function main() {
|
|
|
789
790
|
// millisecond started_at cannot match any FUTURE lock, so it
|
|
790
791
|
// self-invalidates), this just avoids the stale artifact. ENOENT
|
|
791
792
|
// (no proof was ever written) lands in the same swallow.
|
|
793
|
+
// Only when the proof still describes the lock we released: since
|
|
794
|
+
// #1541 every acquire() rewrites it, so a successor that acquired
|
|
795
|
+
// during the awaited emit above owns a fresh proof we must not
|
|
796
|
+
// remove. A narrow window between this read and the unlink remains.
|
|
792
797
|
try {
|
|
793
|
-
|
|
798
|
+
if (isLockOwnedByProof(lock, loadOwnerProof({ repoRoot: projectRoot }))) {
|
|
799
|
+
await fs.unlink(path.join(projectRoot, OWNER_PROOF_RELPATH));
|
|
800
|
+
}
|
|
794
801
|
} catch { /* best-effort — a leftover proof is self-invalidating */ }
|
|
795
802
|
}
|
|
796
803
|
} else {
|
|
@@ -29,16 +29,17 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
32
|
-
import { writeSync } from 'node:fs';
|
|
32
|
+
import { existsSync, writeSync } from 'node:fs';
|
|
33
33
|
import { execFile } from 'node:child_process';
|
|
34
34
|
import { promisify } from 'node:util';
|
|
35
35
|
import { randomUUID } from 'node:crypto';
|
|
36
36
|
import path from 'node:path';
|
|
37
37
|
|
|
38
38
|
import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
39
|
+
import { ensureOrchestratorIgnore } from './_lib/orchestrator-ignore.mjs';
|
|
39
40
|
|
|
40
41
|
import { emitEvent, eventsFilePath } from '../scripts/lib/events.mjs';
|
|
41
|
-
import { readNavigatorLease } from '../scripts/lib/fleet-protocol.mjs';
|
|
42
|
+
import { readNavigatorLease, fleetIntegrationEnabled } from '../scripts/lib/fleet-protocol.mjs';
|
|
42
43
|
import { maybeRotate } from '../scripts/lib/events-rotation.mjs';
|
|
43
44
|
import { readConfigFile, parseSessionConfig } from '../scripts/lib/config.mjs';
|
|
44
45
|
import { getPlatform, resolveProjectDir } from '../scripts/lib/platform.mjs';
|
|
@@ -781,6 +782,12 @@ function maybeQueueConsentNudge() {
|
|
|
781
782
|
async function main() {
|
|
782
783
|
const projectRoot = resolveProjectDir();
|
|
783
784
|
|
|
785
|
+
// #1515 Pkt 5 — before this hook's own writers (host.json, session.lock,
|
|
786
|
+
// events.jsonl) run: an un-bootstrapped repo gets `.orchestrator/.gitignore`
|
|
787
|
+
// so the runtime state never shows up in `git status`. Bootstrapped repos
|
|
788
|
+
// pay two existsSync and nothing else. Never throws.
|
|
789
|
+
await ensureOrchestratorIgnore(projectRoot);
|
|
790
|
+
|
|
784
791
|
// Read optional stdin payload in parallel with git info so we don't stall
|
|
785
792
|
// the hook. Both are best-effort and the promise races against a 500 ms cap
|
|
786
793
|
// inside readStdinJson itself.
|
|
@@ -810,15 +817,23 @@ async function main() {
|
|
|
810
817
|
// null on its own error paths, which would silently re-open the nudge.
|
|
811
818
|
const hostBannerEnabled = await isHostBannerEnabled(projectRoot);
|
|
812
819
|
let bannerData = null;
|
|
820
|
+
// #1559: a git repository with no bootstrap.lock — neither at its toplevel
|
|
821
|
+
// nor under projectRoot (a bootstrapped sub-project) — has one first step,
|
|
822
|
+
// /bootstrap; /go and /close do not work before it. Judged at the toplevel
|
|
823
|
+
// too, so a session started in a subdirectory of a bootstrapped repo is not
|
|
824
|
+
// told to bootstrap again; outside a git repository there is nothing to set up.
|
|
825
|
+
const needsBootstrap = Boolean(topLevel)
|
|
826
|
+
&& !existsSync(path.join(topLevel, '.orchestrator', 'bootstrap.lock'))
|
|
827
|
+
&& !existsSync(path.join(projectRoot, '.orchestrator', 'bootstrap.lock'));
|
|
813
828
|
if (hostBannerEnabled) {
|
|
814
829
|
bannerData = await emitHostBanner(projectRoot);
|
|
815
|
-
//
|
|
816
|
-
//
|
|
817
|
-
//
|
|
818
|
-
//
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
830
|
+
// AUQ-001 in plain words (#1559): name the command when nothing is
|
|
831
|
+
// blocked, read available answers from config / STATE.md / git, and ask
|
|
832
|
+
// only when blocked — then with the question tool, never as a prose list.
|
|
833
|
+
// Before bootstrap, the setup step replaces it as the first action.
|
|
834
|
+
pushBanner(needsBootstrap
|
|
835
|
+
? 'ℹ New here? Start with /bootstrap — it sets up this repo for session-orchestrator.'
|
|
836
|
+
: '🎯 Next step: name the command (/go, /close) when nothing is blocked; read answers from config, STATE.md or git; when blocked, ask with the question tool, never as a prose list.');
|
|
822
837
|
}
|
|
823
838
|
|
|
824
839
|
// F1.3 cold-start abandonment fix (PRD 2026-05-21). Emit a one-shot
|
|
@@ -1033,7 +1048,8 @@ async function main() {
|
|
|
1033
1048
|
// Gated like the peer lines above: no host banner → no navigator line. A read
|
|
1034
1049
|
// failure must never block the hook — the helper is fail-closed itself, the
|
|
1035
1050
|
// try/catch only guards against the unexpected.
|
|
1036
|
-
|
|
1051
|
+
// #1559: solo users without fleet integration see no fleet line.
|
|
1052
|
+
if (bannerData && fleetIntegrationEnabled()) {
|
|
1037
1053
|
try {
|
|
1038
1054
|
pushBanner(navigatorBannerLine(await readNavigatorLease()));
|
|
1039
1055
|
} catch { /* swallow — hook must remain non-blocking */ }
|
|
@@ -1299,10 +1299,23 @@ async function main() {
|
|
|
1299
1299
|
? []
|
|
1300
1300
|
: readPeerScopeRecords(path.dirname(scopePath), scope.wave, sidecarRaw);
|
|
1301
1301
|
if (sidecarBinding.notify) {
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1302
|
+
const relSidecar = path.relative(
|
|
1303
|
+
repoRoot,
|
|
1304
|
+
path.join(path.dirname(scopePath), 'filescopes', `wave-${scope.wave}.scopes.json`),
|
|
1305
|
+
);
|
|
1306
|
+
messages.push(formatSidecarMismatchNotice(relSidecar));
|
|
1305
1307
|
warn = true;
|
|
1308
|
+
// #1514 point 6 (HR-105) — the notice alone left its firing rate unmeasurable.
|
|
1309
|
+
// Same once-per-new-hash dedupe as the notice (`bindPeerSidecar().notify`);
|
|
1310
|
+
// awaited so the append cannot be lost to the process exiting.
|
|
1311
|
+
try {
|
|
1312
|
+
const { emitEvent } = await import('../scripts/lib/events.mjs');
|
|
1313
|
+
await emitEvent(
|
|
1314
|
+
'orchestrator.scope.sidecar_mismatch',
|
|
1315
|
+
{ hook: 'post-bash-write-verify', sidecar: relSidecar.split(path.sep).join('/'), wave: scope.wave },
|
|
1316
|
+
{ repoRoot: context.manifestRoot },
|
|
1317
|
+
);
|
|
1318
|
+
} catch { /* observability is best-effort — never blocks the notice */ }
|
|
1306
1319
|
}
|
|
1307
1320
|
const violations = [];
|
|
1308
1321
|
for (const relPath of report) {
|