@ngockhoale/ukit 3.4.22 → 3.4.24

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.
Files changed (30) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/manifests/platform.full.yaml +29 -0
  3. package/package.json +1 -1
  4. package/src/cli/commands/telemetry.js +67 -4
  5. package/src/cli/index.js +1 -1
  6. package/src/core/docContracts.js +60 -6
  7. package/src/core/observability/adapters/routeAdapter.js +48 -9
  8. package/src/core/observability/analytics/usageReadiness.js +342 -0
  9. package/src/core/observability/privacy/allowlist.js +10 -1
  10. package/src/core/observability/schema/constants.js +13 -0
  11. package/src/core/observability/schema/registry.js +20 -0
  12. package/src/core/observability/schema/validate.js +45 -2
  13. package/src/core/observability/segments/readSegments.js +23 -1
  14. package/template_project/.claude/agents/feature-implementer.md +10 -3
  15. package/template_project/.claude/commands/ukit/handoff-clear.md +27 -12
  16. package/template_project/.claude/commands/ukit/handoff-fullstack.md +65 -32
  17. package/template_project/.claude/commands/ukit/handoff-implement.md +44 -23
  18. package/template_project/.claude/hooks/handoff-resume.sh +36 -11
  19. package/template_project/.claude/hooks/protect-files.sh +10 -2
  20. package/template_project/.claude/hooks/vision-router.sh +11 -1
  21. package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +418 -0
  22. package/template_project/.claude/ukit/index/extract-image.mjs +77 -22
  23. package/template_project/.claude/ukit/index/land-worktree.mjs +543 -0
  24. package/template_project/.claude/ukit/index/stale-spec-check.mjs +48 -1
  25. package/template_project/.claude/ukit/runtime/handoff-intent.mjs +86 -28
  26. package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +60 -0
  27. package/template_project/.claude/ukit/runtime/observability-emit.mjs +53 -2
  28. package/template_project/.claude/ukit/runtime/output-compression.mjs +46 -20
  29. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +16 -2
  30. package/template_project/.omp/agents/feature-implementer.md +10 -3
