session-orchestrator 5.9.0 → 5.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -0
- package/CHANGELOG.md +65 -2
- package/README.md +11 -9
- package/SECURITY.md +1 -1
- package/agents/code-implementer.md +7 -1
- package/docs/USER-GUIDE.md +2 -0
- package/docs/agent-authoring.md +2 -0
- package/docs/ci-setup.md +46 -1
- package/docs/events-schema.md +37 -6
- package/docs/fleet-checkin-audit.md +172 -0
- package/docs/owner-config-schema.md +18 -1
- package/docs/owner-privacy-patterns.md +85 -0
- package/docs/role-profile-contract.md +139 -0
- package/docs/scope-collision-guard.md +15 -2
- package/docs/session-config-reference.md +1 -1
- 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/enforce-commands.mjs +9 -19
- package/hooks/enforce-scope.mjs +23 -42
- 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 +7 -0
- package/hooks/post-bash-write-verify.mjs +54 -24
- 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 +2 -2
- package/rules/opt-in-stack/security-web.md +1 -1
- package/scripts/emit-session.mjs +28 -0
- package/scripts/fleet-checkin-audit.mjs +138 -0
- package/scripts/lib/common.mjs +2 -4
- package/scripts/lib/context-overhead-format.mjs +57 -0
- package/scripts/lib/fleet-checkin-audit.mjs +244 -0
- package/scripts/lib/gates/gate-full.mjs +6 -0
- package/scripts/lib/gates/gate-helpers.mjs +2 -0
- package/scripts/lib/memory-proposals/sink.mjs +79 -17
- package/scripts/lib/memory-proposals/store.mjs +245 -9
- package/scripts/lib/named-vault-resolver.mjs +1 -1
- package/scripts/lib/owner-yaml.mjs +33 -3
- package/scripts/lib/platform.mjs +70 -1
- package/scripts/lib/pre-push-remote-gate.mjs +107 -0
- package/scripts/lib/project-hygiene.mjs +37 -0
- package/scripts/lib/quality-gate.mjs +6 -1
- package/scripts/lib/redact-spans.mjs +8 -17
- package/scripts/lib/role-profile/schema.mjs +219 -0
- package/scripts/lib/scope-gate.mjs +68 -5
- package/scripts/lib/session-lock.mjs +57 -10
- package/scripts/lib/session-record-repair.mjs +63 -30
- package/scripts/lib/session-schema/constants.mjs +8 -0
- package/scripts/lib/session-schema/validator.mjs +33 -0
- package/scripts/lib/subagents-schema.mjs +9 -0
- package/scripts/lib/telemetry/pricing.mjs +11 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +198 -1015
- package/scripts/lib/vault-backfill/template.mjs +1 -1
- package/scripts/lib/vault-mirror/render-sessions.mjs +15 -3
- package/scripts/lib/vault-status/board-writer.mjs +15 -7
- package/scripts/measure-context-overhead.sh +4 -21
- package/scripts/migrate-vault-paths.mjs +57 -60
- package/scripts/release.mjs +463 -48
- package/scripts/run-quality-gate.mjs +47 -0
- package/scripts/validate-wave-scope.mjs +59 -6
- package/scripts/vault-consolidate.mjs +19 -4
- package/skills/claude-md-drift-check/SKILL.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +22 -9
- 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/metrics-collection.md +28 -4
- package/skills/session-end/session-metrics-write.md +1 -1
- package/skills/session-start/references/phase-1-2-session-lock.md +1 -1
- package/skills/wave-executor/circuit-breaker.md +1 -0
- package/skills/wave-executor/references/wave-loop-review.md +26 -2
|
@@ -126,7 +126,7 @@ hardware-sharing: { enabled: true }
|
|
|
126
126
|
|
|
127
127
|
# ACCEPTED — all four required sections present and valid
|
|
128
128
|
owner:
|
|
129
|
-
name:
|
|
129
|
+
name: Example Owner
|
|
130
130
|
language: de
|
|
131
131
|
tone:
|
|
132
132
|
style: direct
|
|
@@ -136,3 +136,20 @@ efficiency:
|
|
|
136
136
|
hardware-sharing:
|
|
137
137
|
enabled: false
|
|
138
138
|
```
|
|
139
|
+
|
|
140
|
+
## Owner privacy pattern source
|
|
141
|
+
|
|
142
|
+
Host-local owner leakage patterns use a separate protected version 1 JSON file, not an owner.yaml section. See [Owner privacy patterns](owner-privacy-patterns.md) for source precedence, schema, migration and strict packed-release checks. `paths.vault-dir` remains the canonical vault target used by the vault migration and consolidation tools; `SO_VAULT_DIR` overrides it.
|
|
143
|
+
|
|
144
|
+
### Host-local drift path exceptions (#937)
|
|
145
|
+
|
|
146
|
+
Optional `drift-check.foreign-host-prefixes` is an array of absolute directory
|
|
147
|
+
prefixes belonging to known foreign hosts, for example
|
|
148
|
+
`/Users/remote-user/Projects/foreign-layout`. Missing `/Users/…` references
|
|
149
|
+
under a declared prefix become warnings; other missing paths remain errors.
|
|
150
|
+
Matching respects directory boundaries and accepts a trailing slash. Prefixes
|
|
151
|
+
must contain at least three components (four under `/Users` or `/home`); roots,
|
|
152
|
+
home directories, broad Projects roots, traversal, globs and malformed entries
|
|
153
|
+
are rejected. One invalid entry drops the entire optional `drift-check` section
|
|
154
|
+
while preserving other valid owner settings. This section does not change
|
|
155
|
+
`drift-check.mode` or its precedence. No default allowlist is supplied.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Host-local owner privacy patterns
|
|
2
|
+
|
|
3
|
+
The owner-leakage scanner keeps host-specific usernames, endpoints, project names and allowed public contacts in a protected file outside every repository. The package ships generic rules and invented test fixtures. Public author and copyright attribution remain public; operational paths and private endpoints belong in local configuration.
|
|
4
|
+
|
|
5
|
+
## Configure a host
|
|
6
|
+
|
|
7
|
+
Create `owner-patterns.json` using your own private values. Do not commit it, copy it into a release tree, or print it into CI logs. Resolution follows this order:
|
|
8
|
+
|
|
9
|
+
1. `SO_OWNER_PATTERNS_FILE`: an explicit absolute filename.
|
|
10
|
+
2. `SO_CONFIG_HOME/owner-patterns.json`.
|
|
11
|
+
3. `XDG_CONFIG_HOME/session-orchestrator/owner-patterns.json`.
|
|
12
|
+
4. `~/.config/session-orchestrator/owner-patterns.json`.
|
|
13
|
+
|
|
14
|
+
Empty or whitespace-only environment values are unset. An explicit relative filename is unsafe. The file must be regular, owned by the current user, have exactly mode `0600` and a single hard link, and be outside any Git repository, including ignored directories. Its parent and user-owned ancestors must not be group/world writable. User-controlled symlink ancestors and a symlink file are rejected; root-owned operating-system aliases such as macOS `/tmp` are allowed above the protected parent.
|
|
15
|
+
|
|
16
|
+
For a new directory, use a private umask and create the file with mode `0600`. Choose the directory according to the resolution above; this example uses the default:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
umask 077
|
|
20
|
+
mkdir -p "$HOME/.config/session-orchestrator"
|
|
21
|
+
# Create owner-patterns.json in that directory with a local editor.
|
|
22
|
+
chmod 600 "$HOME/.config/session-orchestrator/owner-patterns.json"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Existing directory permissions may remain `0755` if they meet the ownership/writability requirements. The file is limited to 64 KiB. Use a local protected file on each host that needs these checks; it is separate from [owner.yaml](owner-config-schema.md).
|
|
26
|
+
|
|
27
|
+
## Version 1 schema
|
|
28
|
+
|
|
29
|
+
The object has `version: 1` and these optional arrays of literal strings. Omitted arrays become empty; explicit `null`, unknown fields and invalid types fail validation. Strings are escaped as literals rather than interpreted as regular expressions. Each array permits at most 256 strings, each at most 256 UTF-16 code units, without leading/trailing whitespace or ASCII control characters below U+0020. At least one blocking array must be nonempty.
|
|
30
|
+
|
|
31
|
+
| Field | Purpose |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `usernamePrefixes` | Personal home-path and bare username prefixes |
|
|
34
|
+
| `privateHosts` | Private service hosts |
|
|
35
|
+
| `eventsHosts` | Private events service hosts |
|
|
36
|
+
| `privateDomains` | Private domain catch-all |
|
|
37
|
+
| `packageScopes` | Private package scope names, **without** the leading `@` |
|
|
38
|
+
| `privateSlugs` | Private project/repository slugs |
|
|
39
|
+
| `vaultClearSlugs` | Members of `privateSlugs` allowed by the synchronous vault namespace consumer; case-insensitive subset |
|
|
40
|
+
| `personalNames` | Personal segments in home-relative `Projects` paths |
|
|
41
|
+
| `publicEmails` | Exact published email contacts permitted by occurrence |
|
|
42
|
+
| `publicUrls` | Exact public publication URLs permitted in designated attribution files |
|
|
43
|
+
|
|
44
|
+
This invented example demonstrates the schema; replace it locally rather than treating these values as protection for your host:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"version": 1,
|
|
49
|
+
"usernamePrefixes": ["sampleg"],
|
|
50
|
+
"privateHosts": ["gitlab.example.invalid"],
|
|
51
|
+
"eventsHosts": ["events.example.invalid"],
|
|
52
|
+
"privateDomains": ["example.invalid"],
|
|
53
|
+
"packageScopes": ["example"],
|
|
54
|
+
"privateSlugs": ["project-secret", "project-public"],
|
|
55
|
+
"vaultClearSlugs": ["project-public"],
|
|
56
|
+
"personalNames": ["SampleOwner"],
|
|
57
|
+
"publicEmails": ["office@example.invalid"],
|
|
58
|
+
"publicUrls": ["https://www.example.invalid", "www.example.invalid"]
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Public contacts exempt only matching occurrences. A line that combines a published contact with a private host still fails. Public URL exceptions apply to designated publication files such as the root README, plugin manifests and site attribution pages. They do not grant every file permission to contain a private domain. Clearance affects the synchronous vault namespace consumer; the full scanner still checks private slugs.
|
|
63
|
+
|
|
64
|
+
## Scans and releases
|
|
65
|
+
|
|
66
|
+
An ordinary scan with no host file visibly warns that host-specific rules are unavailable. Generic rules and independently configured confidential-name checks continue. An unsafe or malformed source fails the scan. The synchronous namespace guard stays synchronous and conservatively redacts identities for an invalid/unsafe policy or an explicitly configured missing source.
|
|
67
|
+
|
|
68
|
+
Release checks require the source with `--require-owner-patterns`. Packed scans also read the actual contents listed in npm's JSON inventory:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
node scripts/lib/validate/check-owner-leakage.mjs /absolute/extracted/package \
|
|
72
|
+
--require-owner-patterns --packed-files /absolute/inventory.json
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Use an inventory describing the actual extracted tarball and scan that extraction, not just a list of filenames. Packed mode checks all listed regular files and rejects unsafe paths, symlinks and unreadable entries. Ordinary test/file exclusions and CP8/CP10 filename exceptions do not exempt packed contents. Violation output keeps checkpoint IDs, counts and safe file/line locations, omits offending content, and replaces private filename segments; it does not publish configured policy values. This detects the configured patterns, not every possible unknown secret.
|
|
76
|
+
|
|
77
|
+
Confidential customer-name checking (CP11) remains separately configured through `SO_CONFIDENTIAL_NAMES_FILE` or `owner.yaml` `paths.confidential-names-file`. A configured unusable CP11 source fails closed; absence of that optional source does not disable the other rules.
|
|
78
|
+
|
|
79
|
+
## Migration and configured vault paths
|
|
80
|
+
|
|
81
|
+
Hosts upgrading from the previous embedded pattern lists must privately migrate the values they relied on to this version 1 file before strict release checks. Preserve blocking entries, narrowly justified public contacts/URLs and vault clearance; verify mode and a strict scan without printing the source. Do not substitute invented fixture values or remove protection to pass a release. New hosts opt in by creating their own protected policy.
|
|
82
|
+
|
|
83
|
+
Vault tools resolve the canonical vault from `SO_VAULT_DIR`, then `owner.yaml` `paths.vault-dir`. `vault-consolidate.mjs --canonical` overrides both; it retains the generic source default `~/Projects/vault`, and a source equal to the canonical real path is a no-op before backups or writes. Existing sources require a configured canonical destination. A missing source retains its harmless no-op behavior.
|
|
84
|
+
|
|
85
|
+
`migrate-vault-paths.mjs` replaces narrowly matched legacy `vault-dir` bases with the whole configured target, preserving suffixes, scalar quoting and comments. It skips the vault-target migration when unconfigured or invalid, without inserting a guessed personal segment; its separately configured username rewrite still applies. Historical and username-owned lines retain their existing protections. Preview either tool before applying it.
|
|
@@ -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.
|
|
@@ -74,7 +74,17 @@ The event is what keeps the skip countable rather than silent: `orchestrator.sco
|
|
|
74
74
|
|
|
75
75
|
The grant is exactly one directory: containment is exact-prefix on the realpath-resolved candidate (both the raw and the canonical project root are encoded, since Claude Code names the directory after the path it was launched with), so a SIBLING repo's memory dir, an adjacent-prefix sibling directory (`<memory>-evil/`), a `..` traversal out of it, and every other out-of-repo path stay denied. One honest caveat: the harness name is not unique — `encodeProjectDir()` maps both `/` and `.` to `-`, so two repo roots differing only in `.` vs `-` (`/x/a.b` and `/x/a-b`) share ONE memory dir. That collision is the harness's, inherited here and not narrowable by this gate — and in-repo enforcement, including Discovery's `allowedPaths: []` deny-all, is untouched. The encoder is bound lazily and a load failure falls through to the deny (fail-closed). **Who the grant is for (#1352): the COORDINATOR only.** The carve-out opens a directory whose content is auto-injected as trusted project context into every later session, outside `git diff`, CI review, owner-leakage and gitleaks — so a dispatched wave agent must not reach it. The discriminator is the payload's **`agent_id`**, and it is measured, not assumed (Claude Code 2.1.270, `strings` over the shipped binary, 2026-09-13): the base hook-input builder every event shares is `{session_id, transcript_path: yf(session.id), cwd, …, agent_id: toolUseContext?.agentId, agent_type}`, which PreToolUse spreads verbatim, and the harness's own schema says of `agent_id` — *"Present only when the hook fires from within a subagent… Absent for the main thread, even in `--agent` sessions. Use this field (not `agent_type`) to distinguish subagent calls from main-thread calls."* The issue's original candidate is refuted by the same source: `transcript_path` is derived from the SESSION id, so coordinator and subagent carry the identical value; so is `CLAUDE_CODE_SESSION_ID`, and `CLAUDE_CODE_CHILD_SESSION=1` was measured in a main-thread `env` dump (`isSidechain:false`).
|
|
76
76
|
|
|
77
|
-
`classifyCaller()` therefore returns `subagent` (non-empty `agent_id` → **no carve-out**; the write falls through to the normal gates, which is a DENY under Discovery's `allowedPaths: []`), `coordinator` (no `agent_id` but a non-empty `agent_type` — per the schema that combination is the main thread of an `--agent` session), or `absent` (neither field).
|
|
77
|
+
`classifyCaller()` therefore returns `subagent` (non-empty `agent_id` → **no carve-out**; the write falls through to the normal gates, which is a DENY under Discovery's `allowedPaths: []`), `coordinator` (no `agent_id` but a non-empty `agent_type` — per the schema that combination is the main thread of an `--agent` session), or `absent` (neither field). **For a caller outside a verified isolated agent checkout, `absent` keeps the pre-#1352 ALLOW, deliberately fail-OPEN:** fail-closed on ambiguity would deny the coordinator's own memory writes and break `/close`, and a harness that sends no marker is indistinguishable from a main thread. The outcome rides on the existing `orchestrator.scope.memory_dir_allowed` event as `discriminator`, so the fail-open case is countable rather than silent (HR-105) — revisit if `absent` ever dominates the records on a harness that does dispatch subagents.
|
|
78
|
+
|
|
79
|
+
### 2.5 Isolated agent worktrees (#1504 items 1–2)
|
|
80
|
+
|
|
81
|
+
`resolveScopeContext()` separates the coordinator's **manifest root** from the agent's **write root**. `enforce-scope`, `enforce-commands` and `post-bash-write-verify` use this context; `resolveSessionRoot()` keeps its existing lifecycle behavior. A relative grant such as `src/**` applies to the actual agent checkout. Writing the coordinator's `src/` requires an explicit absolute grant, and isolated agents receive neither coordinator control-file nor memory-directory carveouts, even when their hook payload omits `agent_id`.
|
|
82
|
+
|
|
83
|
+
Discovery requires the Claude harness agent-directory shape and reciprocal Git worktree metadata belonging to the launch checkout's common Git directory. A local own, unbound or corrupt manifest retains precedence. When no local authority exists, the resolver inspects only registered worktree roots, at most 256 entries, and adopts a uniquely owned manifest using process-local session identity. Multiple owned coordinators, unreadable discovery or an unsafe remote manifest deny instead of becoming unrestricted absence. Unknown or foreign remote manifests cannot supply authority. Ordinary checkout and no-match behavior remain unchanged; arbitrary subdirectories of registered worktrees are not searched.
|
|
84
|
+
|
|
85
|
+
The post-Bash hook reads control state from the coordinator but observes Git changes in the agent checkout. Its saved observations are keyed by both roots and the manifest path, preventing one agent's snapshot from hiding another's changes.
|
|
86
|
+
|
|
87
|
+
These guarantees require the named hooks to run. Native Codex `exec_command` and `apply_patch` are not wired to these Claude `PreToolUse` matchers. Codex sessions still require explicit scope manifests, disjoint assignments and diff verification; this change does not claim native pre-tool enforcement. The environment-only commit guard and dispatch-ledger retention are separate mechanisms.
|
|
78
88
|
|
|
79
89
|
## 3. The collision algorithm
|
|
80
90
|
|
|
@@ -86,7 +96,7 @@ The grant is exactly one directory: containment is exact-prefix on the realpath-
|
|
|
86
96
|
- **3a — witness:** expand both entries against `KNOWN = knownFiles ∪ {every concrete entry of every agent}`; a non-empty intersection is `glob-expanded`. The second half of that union matters — a file the wave is about to *create* is not in `git ls-files`, but a concrete claim by one agent can still witness another's glob.
|
|
87
97
|
- **3b — prefix fallback:** for the intersection that exists only in files not yet on disk. Requires literal-prefix containment in either direction, at least one **recursive** entry (`**`, or a trailing `/`, which `pathMatchesPattern` matches by `startsWith` at any depth), and compatible literal suffixes. The suffix filter is a necessary condition, so it adds no false negative while removing `scripts/**/*.ts` vs `scripts/**/*.mjs`.
|
|
88
98
|
|
|
89
|
-
`knownFiles` is **injected, never discovered
|
|
99
|
+
`knownFiles` is **injected, never discovered** by the collision helpers: they remain pure and synchronous, with no I/O or spawning. The separate scope-context resolver in the same module performs bounded synchronous filesystem reads (§ 2.5). Under the exit-0/stdout-JSON protocol, an uncaught throw in a hook reads as "no decision" = ALLOW. The CLI spawns `git ls-files` in `knownRepoFiles()`; the hook does the same in `listTrackedFiles()`, both resolving `git rev-parse --show-toplevel` first so a session started in a subdirectory produces repo-relative paths on both sides.
|
|
90
100
|
|
|
91
101
|
Duplicate agent ids are reported separately (`duplicateIds`), not as a self-collision. A record with no usable id runs as `<unnamed#i>` rather than being dropped — an unreviewed scope is exactly the one that collides.
|
|
92
102
|
|
|
@@ -201,6 +211,7 @@ Complete list of what this guard does **not** see, or sees only approximately:
|
|
|
201
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.
|
|
202
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.
|
|
203
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.
|
|
204
215
|
|
|
205
216
|
## 7. Debugging
|
|
206
217
|
|
|
@@ -218,6 +229,8 @@ node scripts/validate-wave-scope.mjs --assert-disjoint "$WAVE_SCOPES_SIDECAR" <
|
|
|
218
229
|
|
|
219
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).
|
|
220
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
|
+
|
|
221
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.
|
|
222
235
|
|
|
223
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
|
|
|
@@ -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
|
+
}
|
|
@@ -95,10 +95,9 @@ 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 resolveSessionRoot;
|
|
99
98
|
let readJson;
|
|
100
99
|
// #1504 point 6 — bound from scope-gate below.
|
|
101
|
-
let
|
|
100
|
+
let resolveScopeContext;
|
|
102
101
|
let extractBashWriteTargets;
|
|
103
102
|
let pathMatchesPattern;
|
|
104
103
|
/** @type {typeof import('../scripts/lib/session-identity/own-session.mjs').readProcessLocalSessionIds} */
|
|
@@ -208,10 +207,9 @@ async function bootstrap() {
|
|
|
208
207
|
);
|
|
209
208
|
|
|
210
209
|
({ readStdin, emitAllow, emitDeny, emitWarn } = modules.io);
|
|
211
|
-
({ resolveSessionRoot } = modules.platform);
|
|
212
210
|
({ readJson } = modules.common);
|
|
213
211
|
({ extractBashWriteTargets, pathMatchesPattern } = modules.hardening);
|
|
214
|
-
({
|
|
212
|
+
({ resolveScopeContext } = modules.scopeGate);
|
|
215
213
|
({ readProcessLocalSessionIds, classifyManifestSession } = modules.sessionIdentity);
|
|
216
214
|
blocker = modules.blocker;
|
|
217
215
|
degradedLabels = degraded;
|
|
@@ -281,21 +279,13 @@ async function main() {
|
|
|
281
279
|
);
|
|
282
280
|
}
|
|
283
281
|
|
|
284
|
-
//
|
|
285
|
-
//
|
|
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.
|
|
282
|
+
// Coordinator control state and isolated-agent write targets may live in
|
|
283
|
+
// different working copies; the shared guard context resolves both.
|
|
292
284
|
const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
// is foreign does the first come back, and G3b below stands down as before.
|
|
298
|
-
const located = findOwnScopeFile(projectRoot, ownIds, classifyManifestSession);
|
|
285
|
+
const context = resolveScopeContext({ cwd: input.cwd, ownIds, classify: classifyManifestSession });
|
|
286
|
+
if (context.error) return emitDeny('Scope coordinator cannot be resolved', context.error);
|
|
287
|
+
const projectRoot = context.writeRoot;
|
|
288
|
+
const located = context;
|
|
299
289
|
const scopePath = located.path ?? located.foreignPath;
|
|
300
290
|
if (!scopePath) return flushNotices(notices);
|
|
301
291
|
|
|
@@ -350,7 +340,7 @@ async function main() {
|
|
|
350
340
|
wave: scope.wave,
|
|
351
341
|
command_hash: hashCommand(command),
|
|
352
342
|
},
|
|
353
|
-
{ repoRoot:
|
|
343
|
+
{ repoRoot: context.manifestRoot },
|
|
354
344
|
);
|
|
355
345
|
} catch { /* observability is best-effort — never blocks the decision */ }
|
|
356
346
|
return flushNotices(notices);
|