@etiquekit/etq 1.0.11 → 1.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +19 -26
- package/LICENSE +2 -3
- package/NOTICE +6 -4
- package/QuickStart.md +35 -47
- package/README.md +54 -56
- package/bin/etiquette +1 -5
- package/bin/etiquette-core +1 -5
- package/docs/ARCHITECTURE.md +5 -5
- package/docs/CODEX_CLIENT_COMPATIBILITY.md +1 -1
- package/docs/CONCEPTS.md +7 -23
- package/docs/CONCEPT_STATUS.md +66 -0
- package/docs/CORE_PROFILE.md +3 -5
- package/docs/LANE_PROVISIONING.md +7 -2
- package/docs/README.md +4 -4
- package/docs/RELEASE_SURFACE_AUDIT.md +7 -6
- package/docs/SEAT_DISCIPLINE.md +55 -91
- package/docs/SEAT_PROVISIONING.md +19 -22
- package/docs/TEAM_HANDOFF.md +21 -21
- package/docs/WORKTREE_QOL.md +7 -6
- package/docs/contracts/ledger-entry/README.md +11 -11
- package/docs/contracts/ledger-entry/ledger-entry.v0.2.md +3 -3
- package/docs/contracts/ledger-entry/ledger-entry.v0.md +8 -8
- package/package.json +11 -11
- package/packages/control/src/authority/lease.ts +261 -0
- package/packages/control/src/authority/node-delegation.ts +257 -0
- package/packages/control/src/authority/rig-conductor.ts +632 -0
- package/packages/control/src/cli/argv.ts +155 -0
- package/packages/control/src/cli/commands/console.ts +200 -0
- package/packages/control/src/cli/commands/dispatch-core.ts +89 -0
- package/packages/control/src/cli/commands/dispatch.ts +279 -0
- package/packages/control/src/cli/commands/harness.ts +89 -0
- package/packages/control/src/cli/commands/hook.ts +50 -0
- package/packages/control/src/cli/commands/ledger.ts +91 -0
- package/packages/control/src/cli/commands/local-workflow.ts +4686 -0
- package/packages/control/src/cli/commands/memory.ts +445 -0
- package/packages/control/src/cli/commands/release.ts +108 -0
- package/packages/control/src/cli/commands/rubric.ts +103 -0
- package/packages/control/src/cli/commands/seat.ts +179 -0
- package/packages/control/src/cli/commands/session.ts +127 -0
- package/packages/control/src/cli/commands/supervision.ts +246 -0
- package/packages/control/src/cli/commands/sync.ts +119 -0
- package/packages/control/src/cli/commands/workflow.ts +86 -0
- package/packages/control/src/cli/commands/workspace.ts +50 -0
- package/packages/control/src/cli/core-usage.ts +34 -0
- package/packages/control/src/cli/prompt.ts +67 -0
- package/packages/control/src/cli/supervision-deps.ts +44 -0
- package/packages/control/src/cli/usage.ts +241 -0
- package/packages/control/src/cli.ts +207 -0
- package/packages/control/src/core-cli.ts +50 -0
- package/packages/control/src/dispatch/decision.ts +202 -0
- package/packages/control/src/dispatch/projection.ts +293 -0
- package/packages/control/src/dispatch/record.ts +153 -0
- package/packages/control/src/engagement/project.ts +170 -0
- package/packages/control/src/fs.ts +19 -0
- package/packages/control/src/harness/pruning.ts +406 -0
- package/packages/control/src/hooks/dispatcher.ts +117 -0
- package/packages/control/src/hooks/outbox.ts +86 -0
- package/packages/control/src/hooks/sanitize.ts +6 -0
- package/packages/control/src/hooks/types.ts +34 -0
- package/packages/control/src/index.ts +384 -0
- package/packages/control/src/ledger/entry.ts +303 -0
- package/packages/control/src/ledger/indexer.ts +542 -0
- package/packages/control/src/memory/context.ts +149 -0
- package/packages/control/src/memory/drain-import.ts +207 -0
- package/packages/control/src/memory/indexer.ts +284 -0
- package/packages/control/src/memory/query.ts +75 -0
- package/packages/control/src/memory/sanitize.ts +50 -0
- package/packages/control/src/memory/sharded-drain-import.ts +212 -0
- package/packages/control/src/memory/status.ts +211 -0
- package/packages/control/src/memory/store-lifecycle.ts +509 -0
- package/packages/control/src/memory/store.ts +284 -0
- package/packages/control/src/memory/types.ts +146 -0
- package/packages/control/src/parity/surfaces.ts +748 -0
- package/packages/control/src/project.ts +141 -0
- package/packages/control/src/projection/local-ledger-view.ts +373 -0
- package/packages/control/src/projection/return-enforcement.ts +48 -0
- package/packages/control/src/projection/timeline-preview.ts +539 -0
- package/packages/control/src/projection/timeline.ts +708 -0
- package/packages/control/src/release/readiness.ts +831 -0
- package/packages/control/src/rubric/loader.ts +326 -0
- package/packages/control/src/rubric/promotion.ts +54 -0
- package/packages/control/src/rubric/runner.ts +159 -0
- package/packages/control/src/rubric/types.ts +158 -0
- package/packages/control/src/seat/owner-card.ts +388 -0
- package/packages/control/src/seat/readiness.ts +834 -0
- package/packages/control/src/session/runbook.ts +431 -0
- package/packages/control/src/shared/sanitize.ts +49 -0
- package/packages/control/src/supervision/action-classes.ts +192 -0
- package/packages/control/src/supervision/command-apply.ts +378 -0
- package/packages/control/src/supervision/errors.ts +14 -0
- package/packages/control/src/supervision/event-replay.ts +155 -0
- package/packages/control/src/supervision/events.ts +109 -0
- package/packages/control/src/supervision/index.ts +16 -0
- package/packages/control/src/supervision/manifest.ts +127 -0
- package/packages/control/src/supervision/paths.ts +49 -0
- package/packages/control/src/supervision/projection-adapter.ts +274 -0
- package/packages/control/src/supervision/projection.ts +75 -0
- package/packages/control/src/supervision/rebuild.ts +99 -0
- package/packages/control/src/supervision/session-open.ts +131 -0
- package/packages/control/src/supervision/session-read.ts +99 -0
- package/packages/control/src/supervision/sqlite-impl.ts +71 -0
- package/packages/control/src/supervision/sqlite.ts +121 -0
- package/packages/control/src/supervision/store-rows.ts +371 -0
- package/packages/control/src/supervision/turn-close.ts +154 -0
- package/packages/control/src/supervision/turn-open.ts +284 -0
- package/packages/control/src/sync/event-log-merge-driver.ts +263 -0
- package/packages/control/src/sync/join-plan.ts +375 -0
- package/packages/control/src/sync/outbox.ts +492 -0
- package/packages/control/src/workflow/evaluator.ts +140 -0
- package/packages/control/src/workflow/loader.ts +200 -0
- package/packages/control/src/workflow/types.ts +90 -0
- package/packages/control/src/workspace/authority.ts +499 -0
- package/packages/protocol/src/guards.ts +119 -0
- package/packages/protocol/src/huddle-board.ts +198 -0
- package/packages/protocol/src/huddle.ts +295 -0
- package/packages/protocol/src/incident.ts +251 -0
- package/packages/protocol/src/index.ts +8 -0
- package/packages/protocol/src/interfaces.ts +107 -0
- package/packages/protocol/src/packet-profile.ts +195 -0
- package/packages/protocol/src/state.ts +81 -0
- package/packages/protocol/src/types.ts +434 -0
- package/release/lineage.v0.json +14 -0
- package/scripts/release-candidate-verify.sh +175 -0
- package/scripts/release-checksum.sh +25 -0
- package/scripts/release-pack-canary.sh +97 -0
- package/scripts/release-scan.sh +249 -0
- package/scripts/release-sign.sh +34 -0
- package/templates/etiquette-vanilla-v0/README.md +2 -3
- package/templates/etiquette-vanilla-v0/source/control-seat/README.md +0 -6
- package/templates/etiquette-vanilla-v0/source/control-seat/bin/seat-doctor +0 -5
- package/templates/etiquette-vanilla-v0/validate-vanilla.sh +0 -5
- package/templates/seat-packs-v0/README.md +3 -2
- package/templates/seat-packs-v0/source/claude-code-seat/README.md +3 -29
- package/templates/seat-packs-v0/source/codex-seat/README.md +3 -17
- package/templates/seat-packs-v0/source/gemini-seat/README.md +3 -23
- package/templates/seat-packs-v0/source/ollama-seat/README.md +3 -3
- package/templates/seat-packs-v0/source/openrouter-seat/README.md +3 -3
- package/docs/AGENT_SPAWNING.md +0 -78
- package/docs/INTEGRATION_UI.md +0 -215
- package/docs/OPERATOR_PLAYBOOK.md +0 -77
- package/docs/TAG_ROUTE.md +0 -95
- package/lib/etiquette-core.js +0 -315
- package/lib/etiquette.js +0 -1160
- package/templates/DISPATCH_PREMISES.md +0 -39
- package/templates/etiquette-vanilla-v0/source/control-seat/bin/access-assurance-check +0 -55
- package/templates/etiquette-vanilla-v0/source/control-seat/docs/work/access/ACCESS-ASSURANCE.md +0 -39
- package/templates/etiquette-vanilla-v0/source/control-seat/docs/work/access/workspace-secure-profile.v0.json +0 -53
- package/templates/hosted-receiver/README.md +0 -41
- package/templates/hosted-receiver/w1-github-webhook-receiver.mjs +0 -129
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { extname } from 'node:path';
|
|
4
|
+
import { parse as parseYaml } from 'yaml';
|
|
5
|
+
import { assertSharedMemoryScopeId, assertSharedMemoryText, detectMemoryRiskFlags } from '../memory/sanitize';
|
|
6
|
+
|
|
7
|
+
type JsonRecord = Record<string, unknown>;
|
|
8
|
+
|
|
9
|
+
export const HARNESS_OBJECT_CLASSES = [
|
|
10
|
+
'prompt',
|
|
11
|
+
'skill',
|
|
12
|
+
'schema',
|
|
13
|
+
'adapter',
|
|
14
|
+
'ui',
|
|
15
|
+
'runbook',
|
|
16
|
+
'infra',
|
|
17
|
+
'other',
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
export const HARNESS_AUTHORITY_CLASSES = [
|
|
21
|
+
'ergonomic',
|
|
22
|
+
'evidence',
|
|
23
|
+
'authority',
|
|
24
|
+
'recovery',
|
|
25
|
+
'unknown',
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
export const HARNESS_CONSUMER_STATUSES = [
|
|
29
|
+
'active',
|
|
30
|
+
'nascent',
|
|
31
|
+
'stale',
|
|
32
|
+
'unknown',
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
export const HARNESS_DISPOSITIONS = [
|
|
36
|
+
'keep',
|
|
37
|
+
'consolidate',
|
|
38
|
+
'quarantine',
|
|
39
|
+
'delete-ready',
|
|
40
|
+
'blocked',
|
|
41
|
+
] as const;
|
|
42
|
+
|
|
43
|
+
export const HARNESS_CANARIES = [
|
|
44
|
+
'ordinary-run',
|
|
45
|
+
'onboarding',
|
|
46
|
+
'recovery',
|
|
47
|
+
'auth-drift',
|
|
48
|
+
'single-writer-or-drain',
|
|
49
|
+
'receipt-verification',
|
|
50
|
+
'degraded-fail-closed',
|
|
51
|
+
] as const;
|
|
52
|
+
|
|
53
|
+
export type HarnessObjectClass = (typeof HARNESS_OBJECT_CLASSES)[number];
|
|
54
|
+
export type HarnessAuthorityClass = (typeof HARNESS_AUTHORITY_CLASSES)[number];
|
|
55
|
+
export type HarnessConsumerStatus = (typeof HARNESS_CONSUMER_STATUSES)[number];
|
|
56
|
+
export type HarnessDisposition = (typeof HARNESS_DISPOSITIONS)[number];
|
|
57
|
+
export type HarnessCanary = (typeof HARNESS_CANARIES)[number];
|
|
58
|
+
|
|
59
|
+
export interface HarnessPruneAuthorityBoundary {
|
|
60
|
+
can_grant: false;
|
|
61
|
+
can_close_lane: false;
|
|
62
|
+
can_merge: false;
|
|
63
|
+
can_delete: false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface HarnessPruneReceipt {
|
|
67
|
+
schema: 'harness-prune-receipt.v0';
|
|
68
|
+
object_id: string;
|
|
69
|
+
object_class: HarnessObjectClass;
|
|
70
|
+
authority_class: HarnessAuthorityClass;
|
|
71
|
+
consumer_status: HarnessConsumerStatus;
|
|
72
|
+
proposed_disposition: HarnessDisposition;
|
|
73
|
+
evidence_refs: string[];
|
|
74
|
+
canary_set: HarnessCanary[];
|
|
75
|
+
quarantine_until?: string;
|
|
76
|
+
next_owner: string;
|
|
77
|
+
authority: HarnessPruneAuthorityBoundary;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface HarnessReceiptValidation {
|
|
81
|
+
schema: 'harness-prune-receipt-validation.v0';
|
|
82
|
+
receipt_id: string;
|
|
83
|
+
object_id: string;
|
|
84
|
+
verdict: 'valid';
|
|
85
|
+
proposed_disposition: HarnessDisposition;
|
|
86
|
+
canary: HarnessCanaryResult;
|
|
87
|
+
authority_boundary: HarnessPruneAuthorityBoundary & {
|
|
88
|
+
receipt_can_authorize_pruning: false;
|
|
89
|
+
human_disposition_required: true;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface HarnessCanaryResult {
|
|
94
|
+
schema: 'harness-canary-result.v0';
|
|
95
|
+
object_id: string;
|
|
96
|
+
required_canaries: HarnessCanary[];
|
|
97
|
+
observed_canaries: HarnessCanary[];
|
|
98
|
+
missing_canaries: HarnessCanary[];
|
|
99
|
+
verdict: 'pass' | 'blocked';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface HarnessInventoryItem {
|
|
103
|
+
object_id: string;
|
|
104
|
+
object_class: HarnessObjectClass;
|
|
105
|
+
authority_class: HarnessAuthorityClass;
|
|
106
|
+
consumer_status: HarnessConsumerStatus;
|
|
107
|
+
evidence_refs: string[];
|
|
108
|
+
roadmap_ref?: string;
|
|
109
|
+
last_seen_ref?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface HarnessInventoryManifest {
|
|
113
|
+
schema: 'harness-inventory.v0';
|
|
114
|
+
generated_by?: string;
|
|
115
|
+
items: HarnessInventoryItem[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface HarnessInventoryReportItem extends HarnessInventoryItem {
|
|
119
|
+
suggested_disposition: HarnessDisposition;
|
|
120
|
+
reason: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface HarnessInventoryReport {
|
|
124
|
+
schema: 'harness-inventory-report.v0';
|
|
125
|
+
item_count: number;
|
|
126
|
+
counts: Record<HarnessDisposition, number>;
|
|
127
|
+
items: HarnessInventoryReportItem[];
|
|
128
|
+
authority_boundary: {
|
|
129
|
+
inventory_can_delete: false;
|
|
130
|
+
inventory_can_grant: false;
|
|
131
|
+
inventory_can_close_lane: false;
|
|
132
|
+
pruning_receipt_required: true;
|
|
133
|
+
human_disposition_required: true;
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function stableHash(value: unknown): string {
|
|
138
|
+
return createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 16);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function asRecord(value: unknown, name: string): JsonRecord {
|
|
142
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
143
|
+
throw new Error(`${name} must be an object`);
|
|
144
|
+
}
|
|
145
|
+
return value as JsonRecord;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function asString(record: JsonRecord, key: string): string {
|
|
149
|
+
const value = record[key];
|
|
150
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
151
|
+
throw new Error(`${key} must be a non-empty string`);
|
|
152
|
+
}
|
|
153
|
+
return value.trim();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function optionalString(record: JsonRecord, key: string): string | undefined {
|
|
157
|
+
const value = record[key];
|
|
158
|
+
if (value === undefined) return undefined;
|
|
159
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
160
|
+
throw new Error(`${key} must be a non-empty string when present`);
|
|
161
|
+
}
|
|
162
|
+
return value.trim();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function asStringArray(record: JsonRecord, key: string): string[] {
|
|
166
|
+
const value = record[key];
|
|
167
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || item.trim().length === 0)) {
|
|
168
|
+
throw new Error(`${key} must be a non-empty string array`);
|
|
169
|
+
}
|
|
170
|
+
return value.map((item) => item.trim());
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function asEnum<T extends readonly string[]>(values: T, raw: unknown, key: string): T[number] {
|
|
174
|
+
if (typeof raw !== 'string' || !(values as readonly string[]).includes(raw)) {
|
|
175
|
+
throw new Error(`${key} must be one of: ${values.join(', ')}`);
|
|
176
|
+
}
|
|
177
|
+
return raw as T[number];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function asEnumArray<T extends readonly string[]>(values: T, raw: unknown, key: string): T[number][] {
|
|
181
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
182
|
+
throw new Error(`${key} must be a non-empty array`);
|
|
183
|
+
}
|
|
184
|
+
return raw.map((item, index) => asEnum(values, item, `${key}[${index}]`));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function assertOpaqueText(value: string, field: string): void {
|
|
188
|
+
assertSharedMemoryText(value, field);
|
|
189
|
+
if (value.startsWith('/') || value.startsWith('\\') || /^[A-Za-z]:[\\/]/.test(value)) {
|
|
190
|
+
throw new Error(`${field} must be opaque or repo-relative, not an absolute local path`);
|
|
191
|
+
}
|
|
192
|
+
if (/[a-z][a-z0-9+.-]*:\/\//i.test(value)) {
|
|
193
|
+
throw new Error(`${field} must not be a URL`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function parseAuthorityBoundary(raw: unknown): HarnessPruneAuthorityBoundary {
|
|
198
|
+
const record = asRecord(raw, 'authority');
|
|
199
|
+
const boundary = {
|
|
200
|
+
can_grant: record.can_grant,
|
|
201
|
+
can_close_lane: record.can_close_lane,
|
|
202
|
+
can_merge: record.can_merge,
|
|
203
|
+
can_delete: record.can_delete,
|
|
204
|
+
};
|
|
205
|
+
for (const [key, value] of Object.entries(boundary)) {
|
|
206
|
+
if (value !== false) throw new Error(`authority.${key} must be false`);
|
|
207
|
+
}
|
|
208
|
+
return boundary as HarnessPruneAuthorityBoundary;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function parseHarnessPruneReceipt(value: unknown): HarnessPruneReceipt {
|
|
212
|
+
const record = asRecord(value, 'harness_prune_receipt');
|
|
213
|
+
const receipt: HarnessPruneReceipt = {
|
|
214
|
+
schema: asEnum(['harness-prune-receipt.v0'] as const, record.schema, 'schema'),
|
|
215
|
+
object_id: asString(record, 'object_id'),
|
|
216
|
+
object_class: asEnum(HARNESS_OBJECT_CLASSES, record.object_class, 'object_class') as HarnessObjectClass,
|
|
217
|
+
authority_class: asEnum(HARNESS_AUTHORITY_CLASSES, record.authority_class, 'authority_class') as HarnessAuthorityClass,
|
|
218
|
+
consumer_status: asEnum(HARNESS_CONSUMER_STATUSES, record.consumer_status, 'consumer_status') as HarnessConsumerStatus,
|
|
219
|
+
proposed_disposition: asEnum(HARNESS_DISPOSITIONS, record.proposed_disposition, 'proposed_disposition') as HarnessDisposition,
|
|
220
|
+
evidence_refs: asStringArray(record, 'evidence_refs'),
|
|
221
|
+
canary_set: asEnumArray(HARNESS_CANARIES, record.canary_set, 'canary_set') as HarnessCanary[],
|
|
222
|
+
quarantine_until: optionalString(record, 'quarantine_until'),
|
|
223
|
+
next_owner: asString(record, 'next_owner'),
|
|
224
|
+
authority: parseAuthorityBoundary(record.authority),
|
|
225
|
+
};
|
|
226
|
+
validateHarnessPruneReceiptShape(receipt);
|
|
227
|
+
return receipt;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function parseHarnessInventoryManifest(value: unknown): HarnessInventoryManifest {
|
|
231
|
+
const record = asRecord(value, 'harness_inventory');
|
|
232
|
+
if (asEnum(['harness-inventory.v0'] as const, record.schema, 'schema') !== 'harness-inventory.v0') {
|
|
233
|
+
throw new Error('schema must be harness-inventory.v0');
|
|
234
|
+
}
|
|
235
|
+
const itemsRaw = record.items;
|
|
236
|
+
if (!Array.isArray(itemsRaw) || itemsRaw.length === 0) throw new Error('items must be a non-empty array');
|
|
237
|
+
const items = itemsRaw.map((item, index): HarnessInventoryItem => {
|
|
238
|
+
const entry = asRecord(item, `items[${index}]`);
|
|
239
|
+
const parsed = {
|
|
240
|
+
object_id: asString(entry, 'object_id'),
|
|
241
|
+
object_class: asEnum(HARNESS_OBJECT_CLASSES, entry.object_class, `items[${index}].object_class`) as HarnessObjectClass,
|
|
242
|
+
authority_class: asEnum(HARNESS_AUTHORITY_CLASSES, entry.authority_class, `items[${index}].authority_class`) as HarnessAuthorityClass,
|
|
243
|
+
consumer_status: asEnum(HARNESS_CONSUMER_STATUSES, entry.consumer_status, `items[${index}].consumer_status`) as HarnessConsumerStatus,
|
|
244
|
+
evidence_refs: asStringArray(entry, 'evidence_refs'),
|
|
245
|
+
roadmap_ref: optionalString(entry, 'roadmap_ref'),
|
|
246
|
+
last_seen_ref: optionalString(entry, 'last_seen_ref'),
|
|
247
|
+
};
|
|
248
|
+
validateInventoryItem(parsed, `items[${index}]`);
|
|
249
|
+
return parsed;
|
|
250
|
+
});
|
|
251
|
+
const generatedBy = optionalString(record, 'generated_by');
|
|
252
|
+
if (generatedBy) assertSharedMemoryScopeId(generatedBy, 'generated_by');
|
|
253
|
+
return { schema: 'harness-inventory.v0', ...(generatedBy ? { generated_by: generatedBy } : {}), items };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function readStructured(path: string): Promise<unknown> {
|
|
257
|
+
const raw = await readFile(path, 'utf-8');
|
|
258
|
+
if (extname(path).toLowerCase() === '.json') return JSON.parse(raw) as unknown;
|
|
259
|
+
return parseYaml(raw) as unknown;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function validateRefs(values: string[], field: string): void {
|
|
263
|
+
for (const [index, value] of values.entries()) assertOpaqueText(value, `${field}[${index}]`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function validateInventoryItem(item: HarnessInventoryItem, field: string): void {
|
|
267
|
+
assertOpaqueText(item.object_id, `${field}.object_id`);
|
|
268
|
+
validateRefs(item.evidence_refs, `${field}.evidence_refs`);
|
|
269
|
+
if (item.roadmap_ref) assertOpaqueText(item.roadmap_ref, `${field}.roadmap_ref`);
|
|
270
|
+
if (item.last_seen_ref) assertOpaqueText(item.last_seen_ref, `${field}.last_seen_ref`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function requiredCanariesFor(receipt: Pick<HarnessPruneReceipt, 'proposed_disposition'>): HarnessCanary[] {
|
|
274
|
+
if (receipt.proposed_disposition === 'delete-ready') return [...HARNESS_CANARIES];
|
|
275
|
+
return ['ordinary-run', 'receipt-verification'];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function evaluateHarnessCanary(receipt: HarnessPruneReceipt): HarnessCanaryResult {
|
|
279
|
+
const required = requiredCanariesFor(receipt);
|
|
280
|
+
const observed = [...new Set(receipt.canary_set)];
|
|
281
|
+
const missing = required.filter((canary) => !observed.includes(canary));
|
|
282
|
+
return {
|
|
283
|
+
schema: 'harness-canary-result.v0',
|
|
284
|
+
object_id: receipt.object_id,
|
|
285
|
+
required_canaries: required,
|
|
286
|
+
observed_canaries: observed,
|
|
287
|
+
missing_canaries: missing,
|
|
288
|
+
verdict: missing.length === 0 ? 'pass' : 'blocked',
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function validateHarnessPruneReceiptShape(receipt: HarnessPruneReceipt): void {
|
|
293
|
+
assertOpaqueText(receipt.object_id, 'object_id');
|
|
294
|
+
validateRefs(receipt.evidence_refs, 'evidence_refs');
|
|
295
|
+
assertSharedMemoryScopeId(receipt.next_owner, 'next_owner');
|
|
296
|
+
if (receipt.quarantine_until) assertOpaqueText(receipt.quarantine_until, 'quarantine_until');
|
|
297
|
+
|
|
298
|
+
const refFlags = receipt.evidence_refs.flatMap((ref) => detectMemoryRiskFlags(ref));
|
|
299
|
+
if (refFlags.length > 0) throw new Error(`evidence_refs contain restricted shared content: ${[...new Set(refFlags)].join(', ')}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function validateHarnessPruneReceipt(receipt: HarnessPruneReceipt): void {
|
|
303
|
+
validateHarnessPruneReceiptShape(receipt);
|
|
304
|
+
if (receipt.consumer_status === 'nascent' && receipt.proposed_disposition === 'delete-ready') {
|
|
305
|
+
throw new Error('nascent harness surfaces must be quarantined or kept, not delete-ready');
|
|
306
|
+
}
|
|
307
|
+
if ((receipt.authority_class === 'authority' || receipt.authority_class === 'recovery')
|
|
308
|
+
&& receipt.proposed_disposition === 'delete-ready') {
|
|
309
|
+
throw new Error('authority and recovery surfaces cannot be delete-ready in V0; route principal-gated obsolescence separately');
|
|
310
|
+
}
|
|
311
|
+
if (receipt.authority_class === 'unknown' && receipt.proposed_disposition !== 'blocked') {
|
|
312
|
+
throw new Error('unknown authority class must use proposed_disposition: blocked');
|
|
313
|
+
}
|
|
314
|
+
if (receipt.consumer_status === 'unknown' && receipt.proposed_disposition !== 'blocked') {
|
|
315
|
+
throw new Error('unknown consumer status must use proposed_disposition: blocked');
|
|
316
|
+
}
|
|
317
|
+
if (receipt.proposed_disposition === 'delete-ready') {
|
|
318
|
+
if (receipt.authority_class !== 'ergonomic') {
|
|
319
|
+
throw new Error('delete-ready is only valid for ergonomic scaffolding in V0');
|
|
320
|
+
}
|
|
321
|
+
if (receipt.consumer_status !== 'stale') {
|
|
322
|
+
throw new Error('delete-ready requires consumer_status: stale');
|
|
323
|
+
}
|
|
324
|
+
if (!receipt.quarantine_until) {
|
|
325
|
+
throw new Error('delete-ready requires quarantine_until');
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const canary = evaluateHarnessCanary(receipt);
|
|
330
|
+
if (canary.verdict !== 'pass') {
|
|
331
|
+
throw new Error(`missing canaries: ${canary.missing_canaries.join(', ')}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export async function loadHarnessPruneReceipt(path: string): Promise<HarnessPruneReceipt> {
|
|
336
|
+
return parseHarnessPruneReceipt(await readStructured(path));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export async function validateHarnessPruneReceiptFile(path: string): Promise<HarnessReceiptValidation> {
|
|
340
|
+
const receipt = await loadHarnessPruneReceipt(path);
|
|
341
|
+
validateHarnessPruneReceipt(receipt);
|
|
342
|
+
return {
|
|
343
|
+
schema: 'harness-prune-receipt-validation.v0',
|
|
344
|
+
receipt_id: `harness-prune:${stableHash(receipt)}`,
|
|
345
|
+
object_id: receipt.object_id,
|
|
346
|
+
verdict: 'valid',
|
|
347
|
+
proposed_disposition: receipt.proposed_disposition,
|
|
348
|
+
canary: evaluateHarnessCanary(receipt),
|
|
349
|
+
authority_boundary: {
|
|
350
|
+
...receipt.authority,
|
|
351
|
+
receipt_can_authorize_pruning: false,
|
|
352
|
+
human_disposition_required: true,
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function suggestDisposition(item: HarnessInventoryItem): Pick<HarnessInventoryReportItem, 'suggested_disposition' | 'reason'> {
|
|
358
|
+
if (item.authority_class === 'unknown' || item.consumer_status === 'unknown') {
|
|
359
|
+
return { suggested_disposition: 'blocked', reason: 'authority or consumer reach is unknown' };
|
|
360
|
+
}
|
|
361
|
+
if (item.consumer_status === 'active') {
|
|
362
|
+
return { suggested_disposition: 'keep', reason: 'active consumer evidence exists' };
|
|
363
|
+
}
|
|
364
|
+
if (item.consumer_status === 'nascent') {
|
|
365
|
+
return { suggested_disposition: 'quarantine', reason: 'roadmap-aware grace: built ahead of consumer' };
|
|
366
|
+
}
|
|
367
|
+
if (item.authority_class === 'authority' || item.authority_class === 'recovery') {
|
|
368
|
+
return { suggested_disposition: 'blocked', reason: 'authority and recovery surfaces require principal-gated obsolescence review' };
|
|
369
|
+
}
|
|
370
|
+
if (item.authority_class === 'ergonomic' && item.consumer_status === 'stale') {
|
|
371
|
+
return { suggested_disposition: 'quarantine', reason: 'stale ergonomic scaffolding; pruning receipt and canary-without required before delete-ready' };
|
|
372
|
+
}
|
|
373
|
+
return { suggested_disposition: 'consolidate', reason: 'stale non-authority surface should be simplified before deletion' };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function buildHarnessInventoryReport(manifest: HarnessInventoryManifest): HarnessInventoryReport {
|
|
377
|
+
const counts: Record<HarnessDisposition, number> = {
|
|
378
|
+
keep: 0,
|
|
379
|
+
consolidate: 0,
|
|
380
|
+
quarantine: 0,
|
|
381
|
+
'delete-ready': 0,
|
|
382
|
+
blocked: 0,
|
|
383
|
+
};
|
|
384
|
+
const items = manifest.items.map((item) => {
|
|
385
|
+
const suggestion = suggestDisposition(item);
|
|
386
|
+
counts[suggestion.suggested_disposition] += 1;
|
|
387
|
+
return { ...item, ...suggestion };
|
|
388
|
+
});
|
|
389
|
+
return {
|
|
390
|
+
schema: 'harness-inventory-report.v0',
|
|
391
|
+
item_count: items.length,
|
|
392
|
+
counts,
|
|
393
|
+
items,
|
|
394
|
+
authority_boundary: {
|
|
395
|
+
inventory_can_delete: false,
|
|
396
|
+
inventory_can_grant: false,
|
|
397
|
+
inventory_can_close_lane: false,
|
|
398
|
+
pruning_receipt_required: true,
|
|
399
|
+
human_disposition_required: true,
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export async function buildHarnessInventoryReportFile(path: string): Promise<HarnessInventoryReport> {
|
|
405
|
+
return buildHarnessInventoryReport(parseHarnessInventoryManifest(await readStructured(path)));
|
|
406
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { assertSharedMemoryScopeId, detectMemoryRiskFlags } from './sanitize';
|
|
4
|
+
import { writeOutboxEvent } from './outbox';
|
|
5
|
+
import {
|
|
6
|
+
HOOK_EVENT_TYPES,
|
|
7
|
+
type DispatchResult,
|
|
8
|
+
type HookEvent,
|
|
9
|
+
type HookStage,
|
|
10
|
+
} from './types';
|
|
11
|
+
|
|
12
|
+
export interface DispatchOptions {
|
|
13
|
+
stage: HookStage;
|
|
14
|
+
workspace_id?: string;
|
|
15
|
+
payload?: Record<string, unknown>;
|
|
16
|
+
home?: string;
|
|
17
|
+
enabled_env?: string;
|
|
18
|
+
workspace_env?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DEFAULT_ENABLED_ENV = 'ETIQUETTE_HOOKS_ENABLED';
|
|
22
|
+
const DEFAULT_WORKSPACE_ENV = 'ETIQUETTE_WORKSPACE_ID';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Stage-dispatch entry-point for V0.1 hook adapter.
|
|
26
|
+
*
|
|
27
|
+
* Hook dispatch envelope:
|
|
28
|
+
* - disabled by default (env var opt-in)
|
|
29
|
+
* - local-first (writes per-workspace outbox; no network)
|
|
30
|
+
* - metadata-only (sanitizer strips path/host/IP/credential/raw-prompt/raw-tool/source)
|
|
31
|
+
* - fast-fail (no retries; no blocking on outbox absence)
|
|
32
|
+
* - non-authoritative (writes outbox; never to ledger or canonical surfaces)
|
|
33
|
+
* - non-blocking (any failure returns 'skipped', never throws)
|
|
34
|
+
*
|
|
35
|
+
* Return contract: always returns a DispatchResult. The CLI wrapper exits 0
|
|
36
|
+
* regardless of status; status surfaces in stdout for inspectability.
|
|
37
|
+
*/
|
|
38
|
+
export function dispatchHook(opts: DispatchOptions): DispatchResult {
|
|
39
|
+
const enabledKey = opts.enabled_env ?? DEFAULT_ENABLED_ENV;
|
|
40
|
+
const workspaceKey = opts.workspace_env ?? DEFAULT_WORKSPACE_ENV;
|
|
41
|
+
|
|
42
|
+
if (process.env[enabledKey] !== '1') {
|
|
43
|
+
return { status: 'disabled' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const workspace_id = (opts.workspace_id ?? process.env[workspaceKey])?.trim();
|
|
47
|
+
if (!workspace_id || workspace_id.trim() === '') {
|
|
48
|
+
return {
|
|
49
|
+
status: 'skipped',
|
|
50
|
+
reasons: [`no-workspace-id (set ${workspaceKey} env var or pass --workspace)`],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
assertSharedMemoryScopeId(workspace_id, 'workspace_id');
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return {
|
|
57
|
+
status: 'skipped',
|
|
58
|
+
reasons: [`invalid-workspace-id: ${(err as Error).message}`],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Sanitize each string payload field; strip (do not throw) fields that
|
|
63
|
+
// contain restricted content classes per sanitizer doctrine.
|
|
64
|
+
const sourcePayload = opts.payload ?? {};
|
|
65
|
+
const sanitizedPayload: Record<string, unknown> = {};
|
|
66
|
+
const strippedFields: string[] = [];
|
|
67
|
+
|
|
68
|
+
for (const [key, value] of Object.entries(sourcePayload)) {
|
|
69
|
+
if (typeof value === 'string') {
|
|
70
|
+
const flags = detectMemoryRiskFlags(value);
|
|
71
|
+
if (flags.length > 0) {
|
|
72
|
+
strippedFields.push(`${key}(${flags.join(',')})`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
sanitizedPayload[key] = value;
|
|
76
|
+
} else if (
|
|
77
|
+
typeof value === 'number' ||
|
|
78
|
+
typeof value === 'boolean' ||
|
|
79
|
+
value === null
|
|
80
|
+
) {
|
|
81
|
+
sanitizedPayload[key] = value;
|
|
82
|
+
} else {
|
|
83
|
+
// Reject objects/arrays at V0.1 — keep payload shape flat.
|
|
84
|
+
// V0.2 may add nested sanitization with depth limits.
|
|
85
|
+
strippedFields.push(`${key}(non-scalar)`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const event: HookEvent = {
|
|
90
|
+
event_id: randomUUID(),
|
|
91
|
+
stage: opts.stage,
|
|
92
|
+
event_type: HOOK_EVENT_TYPES[opts.stage],
|
|
93
|
+
occurred_at: new Date().toISOString(),
|
|
94
|
+
workspace_id,
|
|
95
|
+
payload: sanitizedPayload,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const home = opts.home ?? homedir();
|
|
99
|
+
const writeResult = writeOutboxEvent(home, workspace_id, event);
|
|
100
|
+
|
|
101
|
+
if (!writeResult.written) {
|
|
102
|
+
return {
|
|
103
|
+
status: 'skipped',
|
|
104
|
+
reasons: [writeResult.reason ?? 'outbox-write-failed'],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const reasons = strippedFields.length > 0
|
|
109
|
+
? [`sanitizer-stripped: ${strippedFields.join(', ')}`]
|
|
110
|
+
: undefined;
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
status: 'written',
|
|
114
|
+
event_id: event.event_id,
|
|
115
|
+
reasons,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, rmdirSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import type { HookEvent } from './types';
|
|
4
|
+
|
|
5
|
+
export interface OutboxWriteResult {
|
|
6
|
+
written: boolean;
|
|
7
|
+
reason?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function outboxDir(home: string, workspace_id: string): string {
|
|
11
|
+
return join(home, '.etiquette', 'state', 'workspaces', workspace_id);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function outboxPath(home: string, workspace_id: string): string {
|
|
15
|
+
return join(outboxDir(home, workspace_id), 'outbox.ndjson');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const LOCK_DIR_NAME = '.outbox.lock';
|
|
19
|
+
const LOCK_MAX_ATTEMPTS = 100;
|
|
20
|
+
const LOCK_SLEEP_MS = 50;
|
|
21
|
+
|
|
22
|
+
function acquireLock(lockPath: string): boolean {
|
|
23
|
+
for (let attempt = 0; attempt < LOCK_MAX_ATTEMPTS; attempt += 1) {
|
|
24
|
+
try {
|
|
25
|
+
mkdirSync(lockPath);
|
|
26
|
+
return true;
|
|
27
|
+
} catch {
|
|
28
|
+
// already locked; busy-wait briefly
|
|
29
|
+
const deadline = Date.now() + LOCK_SLEEP_MS;
|
|
30
|
+
while (Date.now() < deadline) {
|
|
31
|
+
// busy wait — sync only because hooks must not yield
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function releaseLock(lockPath: string): void {
|
|
39
|
+
try {
|
|
40
|
+
rmdirSync(lockPath);
|
|
41
|
+
} catch {
|
|
42
|
+
// lock already released or never acquired; swallow
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Append a HookEvent to the per-workspace outbox NDJSON. Race-safe via mkdir
|
|
48
|
+
* lock (same pattern as the PR #5 fix for `bin/local-event`). Non-blocking:
|
|
49
|
+
* any failure (missing directory, lock timeout, write error) returns
|
|
50
|
+
* `{written:false, reason}` and the caller decides — V0.1 dispatcher logs and
|
|
51
|
+
* proceeds.
|
|
52
|
+
*
|
|
53
|
+
* Per [[feedback_rotated_operator_topology]]: outbox lives per-workspace, not
|
|
54
|
+
* per-seat. Operator rotation across seats preserves continuity.
|
|
55
|
+
*/
|
|
56
|
+
export function writeOutboxEvent(
|
|
57
|
+
home: string,
|
|
58
|
+
workspace_id: string,
|
|
59
|
+
event: HookEvent,
|
|
60
|
+
): OutboxWriteResult {
|
|
61
|
+
const dir = outboxDir(home, workspace_id);
|
|
62
|
+
try {
|
|
63
|
+
mkdirSync(dir, { recursive: true });
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return { written: false, reason: `outbox-dir-unavailable: ${(err as Error).message}` };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const lockPath = join(dir, LOCK_DIR_NAME);
|
|
69
|
+
if (!acquireLock(lockPath)) {
|
|
70
|
+
return { written: false, reason: `lock-timeout after ${LOCK_MAX_ATTEMPTS} attempts` };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const path = outboxPath(home, workspace_id);
|
|
74
|
+
try {
|
|
75
|
+
appendFileSync(path, `${JSON.stringify(event)}\n`, 'utf-8');
|
|
76
|
+
return { written: true };
|
|
77
|
+
} catch (err) {
|
|
78
|
+
return { written: false, reason: `append-failed: ${(err as Error).message}` };
|
|
79
|
+
} finally {
|
|
80
|
+
releaseLock(lockPath);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function outboxExists(home: string, workspace_id: string): boolean {
|
|
85
|
+
return existsSync(outboxPath(home, workspace_id));
|
|
86
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Re-export the memory-spine sanitizer so the hook adapter applies the same
|
|
2
|
+
// content-class boundary as recall capsules. Single source of truth.
|
|
3
|
+
// Keep hook payload filtering on the same content-class boundary as recall.
|
|
4
|
+
|
|
5
|
+
export { assertSharedMemoryScopeId, detectMemoryRiskFlags } from '../memory/sanitize';
|
|
6
|
+
export type { MemoryRiskFlag } from '../memory/types';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const HOOK_STAGES = [
|
|
2
|
+
'session_start',
|
|
3
|
+
'user_prompt_submit',
|
|
4
|
+
'post_tool_use',
|
|
5
|
+
'stop',
|
|
6
|
+
'session_end',
|
|
7
|
+
] as const;
|
|
8
|
+
|
|
9
|
+
export type HookStage = (typeof HOOK_STAGES)[number];
|
|
10
|
+
|
|
11
|
+
export const HOOK_EVENT_TYPES: Record<HookStage, string> = {
|
|
12
|
+
session_start: 'seat:session_started',
|
|
13
|
+
user_prompt_submit: 'trace:prompt_boundary',
|
|
14
|
+
post_tool_use: 'trace:tool_used',
|
|
15
|
+
stop: 'playback:candidate_observed',
|
|
16
|
+
session_end: 'seat:session_completed',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export interface HookEvent {
|
|
20
|
+
event_id: string;
|
|
21
|
+
stage: HookStage;
|
|
22
|
+
event_type: string;
|
|
23
|
+
occurred_at: string;
|
|
24
|
+
workspace_id: string;
|
|
25
|
+
payload: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type DispatchStatus = 'disabled' | 'written' | 'skipped';
|
|
29
|
+
|
|
30
|
+
export interface DispatchResult {
|
|
31
|
+
status: DispatchStatus;
|
|
32
|
+
event_id?: string;
|
|
33
|
+
reasons?: string[];
|
|
34
|
+
}
|