@@ -0,0 +1,342 @@
1
+ /**
2
+ * usageReadiness.js (TASK-C98-005, SPEC §5 FR-06 / §9 / §10) — pure, deterministic
3
+ * usage-readiness report.
4
+ *
5
+ * buildUsageReadiness(records, options): UsageReport
6
+ * renderUsageReadiness(report): string
7
+ *
8
+ * Pure like analytics/summary.js: no IO, no clock, no randomness — the same
9
+ * records in any order always produce a byte-identical report, and the input
10
+ * is never mutated.
11
+ *
12
+ * Honesty rules (SPEC §1/§5; docs/OBSERVABILITY.md):
13
+ * - Totals are PER SOURCE and PER FAMILY only. Sources are never summed
14
+ * together, and model vs subagent usage is never combined (a subagent
15
+ * run may include model usage — summing would double-count).
16
+ * - A total is null when no numeric value was recorded for that source:
17
+ * unknown stays unknown, never 0. A provider-reported 0 stays 0.
18
+ * - Record counts count records, not unique runs (ingest may duplicate).
19
+ * - cache.hit/cache.miss are UKit's own lookup cache, never a provider
20
+ * prompt cache.
21
+ * - ESTIMATED / LOCAL_TOKENIZER / UNKNOWN evidence never upgrades the
22
+ * verdict to aggregate_only/categorized.
23
+ *
24
+ * The five category key names are duplicated locally (SPEC FR-02) so wave-1
25
+ * tasks stay file-disjoint; TASK-C98-006 guards the drift by feeding a
26
+ * validator-accepted fixture through this report.
27
+ */
28
+
29
+ export const USAGE_REPORT_VERSION = 'df-usage-1';
30
+
31
+ // Local frozen copy of SPEC FR-02's USAGE_CATEGORY_KEYS (wave-1 parallelism).
32
+ const USAGE_CATEGORY_KEYS = Object.freeze([
33
+ 'fresh_input_tokens',
34
+ 'cache_read_tokens',
35
+ 'cache_write_tokens',
36
+ 'output_tokens',
37
+ 'reasoning_tokens',
38
+ ]);
39
+
40
+ const SOURCE_KEYS = Object.freeze(['PROVIDER', 'LOCAL_TOKENIZER', 'ESTIMATED', 'UNKNOWN']);
41
+
42
+ const INTERNAL_CACHE_SCOPE = 'ukit_internal_lookup_not_provider_prompt_cache';
43
+
44
+ const MAX_DEGRADED_DEFAULT = 20;
45
+ const MAX_STRING_LENGTH = 200;
46
+
47
+ // Fixed canonical gap order (SPEC FR-06); membership is data-driven below.
48
+ const GAP_NO_MODEL_RECORDS = 'no_model_records';
49
+ const GAP_NO_PROVIDER_AGGREGATE = 'no_provider_aggregate';
50
+ const GAP_NO_PROVIDER_CATEGORIES = 'no_provider_categories';
51
+ const GAP_CATEGORIES_PARTIAL = 'categories_partial';
52
+ const GAP_SUBAGENT_USAGE_ABSENT = 'subagent_usage_absent';
53
+ const GAP_PROVIDER_IDENTITY = 'provider_identity_not_recorded';
54
+ const GAP_PROMPT_CACHE = 'prompt_cache_not_provider_reported';
55
+ const GAP_STORE_COVERAGE = 'store_coverage_degraded';
56
+ const GAP_SCAN_TRUNCATED = 'scan_truncated';
57
+
58
+ const NOTES = Object.freeze([
59
+ 'Record counts are record counts, not unique runs (ingest may duplicate records).',
60
+ 'cache.hit/cache.miss are UKit internal cache lookups, not a provider prompt cache.',
61
+ 'Estimated values are never reported as provider values.',
62
+ ]);
63
+
64
+ function isPlainObject(value) {
65
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
66
+ }
67
+
68
+ function isNumeric(value) {
69
+ return typeof value === 'number' && Number.isFinite(value);
70
+ }
71
+
72
+ function truncateString(value) {
73
+ return typeof value === 'string' && value.length > MAX_STRING_LENGTH
74
+ ? value.slice(0, MAX_STRING_LENGTH)
75
+ : value;
76
+ }
77
+
78
+ function emptyBySource() {
79
+ const bySource = {};
80
+ for (const source of SOURCE_KEYS) {
81
+ bySource[source] = { records: 0, total: null };
82
+ }
83
+ return bySource;
84
+ }
85
+
86
+ /** Bucket one {value, source} entry. UNKNOWN totals stay null by shape. */
87
+ function addUsageEntry(bySource, flags, entry) {
88
+ const source = isPlainObject(entry) && typeof entry.source === 'string' ? entry.source : null;
89
+ // Own-property guard: an inherited name like 'toString' must never resolve
90
+ // to a prototype member (R-A finding — purity/pollution).
91
+ const bucket = source === null || !Object.hasOwn(bySource, source)
92
+ ? null
93
+ : bySource[source];
94
+ if (!bucket) return;
95
+ bucket.records += 1;
96
+ const numeric = isNumeric(entry.value);
97
+ if (!numeric) return;
98
+ if (source === 'PROVIDER') flags.providerAggregate = true;
99
+ else if (source === 'ESTIMATED' || source === 'LOCAL_TOKENIZER') flags.numericNonProvider = true;
100
+ if (source !== 'UNKNOWN') {
101
+ bucket.total = (bucket.total === null ? 0 : bucket.total) + entry.value;
102
+ }
103
+ }
104
+
105
+ /** Capped, sorted, string-truncated copy of the reader coverage; input untouched. */
106
+ function capCoverage(coverage, maxDegraded) {
107
+ if (!isPlainObject(coverage)) return null;
108
+ const capped = { ...coverage };
109
+ const degraded = Array.isArray(coverage.degraded)
110
+ ? coverage.degraded.filter(isPlainObject).map((entry) => ({
111
+ ...entry,
112
+ reason: truncateString(typeof entry.reason === 'string' ? entry.reason : ''),
113
+ file: truncateString(typeof entry.file === 'string' ? entry.file : ''),
114
+ }))
115
+ : [];
116
+ degraded.sort((a, b) => a.reason.localeCompare(b.reason) || a.file.localeCompare(b.file));
117
+ capped.degraded = degraded.slice(0, maxDegraded);
118
+ if (degraded.length > capped.degraded.length) {
119
+ capped.degraded_truncated = degraded.length - capped.degraded.length;
120
+ } else {
121
+ delete capped.degraded_truncated;
122
+ }
123
+ return capped;
124
+ }
125
+
126
+ function coverageIsDegraded(coverage) {
127
+ if (!isPlainObject(coverage)) return false;
128
+ if (Array.isArray(coverage.degraded) && coverage.degraded.length > 0) return true;
129
+ for (const key of ['corrupt_lines', 'quarantined_segments', 'expired_segments', 'unquarantined_segments']) {
130
+ if (isNumeric(coverage[key]) && coverage[key] > 0) return true;
131
+ }
132
+ return false;
133
+ }
134
+
135
+ export function buildUsageReadiness(records, options = {}) {
136
+ const opts = isPlainObject(options) ? options : {};
137
+ const maxDegraded =
138
+ Number.isInteger(opts.maxDegraded) && opts.maxDegraded > 0 ? opts.maxDegraded : MAX_DEGRADED_DEFAULT;
139
+ const scanLimit = Number.isInteger(opts.scanLimit) && opts.scanLimit >= 0 ? opts.scanLimit : null;
140
+
141
+ const counts = { model_completed: 0, model_failed: 0, subagent_usage: 0, cache_hit: 0, cache_miss: 0 };
142
+ const modelBySource = emptyBySource();
143
+ const subagentBySource = emptyBySource();
144
+ const categories = {};
145
+ for (const key of USAGE_CATEGORY_KEYS) {
146
+ categories[key] = { by_source: emptyBySource(), unknown_records: 0 };
147
+ }
148
+ const flags = { providerAggregate: false, numericNonProvider: false, usageRecord: false };
149
+ let providerCategory = false;
150
+
151
+ let recordsScanned = 0;
152
+ let truncated = opts.truncated === true;
153
+
154
+ const visit = (record) => {
155
+ if (!isPlainObject(record)) return;
156
+ const name = typeof record.semantic_name === 'string' ? record.semantic_name : '';
157
+ if (
158
+ name !== 'model.completed' &&
159
+ name !== 'model.failed' &&
160
+ name !== 'subagent.usage' &&
161
+ name !== 'cache.hit' &&
162
+ name !== 'cache.miss'
163
+ ) {
164
+ return;
165
+ }
166
+ const payload = isPlainObject(record.payload) ? record.payload : {};
167
+
168
+ if (name === 'cache.hit') {
169
+ counts.cache_hit += 1;
170
+ return;
171
+ }
172
+ if (name === 'cache.miss') {
173
+ counts.cache_miss += 1;
174
+ return;
175
+ }
176
+
177
+ const isModel = name === 'model.completed' || name === 'model.failed';
178
+ if (isModel) {
179
+ if (name === 'model.completed') counts.model_completed += 1;
180
+ else counts.model_failed += 1;
181
+ } else {
182
+ counts.subagent_usage += 1;
183
+ }
184
+
185
+ if (isModel && isPlainObject(payload.usage_categories)) {
186
+ let hasEntries = false;
187
+ for (const key of USAGE_CATEGORY_KEYS) {
188
+ const entry = payload.usage_categories[key];
189
+ if (!isPlainObject(entry)) continue;
190
+ hasEntries = true;
191
+ addUsageEntry(categories[key].by_source, flags, entry);
192
+ if (entry.source === 'UNKNOWN') categories[key].unknown_records += 1;
193
+ if (entry.source === 'PROVIDER' && isNumeric(entry.value)) providerCategory = true;
194
+ }
195
+ if (hasEntries) flags.usageRecord = true;
196
+ }
197
+
198
+ if (isPlainObject(payload.resource)) {
199
+ flags.usageRecord = true;
200
+ addUsageEntry(isModel ? modelBySource : subagentBySource, flags, payload.resource);
201
+ }
202
+ };
203
+
204
+ if (records !== null && records !== undefined && typeof records[Symbol.iterator] === 'function') {
205
+ for (const record of records) {
206
+ if (scanLimit !== null && recordsScanned === scanLimit) {
207
+ truncated = true;
208
+ break;
209
+ }
210
+ recordsScanned += 1;
211
+ visit(record);
212
+ }
213
+ }
214
+
215
+ let verdict;
216
+ let verdict_reason;
217
+ if (providerCategory) {
218
+ verdict = 'categorized';
219
+ verdict_reason = 'provider_categories';
220
+ } else if (flags.providerAggregate) {
221
+ verdict = 'aggregate_only';
222
+ verdict_reason = 'provider_aggregate';
223
+ } else {
224
+ verdict = 'no_data';
225
+ if (flags.numericNonProvider) verdict_reason = 'estimated_only';
226
+ else if (flags.usageRecord) verdict_reason = 'unknown_only';
227
+ else verdict_reason = 'no_usage_records';
228
+ }
229
+
230
+ const coverage = capCoverage(opts.coverage, maxDegraded);
231
+ const modelRecords = counts.model_completed + counts.model_failed;
232
+ const providerAggregatePresent =
233
+ modelBySource.PROVIDER.total !== null || subagentBySource.PROVIDER.total !== null;
234
+ const providerCategoryKeys = USAGE_CATEGORY_KEYS.filter(
235
+ (key) => categories[key].by_source.PROVIDER.total !== null,
236
+ );
237
+
238
+ const gaps = [];
239
+ if (modelRecords === 0) gaps.push(GAP_NO_MODEL_RECORDS);
240
+ if (!providerAggregatePresent) gaps.push(GAP_NO_PROVIDER_AGGREGATE);
241
+ if (providerCategoryKeys.length === 0) gaps.push(GAP_NO_PROVIDER_CATEGORIES);
242
+ else if (providerCategoryKeys.length < USAGE_CATEGORY_KEYS.length) gaps.push(GAP_CATEGORIES_PARTIAL);
243
+ if (counts.subagent_usage === 0) gaps.push(GAP_SUBAGENT_USAGE_ABSENT);
244
+ gaps.push(GAP_PROVIDER_IDENTITY);
245
+ gaps.push(GAP_PROMPT_CACHE);
246
+ if (coverageIsDegraded(coverage)) gaps.push(GAP_STORE_COVERAGE);
247
+ if (truncated) gaps.push(GAP_SCAN_TRUNCATED);
248
+
249
+ return {
250
+ report_version: USAGE_REPORT_VERSION,
251
+ verdict,
252
+ verdict_reason,
253
+ records_scanned: recordsScanned,
254
+ truncated,
255
+ scan_limit: scanLimit,
256
+ counts,
257
+ aggregate: { model: { by_source: modelBySource }, subagent: { by_source: subagentBySource } },
258
+ categories,
259
+ internal_cache: { hit: counts.cache_hit, miss: counts.cache_miss, scope: INTERNAL_CACHE_SCOPE },
260
+ gaps,
261
+ notes: NOTES.slice(),
262
+ coverage,
263
+ };
264
+ }
265
+
266
+ function renderBySource(indent, bySource) {
267
+ const lines = [];
268
+ for (const source of SOURCE_KEYS) {
269
+ const bucket = bySource[source];
270
+ const total = isNumeric(bucket.total) ? String(bucket.total) : 'unknown';
271
+ lines.push(`${indent}${source}: ${total} (${bucket.records} records)`);
272
+ }
273
+ return lines;
274
+ }
275
+
276
+ function renderCoverage(coverage) {
277
+ const lines = [];
278
+ if (!isPlainObject(coverage)) {
279
+ lines.push(' (no coverage information)');
280
+ return lines;
281
+ }
282
+ const counters = [
283
+ 'records_read',
284
+ 'corrupt_lines',
285
+ 'quarantined_segments',
286
+ 'expired_segments',
287
+ 'unquarantined_segments',
288
+ 'partial_tail_bytes',
289
+ ];
290
+ for (const key of counters) {
291
+ if (isNumeric(coverage[key])) lines.push(` ${key}: ${coverage[key]}`);
292
+ }
293
+ if (isNumeric(coverage.degraded_truncated)) lines.push(` degraded_truncated: ${coverage.degraded_truncated}`);
294
+ const degraded = Array.isArray(coverage.degraded) ? coverage.degraded : [];
295
+ if (degraded.length === 0) lines.push(' degraded: (none)');
296
+ else {
297
+ lines.push(' degraded:');
298
+ for (const entry of degraded) lines.push(` - ${entry.reason} ${entry.file}`);
299
+ }
300
+ return lines;
301
+ }
302
+
303
+ export function renderUsageReadiness(report) {
304
+ const lines = [];
305
+ lines.push('Usage readiness (local, read-only)');
306
+ lines.push(`Readiness: ${report.verdict} (${report.verdict_reason})`);
307
+ let scannedLine = `Records scanned: ${report.records_scanned}`;
308
+ if (report.truncated && Number.isInteger(report.scan_limit)) {
309
+ scannedLine += ` (truncated at ${report.scan_limit})`;
310
+ }
311
+ lines.push(scannedLine);
312
+
313
+ lines.push('Aggregate usage (provider-reported only, per source)');
314
+ for (const family of ['model', 'subagent']) {
315
+ lines.push(` ${family}:`);
316
+ lines.push(...renderBySource(' ', report.aggregate[family].by_source));
317
+ }
318
+
319
+ lines.push('Categories');
320
+ for (const key of USAGE_CATEGORY_KEYS) {
321
+ lines.push(` ${key}:`);
322
+ lines.push(...renderBySource(' ', report.categories[key].by_source));
323
+ lines.push(` unknown records: ${report.categories[key].unknown_records}`);
324
+ }
325
+
326
+ const cache = report.internal_cache;
327
+ const hit = isNumeric(cache.hit) ? String(cache.hit) : 'unknown';
328
+ const miss = isNumeric(cache.miss) ? String(cache.miss) : 'unknown';
329
+ lines.push(`Internal UKit cache lookups (not a provider prompt cache): hit ${hit} miss ${miss}`);
330
+
331
+ lines.push('Gaps:');
332
+ if (report.gaps.length === 0) lines.push(' (none)');
333
+ else for (const gap of report.gaps) lines.push(` - ${gap}`);
334
+
335
+ lines.push('Store coverage:');
336
+ lines.push(...renderCoverage(report.coverage));
337
+
338
+ lines.push('Notes:');
339
+ for (const note of report.notes) lines.push(` - ${note}`);
340
+
341
+ return lines.join('\n');
342
+ }
@@ -11,7 +11,7 @@
11
11
  * redaction rules change so downstream readers can detect stale records.
12
12
  */
13
13
 
14
- export const REDACTION_VERSION = 'df-redact-6';
14
+ export const REDACTION_VERSION = 'df-redact-7';
15
15
 
16
16
  /**
17
17
  * Envelope fields permitted on a persisted record (DF-FR01) plus the
@@ -188,6 +188,15 @@ export const ALLOWED_SUPPORT_PAYLOAD_FIELDS = new Set([
188
188
  'tokens',
189
189
  'parent_run_ref',
190
190
  'telemetry_complete',
191
+ // C98 FR-03 (TASK-C98-003): model token usage provenance — the closed
192
+ // usage_categories map and its five category keys. Counts and source tags
193
+ // only, never content.
194
+ 'usage_categories',
195
+ 'fresh_input_tokens',
196
+ 'cache_read_tokens',
197
+ 'cache_write_tokens',
198
+ 'output_tokens',
199
+ 'reasoning_tokens',
191
200
  ]);
192
201
 
193
202
  // --- size caps (prototype defaults per SPEC §14; freeze after measurement) ---
@@ -61,6 +61,19 @@ export const IMPORTANCE_LEVELS = Object.freeze(['low', 'normal', 'high', 'critic
61
61
  // never 0.
62
62
  export const RESOURCE_SOURCES = Object.freeze(['PROVIDER', 'LOCAL_TOKENIZER', 'ESTIMATED', 'UNKNOWN']);
63
63
 
64
+ // Schema v1.3 additive payload extension (C98 FR-02): typed per-category
65
+ // token resources on model.completed|failed. Closed key set; an absent
66
+ // category reads as unknown — never 0.
67
+ export const USAGE_CATEGORY_KEYS = Object.freeze([
68
+ 'fresh_input_tokens',
69
+ 'cache_read_tokens',
70
+ 'cache_write_tokens',
71
+ 'output_tokens',
72
+ 'reasoning_tokens',
73
+ ]);
74
+
75
+ export const USAGE_CATEGORIES_FIELD = 'usage_categories';
76
+
64
77
  // SPEC §7: confidence is always typed and never an instruction authority.
65
78
  export const CONFIDENCE_TYPES = Object.freeze([
66
79
  'HEURISTIC',
@@ -16,6 +16,7 @@ import { PRIVACY_CLASSES } from './constants.js';
16
16
  const V1 = '1';
17
17
  const V1_1 = '1.1';
18
18
  const V1_2 = '1.2';
19
+ const V1_3 = '1.3';
19
20
 
20
21
  export const SEMANTIC_REGISTRY = Object.freeze({
21
22
  'execution.started': {
@@ -387,3 +388,22 @@ for (const [name, entry] of Object.entries(SEMANTIC_REGISTRY)) {
387
388
  throw new Error(`semantic registry entry ${name} has unknown privacy_class ${entry.privacy_class}`);
388
389
  }
389
390
  }
391
+
392
+ // Schema v1.3 additive payload extensions (TASK-C98-002, SPEC §5 FR-02).
393
+ // The semantic registry is append-only and model.completed|failed cannot be
394
+ // re-defined in place, so the typed per-category usage field is versioned
395
+ // here instead: a record either carries usage_categories (validated per
396
+ // FR-02) or not — absence reads as unknown, never 0.
397
+ const USAGE_CATEGORIES_EXTENSION = Object.freeze({
398
+ introduced_version: V1_3,
399
+ definition:
400
+ 'Typed per-category token usage: five optional {value, source} resources '
401
+ + '(fresh_input_tokens, cache_read_tokens, cache_write_tokens, output_tokens, reasoning_tokens); '
402
+ + 'value is a non-negative finite integer or null; null only with source UNKNOWN — '
403
+ + 'an absent category or UNKNOWN stays unknown, never 0.',
404
+ });
405
+
406
+ export const PAYLOAD_EXTENSIONS = Object.freeze({
407
+ 'model.completed': Object.freeze({ usage_categories: USAGE_CATEGORIES_EXTENSION }),
408
+ 'model.failed': Object.freeze({ usage_categories: USAGE_CATEGORIES_EXTENSION }),
409
+ });
@@ -25,6 +25,8 @@ import {
25
25
  IMPORTANCE_LEVELS,
26
26
  RESOURCE_SOURCES,
27
27
  CONFIDENCE_TYPES,
28
+ USAGE_CATEGORY_KEYS,
29
+ USAGE_CATEGORIES_FIELD,
28
30
  } from './constants.js';
29
31
  import { SEMANTIC_REGISTRY, REASON_CODES } from './registry.js';
30
32
 
@@ -100,7 +102,48 @@ function validateOrigin(record, errors) {
100
102
  }
101
103
  }
102
104
 
103
- function validatePayload(payload, errors) {
105
+ // Schema v1.3 additive payload extension (SPEC §5 FR-02): typed per-category
106
+ // token usage on model.completed|failed. Additive — records without the
107
+ // field validate exactly as before; an absent category reads as unknown.
108
+ function validateUsageCategories(payload, semanticName, errors) {
109
+ const categories = payload[USAGE_CATEGORIES_FIELD];
110
+ if (categories === undefined || categories === null) return;
111
+
112
+ if (semanticName !== 'model.completed' && semanticName !== 'model.failed') {
113
+ errors.push(`payload.${USAGE_CATEGORIES_FIELD} is only valid on model.completed|model.failed`);
114
+ return;
115
+ }
116
+ if (!isPlainObject(categories)) {
117
+ errors.push(`payload.${USAGE_CATEGORIES_FIELD} must be an object keyed by usage category`);
118
+ return;
119
+ }
120
+ for (const [key, category] of Object.entries(categories)) {
121
+ if (!USAGE_CATEGORY_KEYS.includes(key)) {
122
+ errors.push(`payload.${USAGE_CATEGORIES_FIELD}.${key} is not a known usage category (allowed: ${USAGE_CATEGORY_KEYS.join('|')})`);
123
+ continue;
124
+ }
125
+ if (!isPlainObject(category)) {
126
+ errors.push(`payload.${USAGE_CATEGORIES_FIELD}.${key} must be an object {value, source}`);
127
+ continue;
128
+ }
129
+ if (!RESOURCE_SOURCES.includes(category.source)) {
130
+ errors.push(`payload.${USAGE_CATEGORIES_FIELD}.${key}.source must be one of ${RESOURCE_SOURCES.join('|')} (got ${JSON.stringify(category.source)})`);
131
+ }
132
+ const { value } = category;
133
+ if (value === null) {
134
+ if (category.source !== 'UNKNOWN') {
135
+ errors.push(`payload.${USAGE_CATEGORIES_FIELD}.${key}.value must be null only when source is UNKNOWN`);
136
+ }
137
+ } else if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
138
+ errors.push(`payload.${USAGE_CATEGORIES_FIELD}.${key}.value must be a finite integer >= 0 or null`);
139
+ } else if (category.source === 'UNKNOWN') {
140
+ errors.push(`payload.${USAGE_CATEGORIES_FIELD}.${key}.value must be null when source is UNKNOWN — unknown is never 0`);
141
+ }
142
+ }
143
+ }
144
+
145
+ function validatePayload(payload, semanticName, errors) {
146
+ validateUsageCategories(payload, semanticName, errors);
104
147
  if (payload.resource !== undefined && payload.resource !== null) {
105
148
  const resource = payload.resource;
106
149
  if (!isPlainObject(resource)) {
@@ -264,7 +307,7 @@ export function validateSemanticRecord(record, context) {
264
307
  if (!isPlainObject(record.payload)) {
265
308
  errors.push('payload must be an object');
266
309
  } else {
267
- validatePayload(record.payload, errors);
310
+ validatePayload(record.payload, record.semantic_name, errors);
268
311
  }
269
312
 
270
313
  if (RECORD_TYPES.includes(record.record_type)) {
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Typed reader over the append-only JSONL segment store.
5
5
  *
6
- * readSegments(root, { fromInclusive?, limit?, ioTimeoutMs?, zlib? })
6
+ * readSegments(root, { fromInclusive?, limit?, ioTimeoutMs?, zlib?, readOnly? })
7
7
  * → AsyncIterable<record> with attached:
8
8
  * .coverage → { expired_segments, quarantined_segments, corrupt_lines,
9
9
  * partial_tail_bytes, cursor_missed, degraded[] }
@@ -26,6 +26,14 @@
26
26
  * A partial tail row in the active segment (hard-kill artifact) is skipped
27
27
  * and counted, never parsed. Symlinked or foreign-named files are never
28
28
  * followed or read.
29
+ *
30
+ * Read-only mode (`readOnly: true`, strict boolean): the reader never mutates
31
+ * the store — corrupt sealed segments (checksum mismatch, missing meta, failed
32
+ * decode) are skipped and reported with `checksum_mismatch_unquarantined` /
33
+ * `decode_failed_unquarantined` degraded entries plus a
34
+ * `coverage.unquarantined_segments` counter (that key exists only in
35
+ * read-only mode) instead of being quarantined. Non-boolean values are
36
+ * treated as false. Default mode is unchanged and still quarantines.
29
37
  */
30
38
 
31
39
  import fs from 'node:fs';
@@ -49,6 +57,9 @@ export function readSegments(root, opts = {}) {
49
57
  const timeoutMs = Number.isFinite(opts.ioTimeoutMs) ? opts.ioTimeoutMs : DEFAULT_IO_TIMEOUT_MS;
50
58
  const fromInclusive = typeof opts.fromInclusive === 'string' ? opts.fromInclusive : null;
51
59
  const limit = Number.isInteger(opts.limit) && opts.limit >= 0 ? opts.limit : null;
60
+ // Strict boolean gate: truthy non-boolean values ('yes', 1) must NOT enable
61
+ // read-only mode — the caller opted into quarantine-free reads explicitly.
62
+ const readOnly = opts.readOnly === true;
52
63
 
53
64
  const coverage = {
54
65
  expired_segments: 0,
@@ -58,6 +69,7 @@ export function readSegments(root, opts = {}) {
58
69
  cursor_missed: false,
59
70
  degraded: [],
60
71
  };
72
+ if (readOnly) coverage.unquarantined_segments = 0;
61
73
  const degrade = (reason, file) => {
62
74
  coverage.degraded.push({ reason, file });
63
75
  };
@@ -123,6 +135,11 @@ export function readSegments(root, opts = {}) {
123
135
  const checksumOk =
124
136
  meta && typeof meta.checksum === 'string' && meta.checksum === sha256Buffer(buf);
125
137
  if (!checksumOk) {
138
+ if (readOnly) {
139
+ coverage.unquarantined_segments += 1;
140
+ degrade('checksum_mismatch_unquarantined', seg.name);
141
+ continue;
142
+ }
126
143
  const q = await quarantineSegment(resolved.root, seg.name, 'checksum_mismatch', {
127
144
  ioTimeoutMs: timeoutMs,
128
145
  });
@@ -150,6 +167,11 @@ export function readSegments(root, opts = {}) {
150
167
  if (decoded === null || decoded.length === 0) {
151
168
  // Stored bytes verified but not a valid/non-empty zstd stream →
152
169
  // corrupt like a bad .jsonl: quarantine, never falsify rows.
170
+ if (readOnly) {
171
+ coverage.unquarantined_segments += 1;
172
+ degrade('decode_failed_unquarantined', seg.name);
173
+ continue;
174
+ }
153
175
  const q = await quarantineSegment(resolved.root, seg.name, 'decode_failed', {
154
176
  ioTimeoutMs: timeoutMs,
155
177
  });
@@ -139,9 +139,16 @@ mode only; daily flow does not get a Progress section.
139
139
  reports `undeclared-drift`.
140
140
  - **Milestone commit.** The commit lives on your worktree branch only (e.g.
141
141
  `handoff/task-xxx`). Use a `-m "milestone: <name>"` message. Never commit outside your
142
- worktree, never push. The orchestrator's copy-back step (`git diff --name-only` against
143
- base) diffs the working tree, so intermediate commits on the worktree branch are free —
144
- **copy-back is unaffected** by milestone commits.
142
+ worktree, never push. The orchestrator's copy-back step lands the branch's final
143
+ base-relative snapshot (`land-worktree.mjs` diffs the working tree against the immutable
144
+ base), so intermediate commits on the worktree branch are free — **copy-back is
145
+ unaffected** by milestone commits.
146
+ - **Final clean milestone commit (required before PASS).** End your run with one final
147
+ milestone commit so the worktree's snapshot equals its committed tree — nothing staged,
148
+ nothing unstaged, no nonignored untracked file left behind. A Git-clean snapshot is what
149
+ lets a later receipt-gated cleanup remove the worktree without `--force`; a tree dirty
150
+ against the snapshot is retained with a named reason, never forced. Do not report PASS on
151
+ a dirty worktree.
145
152
  - **Resume.** When a previous run aborted, you will be respawned. Read `## Progress`,
146
153
  jump straight to the last entry whose `last-green:` is not `none`, and continue from
147
154
  there. Do NOT re-plan, do NOT restart from RED, do NOT re-write tests that already
@@ -9,21 +9,34 @@
9
9
 
10
10
  ---
11
11
 
12
- ## Step 1 — Force-remove all handoff worktrees and branches
12
+ ## Step 1 — Receipt-gated cleanup of handoff worktrees and branches
13
+
14
+ **Clearing is not licence to destroy recovery evidence.** Never force-remove a worktree and
15
+ never force-delete a branch — a stuck or abandoned task's unreported work is exactly what
16
+ someone may need to recover. Use the shared, receipt-gated helper: it removes
17
+ **only** the one task named by a verified landing receipt whose result was independently
18
+ Approved and whose source still matches the receipt snapshot and is Git-clean; everything
19
+ else is retained with a named reason.
20
+
21
+ Every remaining `.worktrees/*` entry and `handoff/*` branch must be named in a
22
+ **retained-reason report** (receipt missing, not approved, FAIL, missing model, dirty vs
23
+ snapshot, unmerged history). Unexplained leftovers fail the gate; nothing is removed by force.
13
24
 
14
25
  ```bash
15
- # Remove task worktrees
16
- git worktree list | grep '.worktrees/task-' | awk '{print $1}' | while read wt; do
17
- name=$(basename "$wt")
18
- git worktree remove "$wt" --force 2>/dev/null
19
- git branch -D "handoff/$name" 2>/dev/null
20
- done
21
- git worktree prune
22
-
23
- # Remove integration branch if still exists
24
- git branch -D handoff/integration 2>/dev/null || true
26
+ # For each task worktree, run the receipt-gated cleanup (dry-run first, then --apply):
27
+ node .claude/ukit/index/cleanup-task-worktree.mjs \
28
+ --receipt <receipt-json> --approval <approval-json> # dry-run: reports eligible/retained
29
+ node .claude/ukit/index/cleanup-task-worktree.mjs \
30
+ --receipt <receipt-json> --approval <approval-json> --apply # removes only that one verified task
31
+
32
+ # A worktree with NO verified receipt/approval is RETAINED with its reason — list it:
33
+ git worktree list # every .worktrees/* entry must be named in the retained-reason report
34
+ git branch | grep handoff # every handoff/* branch must be named in the retained-reason report
25
35
  ```
26
36
 
37
+ An unmerged `handoff/integration` branch is retained the same way: report it with its reason
38
+ (`unmerged history`), never delete it with `-D`.
39
+
27
40
  ## Step 2 — Unstage any pending changes
28
41
 
29
42
  ```bash
@@ -68,7 +81,9 @@ files behind, or the next cycle resumes from stale bytes.
68
81
  ## Step 5 — Report
69
82
 
70
83
  ```
71
- Cleared: X worktrees removed, Y branches deleted, staged changes unstaged.
84
+ Cleared: X receipt-gated tasks removed, staged changes unstaged.
85
+ Retained with reason: <list each surviving worktree/branch and why — receipt missing,
86
+ not approved, FAIL, missing model, dirty vs snapshot, unmerged history>.
72
87
  Handoff docs reset. Ready for new cycle.
73
88
  ```
74
89