@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
@@ -27,21 +27,35 @@
27
27
  // `intent` is the MOST RECENT qualifying intent, `known=false` when the scan
28
28
  // could not complete (missing/unreadable path, budget overrun) — callers keep
29
29
  // their fail-closed posture on `known=false`.
30
+ //
31
+ // Intent values stay the historical pair 'create' | 'fullstack': an exact
32
+ // `handoff-implement` invocation CLAIMS the run (the fullstack command says so
33
+ // itself), so it maps onto the existing 'fullstack' intent instead of adding a
34
+ // new enum every consumer would have to learn (TASK-C97-004).
30
35
 
31
36
  import fs from 'node:fs/promises';
32
37
 
33
- export const HANDOFF_FULLSTACK_TAG = /<command-name>\/?(?:ukit:)?handoff-fullstack\b/;
34
- export const HANDOFF_CREATE_TAG = /<command-name>\/?(?:ukit:)?handoff-create\b/;
38
+ // The `(?![\w-])` closer is the exact token boundary: a command name followed by
39
+ // MORE name characters or a hyphen (`handoff-fullstack-custom`) is a different,
40
+ // unsupported command — `\b` would still match there and let the suffix spoof
41
+ // the intent.
42
+ export const HANDOFF_FULLSTACK_TAG = /<command-name>\/?(?:ukit:)?handoff-fullstack(?![\w-])/;
43
+ export const HANDOFF_CREATE_TAG = /<command-name>\/?(?:ukit:)?handoff-create(?![\w-])/;
35
44
  export const HANDOFF_RESUME_BANNER = 'UKIT HANDOFF RESUME — an unfinished handoff-fullstack run';
36
45
 
46
+ // Sweeper for "which command tag came LAST in this entry": finds every exact
47
+ // command-name tag occurrence with its position so the newest one wins.
48
+ const COMMAND_TAG_SWEEP = /<command-name>\/?(?:ukit:)?handoff-(create|fullstack|implement)(?![\w-])/g;
49
+
37
50
  // omp and Codex do NOT wrap a slash command in `<command-name>` tags the way
38
51
  // Claude Code's user turn does. Their native evidence is the command *expansion*:
39
52
  // omp replaces `/ukit:handoff-fullstack` with the command body, whose FIRST
40
53
  // non-empty line is the H1 `# /ukit:handoff-fullstack — …`; Codex records the same
41
54
  // expanded body as a `response_item`/`message` with `input_text` blocks. Matching
42
55
  // the FIRST line only (not any heading in the body) keeps prose references to the
43
- // command inside the document from being mistaken for an invocation.
44
- const NATIVE_COMMAND_HEADING = /^\s{0,3}#{1,3}\s+\/?(?:ukit:)?(handoff-create|handoff-fullstack)\b/;
56
+ // command inside the document from being mistaken for an invocation. The same
57
+ // exact token boundary applies: `-custom` suffixed headings are not commands.
58
+ const NATIVE_COMMAND_HEADING = /^\s{0,3}#{1,3}\s+\/?(?:ukit:)?(handoff-create|handoff-fullstack|handoff-implement)(?![\w-])/;
45
59
 
46
60
  // The unique customType the omp bridge stamps on host-injected UKit hook context
47
61
  // (`sendContext` → `hookContextMessage`). A resume banner only counts as intent
@@ -62,6 +76,19 @@ function firstNonEmptyLine(text) {
62
76
  return '';
63
77
  }
64
78
 
79
+ /**
80
+ * Map an exact command token (full `handoff-implement` or the captured suffix
81
+ * `implement`) onto the two intent values. `handoff-implement` CLAIMS the run,
82
+ * so it maps onto the existing 'fullstack' intent.
83
+ * @returns {'create' | 'fullstack' | null}
84
+ */
85
+ function commandTokenIntent(token) {
86
+ const t = String(token).toLowerCase().replace(/^handoff-/, '');
87
+ if (t === 'create') return 'create';
88
+ if (t === 'fullstack' || t === 'implement') return 'fullstack';
89
+ return null;
90
+ }
91
+
65
92
  /**
66
93
  * Does this text open with a native (omp/Codex) command-expansion heading?
67
94
  * Only the FIRST non-empty line is inspected, so an H1 that appears later in the
@@ -72,7 +99,7 @@ function firstNonEmptyLine(text) {
72
99
  function nativeCommandIntent(text) {
73
100
  const m = firstNonEmptyLine(text).match(NATIVE_COMMAND_HEADING);
74
101
  if (!m) return null;
75
- return m[1] === 'handoff-create' ? 'create' : 'fullstack';
102
+ return commandTokenIntent(m[1]);
76
103
  }
77
104
 
78
105
  // Bounds for the tail scan. Recency is what matters, so the reader walks the
@@ -109,51 +136,61 @@ function splitUserContent(content) {
109
136
  return { text, blocks, hasToolOutput };
110
137
  }
111
138
 
139
+ /**
140
+ * Classify one user text block. Structured evidence only: exact Claude Code
141
+ * `<command-name>` tags (the LAST one in the block wins) or a native
142
+ * command-expansion heading on the block's first line.
143
+ * @returns {'fullstack' | 'create' | null}
144
+ */
145
+ function classifyUserText(text) {
146
+ const tags = [...String(text ?? '').matchAll(COMMAND_TAG_SWEEP)];
147
+ if (tags.length > 0) return commandTokenIntent(tags[tags.length - 1][1]);
148
+ return nativeCommandIntent(text);
149
+ }
150
+
112
151
  /**
113
152
  * Classify a user turn (Claude Code `type:'user'`, omp `type:'message'` role user,
114
- * Codex `type:'response_item'` payload message role user). Structured evidence only:
115
- * a Claude Code `<command-name>` tag, or a native command-expansion heading on the
116
- * first line of a user text block.
153
+ * Codex `type:'response_item'` payload message role user). The LATEST qualifying
154
+ * evidence inside the entry wins — a later tag/block supersedes an earlier one,
155
+ * while trailing non-qualifying content never erases it. Tool-output blocks
156
+ * (`tool_result` round-trips) are not user words and never carry intent, so a
157
+ * turn that is purely tool output has no user text to match.
117
158
  * @returns {'fullstack' | 'create' | null}
118
159
  */
119
160
  function classifyUserTurn(content) {
120
- const { text, blocks, hasToolOutput } = splitUserContent(content);
121
- // Check the planning tag first: a single turn carrying both is a create turn.
122
- if (HANDOFF_CREATE_TAG.test(text)) return 'create';
123
- if (HANDOFF_FULLSTACK_TAG.test(text)) return 'fullstack';
124
- // Native (omp / Codex) command expansion — the FIRST line of a user text block.
125
- // A user turn that is purely tool output has no user text to match, so a
126
- // `tool_result` carrying the marker string can never register as intent.
161
+ const { blocks } = splitUserContent(content);
162
+ let latest = null;
127
163
  for (const block of blocks) {
128
- const intent = nativeCommandIntent(block);
129
- if (intent) return intent;
164
+ const intent = classifyUserText(block);
165
+ if (intent) latest = intent;
130
166
  }
131
- if (hasToolOutput) return null;
132
- return null;
167
+ return latest;
133
168
  }
134
169
 
135
170
  /**
136
171
  * Classify assistant tool-call blocks. Handles both Claude Code (`tool_use`, name
137
- * `Skill`, input.skill) and omp (`toolCall`, name/arguments.skill).
172
+ * `Skill`, input.skill) and omp (`toolCall`, name/arguments.skill). The LAST
173
+ * qualifying block wins; unrelated tool blocks never erase it.
138
174
  */
139
175
  function classifyAssistantBlocks(blocks) {
140
176
  if (!Array.isArray(blocks)) return null;
177
+ let latest = null;
141
178
  for (const b of blocks) {
142
179
  if (!b || typeof b !== 'object') continue;
143
180
  if (b.type === 'tool_use' && b.name === 'Skill') {
144
181
  const skill = String(b.input?.skill ?? '');
145
- if (/^(?:ukit:)?handoff-create$/.test(skill)) return 'create';
146
- if (/^(?:ukit:)?handoff-fullstack$/.test(skill)) return 'fullstack';
182
+ const m = /^(?:ukit:)?handoff-(create|fullstack|implement)$/.exec(skill);
183
+ if (m) latest = commandTokenIntent(m[1]);
147
184
  continue;
148
185
  }
149
186
  if (b.type === 'toolCall') {
150
187
  const name = String(b.name ?? '');
151
188
  const skill = String(b.arguments?.skill ?? '');
152
- if (/^(?:ukit:)?handoff-create$/i.test(name) || /^(?:ukit:)?handoff-create$/.test(skill)) return 'create';
153
- if (/^(?:ukit:)?handoff-fullstack$/i.test(name) || /^(?:ukit:)?handoff-fullstack$/.test(skill)) return 'fullstack';
189
+ const m = /^(?:ukit:)?handoff-(create|fullstack|implement)$/i.exec(name) || /^(?:ukit:)?handoff-(create|fullstack|implement)$/.exec(skill);
190
+ if (m) latest = commandTokenIntent(m[1]);
154
191
  }
155
192
  }
156
- return null;
193
+ return latest;
157
194
  }
158
195
 
159
196
  /**
@@ -170,7 +207,9 @@ function classifyAssistantBlocks(blocks) {
170
207
  * - Codex: `{ type:'response_item', payload:{ type:'message', role:'user', content:[input_text…] } }`
171
208
  *
172
209
  * Anything else — including a format we do not recognise — returns null (never a
173
- * guessed intent), so the caller's fail-closed posture is preserved.
210
+ * guessed intent), so the caller's fail-closed posture is preserved. Within one
211
+ * entry the LATEST qualifying evidence wins; `-custom` suffixed command names and
212
+ * tool-result / unknown-namespace content are never intent.
174
213
  * @returns {'fullstack' | 'create' | null}
175
214
  */
176
215
  export function classifyHandoffEntry(entry) {
@@ -263,11 +302,30 @@ export async function transcriptHandoffIntent(transcriptPath, {
263
302
  const line = lines[i];
264
303
  if (!line) continue;
265
304
  // Cheap prefilter — skip JSON.parse for lines that cannot be evidence.
266
- if (!line.includes('handoff-create') && !line.includes('handoff-fullstack') && !line.includes('HANDOFF RESUME')) {
305
+ // `handoff-implement` is included: an implement invocation after a create
306
+ // must be seen as the NEWEST intent, not lost to the older create line.
307
+ if (!line.includes('handoff-create') && !line.includes('handoff-fullstack') && !line.includes('handoff-implement') && !line.includes('HANDOFF RESUME')) {
267
308
  continue;
268
309
  }
269
- const intent = classifyHandoffLine(line);
310
+ // This is the NEWEST line carrying intent evidence. A host append can be
311
+ // cut mid-record (killed turn, torn write), leaving this line as unparseable
312
+ // JSON. Skipping it like any other non-qualifying line would fall through to
313
+ // an OLDER — possibly OPPOSITE — intent and misreport the session's current
314
+ // ownership (a torn `handoff-implement` after a create released a real
315
+ // fullstack run). An incomplete newest evidence-bearing record is therefore
316
+ // UNDETERMINED, not "no intent": return unknown so fail-closed callers keep
317
+ // their blocking/conservative posture (MODULE02-SPEC FR-M2-002).
318
+ let entry;
319
+ try {
320
+ entry = JSON.parse(line);
321
+ } catch {
322
+ return { intent: null, known: false };
323
+ }
324
+ const intent = classifyHandoffEntry(entry);
270
325
  if (intent) return { intent, known: true };
326
+ // Complete record that carries no recognised intent (tool output, a foreign
327
+ // envelope, a malformed-but-whole unrelated line): not evidence — keep
328
+ // walking older lines.
271
329
  }
272
330
  // Nothing found. Only a scan of the WHOLE transcript proves "no handoff
273
331
  // intent"; a tail window that missed it (the invoking turn usually appears
@@ -43,6 +43,23 @@ const FAIL_CLOSED_SCRIPTS = new Set([
43
43
 
44
44
  const MAX_BUFFER_BYTES = 2 * 1024 * 1024;
45
45
 
46
+ // C97 (SPEC §FR-002): bound for replaying a triggering step's already-captured
47
+ // stderr diagnostic in the skippedFailClosed verdict. Large enough for real
48
+ // causes, small enough that a flooded capture cannot drown the skipped-gate
49
+ // notice. The excerpt is cut at a UTF-8 code-point boundary so the emitted
50
+ // diagnostic is always valid UTF-8.
51
+ const CAUSE_EXCERPT_MAX_BYTES = 4096;
52
+
53
+ function boundedUtf8Excerpt(text, maxBytes = CAUSE_EXCERPT_MAX_BYTES) {
54
+ const buf = Buffer.from(String(text ?? ''), 'utf8');
55
+ if (buf.length <= maxBytes) return { text: buf.toString('utf8'), truncated: false };
56
+ let end = maxBytes;
57
+ // Back off over UTF-8 continuation bytes (0b10xxxxxx) so the cut never
58
+ // splits a code point.
59
+ while (end > 0 && (buf[end] & 0xc0) === 0x80) end--;
60
+ return { text: buf.subarray(0, end).toString('utf8'), truncated: true };
61
+ }
62
+
46
63
  // TASK-234 review fix (critical): stdin staging must be bounded. The old
47
64
  // `fs.readFileSync(0)` blocked until the producer closed the pipe — a stalled
48
65
  // producer held the runner until the settings timeout SIGKILLed it, so every
@@ -700,14 +717,57 @@ try {
700
717
  // TASK-234 review fix (critical): a mid-chain break that skipped a
701
718
  // fail-closed gate must fail CLOSED — the old per-script path ran every
702
719
  // hook independently, so a killed advisory never skipped a gate.
720
+ // C97 (SPEC §FR-002): the old emission named the unrun gates but dropped
721
+ // the triggering step's already-captured diagnostic entirely (the
722
+ // sibling fail-closed branch below DOES replay `last.stderr`), so an
723
+ // exit-1 gate's cause never reached the operator. Provenance now names
724
+ // the triggering result's step + failureKind — an actual child as the
725
+ // attempted step; the synthetic budget-exhausted result (elapsedMs 0,
726
+ // killed true: the script never launched) explicitly as unattempted —
727
+ // and replays the step's captured stderr ONCE, bounded. An
728
+ // output-overflow capture is truncated mid-stream and may embed anything
729
+ // (payloads, secrets), so it never leaves this process; the generic
730
+ // stdout message carries no raw stderr.
703
731
  if (contextStdout) process.stdout.write(contextStdout);
732
+ const broke = chain.results[chain.results.length - 1] ?? null;
704
733
  const skipped = scriptPaths
705
734
  .slice(chain.results.length)
706
735
  .map((p) => failClosedName(p))
707
736
  .filter((name) => FAIL_CLOSED_SCRIPTS.has(name))
708
737
  .join(', ');
738
+ const unattempted = broke?.failureKind === 'budget-exhausted';
739
+ const causeLabel = !broke
740
+ ? 'unknown step'
741
+ : unattempted
742
+ ? `${broke.scriptName} (${broke.failureKind}, unattempted — budget exhausted before launch)`
743
+ : `${broke.scriptName} (${broke.failureKind}, attempted step)`;
744
+ const provenanceMsg = `UKit hook chain broke at ${causeLabel}.\n`;
709
745
  const brokeMsg = `UKit hook chain broke before fail-closed gate(s) ran: ${skipped}\n`;
746
+ process.stderr.write(provenanceMsg);
747
+ const causeText = broke
748
+ && broke.failureKind !== 'output-overflow'
749
+ && typeof broke.stderr === 'string'
750
+ ? broke.stderr
751
+ : '';
752
+ if (causeText.trim()) {
753
+ const excerpt = boundedUtf8Excerpt(causeText);
754
+ process.stderr.write(
755
+ excerpt.truncated
756
+ ? `${excerpt.text}\n[ukit] cause excerpt truncated at ${CAUSE_EXCERPT_MAX_BYTES} UTF-8 bytes\n`
757
+ : (excerpt.text.endsWith('\n') ? excerpt.text : `${excerpt.text}\n`),
758
+ );
759
+ }
710
760
  process.stderr.write(brokeMsg + breakerAdvice(brokeMsg));
761
+ if (!contextStdout) {
762
+ // Otherwise-empty stdout: emit exactly ONE parseable JSON document so
763
+ // the host still gets a single valid verdict — the generic failure
764
+ // (step/kind/skipped gates), never the raw cause. Appending it after
765
+ // prior stdout would create an invalid two-document stream, so prior
766
+ // stdout suppresses it entirely.
767
+ process.stdout.write(JSON.stringify({
768
+ systemMessage: `${provenanceMsg.trimEnd()} ${brokeMsg.trimEnd()}`,
769
+ }));
770
+ }
711
771
  process.exitCode = 2;
712
772
  } else if (!last) {
713
773
  // No script ran at all (empty chain or budget spent before the first
@@ -143,9 +143,19 @@ const REASON_CODES = new Set([
143
143
 
144
144
  const RESOURCE_SEMANTICS = new Set(['model.completed', 'model.failed']);
145
145
 
146
+ // Schema v1.3 additive payload extension (C98 FR-02): closed usage-category
147
+ // key set — mirror of USAGE_CATEGORY_KEYS in schema/constants.js.
148
+ const USAGE_CATEGORY_KEYS = Object.freeze([
149
+ 'fresh_input_tokens',
150
+ 'cache_read_tokens',
151
+ 'cache_write_tokens',
152
+ 'output_tokens',
153
+ 'reasoning_tokens',
154
+ ]);
155
+
146
156
  // --- frozen privacy contract (mirror of privacy/allowlist.js) ------------
147
157
 
148
- const REDACTION_VERSION = 'df-redact-6';
158
+ const REDACTION_VERSION = 'df-redact-7';
149
159
  const ALLOWED_FIELDS = ENVELOPE_FIELDS;
150
160
  const DENIED_PAYLOAD_FIELDS = new Set([
151
161
  'error_detail', 'feedback', 'comment', 'note_text', 'prompt', 'prompt_text',
@@ -299,7 +309,47 @@ function sanitizeObserved(record, options = {}) {
299
309
 
300
310
  // --- validate (mirror of schema/validate.js, sans cross-record context) ----
301
311
 
302
- function validateSemanticRecord(record) {
312
+ // Schema v1.3 additive payload extension (SPEC FR-02): typed per-category
313
+ // token usage on model.completed|failed. Accept/reject outcomes must match
314
+ // validateUsageCategories in src/core/observability/schema/validate.js
315
+ // exactly; message texts may differ.
316
+ function validateUsageCategories(payload, semanticName, errors) {
317
+ const categories = payload.usage_categories;
318
+ if (categories === undefined || categories === null) return;
319
+ if (semanticName !== 'model.completed' && semanticName !== 'model.failed') {
320
+ errors.push('payload.usage_categories is only valid on model.completed|model.failed');
321
+ return;
322
+ }
323
+ if (!isPlainObject(categories)) {
324
+ errors.push('payload.usage_categories must be an object keyed by usage category');
325
+ return;
326
+ }
327
+ for (const [key, category] of Object.entries(categories)) {
328
+ if (!USAGE_CATEGORY_KEYS.includes(key)) {
329
+ errors.push(`payload.usage_categories.${key} is not a known usage category`);
330
+ continue;
331
+ }
332
+ if (!isPlainObject(category)) {
333
+ errors.push(`payload.usage_categories.${key} must be an object {value, source}`);
334
+ continue;
335
+ }
336
+ if (!RESOURCE_SOURCES.has(category.source)) {
337
+ errors.push(`payload.usage_categories.${key}.source invalid`);
338
+ }
339
+ const { value } = category;
340
+ if (value === null) {
341
+ if (category.source !== 'UNKNOWN') {
342
+ errors.push(`payload.usage_categories.${key}.value must be null only when source is UNKNOWN`);
343
+ }
344
+ } else if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
345
+ errors.push(`payload.usage_categories.${key}.value must be a finite integer >= 0 or null`);
346
+ } else if (category.source === 'UNKNOWN') {
347
+ errors.push(`payload.usage_categories.${key}.value must be null when source is UNKNOWN`);
348
+ }
349
+ }
350
+ }
351
+
352
+ export function validateSemanticRecord(record) {
303
353
  const errors = [];
304
354
  if (!isPlainObject(record)) return { ok: false, errors: ['record must be a plain object'] };
305
355
 
@@ -385,6 +435,7 @@ function validateSemanticRecord(record) {
385
435
  errors.push('payload must be an object');
386
436
  } else {
387
437
  const payload = record.payload;
438
+ validateUsageCategories(payload, record.semantic_name, errors);
388
439
  if (payload.resource !== undefined && payload.resource !== null) {
389
440
  const resource = payload.resource;
390
441
  if (!isPlainObject(resource)) {
@@ -1204,6 +1204,18 @@ function extractExitCode(payload) {
1204
1204
  return null;
1205
1205
  }
1206
1206
 
1207
+ // FR-M5-004 (TASK-C97-014): child identity is a top-level, nonempty, trimmed string
1208
+ // `agent_id` OR `agent_type` — the same documented subagent marker skill-router.sh
1209
+ // reads for its sidechain guard. Nested output text that merely contains marker
1210
+ // words is never authority, and missing/blank/nonstring markers fall through to the
1211
+ // existing main-session accounting unchanged.
1212
+ function isChildToolPayload(payload) {
1213
+ if (!payload || typeof payload !== 'object') return false;
1214
+ return [payload.agent_id, payload.agent_type].some(
1215
+ (marker) => typeof marker === 'string' && marker.trim() !== '',
1216
+ );
1217
+ }
1218
+
1207
1219
  function extractText(payload, preferredKeys = []) {
1208
1220
  const queue = [payload];
1209
1221
  const seen = new Set();
@@ -1376,6 +1388,10 @@ async function main() {
1376
1388
 
1377
1389
  const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
1378
1390
  const config = await loadRuntimeConfig(projectRoot);
1391
+ // FR-M5-004 (TASK-C97-014): resolved once from the parsed payload and applied at
1392
+ // BOTH raw-output pressure updates below. Deliberately NOT an early exit — the
1393
+ // compression, cache, history, tee and hard-cap paths all keep running for a child.
1394
+ const isChildOutput = isChildToolPayload(payload);
1379
1395
  // Session-scoped pressure bookkeeping: output tokens must land in the calling
1380
1396
  // session's record, never a sibling session's.
1381
1397
  const sessionConfig = {
@@ -1448,16 +1464,21 @@ async function main() {
1448
1464
  recoveryReason: cached.metadata?.recoveryReason ?? null,
1449
1465
  truncated: Boolean(cached.metadata?.truncated),
1450
1466
  });
1451
- await updateCompactPressureFromOutput(projectRoot, {
1452
- timestamp: Date.now(),
1453
- command: cachedCommand,
1454
- profile: cachedProfile,
1455
- summary: String(cached.content),
1456
- tokensBefore: cached.tokensBefore ?? 0,
1457
- tokensAfter: cached.tokensAfter ?? 0,
1458
- savedTokens: cached.savedTokens ?? 0,
1459
- exitCode,
1460
- }, sessionConfig);
1467
+ // FR-M5-004: a subagent's raw output must not be charged to its parent's
1468
+ // compact pressure. Suppress ONLY this pressure write — cache/history/tee
1469
+ // writes above, the summary on stdout below, and every safety gate are untouched.
1470
+ if (!isChildOutput) {
1471
+ await updateCompactPressureFromOutput(projectRoot, {
1472
+ timestamp: Date.now(),
1473
+ command: cachedCommand,
1474
+ profile: cachedProfile,
1475
+ summary: String(cached.content),
1476
+ tokensBefore: cached.tokensBefore ?? 0,
1477
+ tokensAfter: cached.tokensAfter ?? 0,
1478
+ savedTokens: cached.savedTokens ?? 0,
1479
+ exitCode,
1480
+ }, sessionConfig);
1481
+ }
1461
1482
  process.stdout.write(String(cached.content));
1462
1483
  return;
1463
1484
  }
@@ -1548,16 +1569,21 @@ async function main() {
1548
1569
  savedTokens: result.savedTokens,
1549
1570
  metadata: { command: result.command, exitCode },
1550
1571
  });
1551
- await updateCompactPressureFromOutput(projectRoot, {
1552
- timestamp: Date.now(),
1553
- command: result.command,
1554
- profile: result.profile,
1555
- summary: result.summary,
1556
- tokensBefore: result.tokensBefore,
1557
- tokensAfter: result.tokensAfter,
1558
- savedTokens: result.savedTokens,
1559
- exitCode,
1560
- }, sessionConfig);
1572
+ // FR-M5-004: same suppression on the fresh path — a child's raw output never
1573
+ // charges the parent's compact estimate; compression, history, tee, the recovery
1574
+ // anchor and every downstream gate keep their existing behavior.
1575
+ if (!isChildOutput) {
1576
+ await updateCompactPressureFromOutput(projectRoot, {
1577
+ timestamp: Date.now(),
1578
+ command: result.command,
1579
+ profile: result.profile,
1580
+ summary: result.summary,
1581
+ tokensBefore: result.tokensBefore,
1582
+ tokensAfter: result.tokensAfter,
1583
+ savedTokens: result.savedTokens,
1584
+ exitCode,
1585
+ }, sessionConfig);
1586
+ }
1561
1587
 
1562
1588
  if (config?.tokenPipeline?.promptCache) {
1563
1589
  await writePromptCacheEntry(projectRoot, {
@@ -966,7 +966,7 @@ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), loc
966
966
  } catch {
967
967
  return { kind: 'none' };
968
968
  }
969
- const { goal, phase, cursor, next, exitPredicate } = parseRunCursor(text);
969
+ const { command, goal, phase, cursor, next, exitPredicate } = parseRunCursor(text);
970
970
  // Shared terminal predicate — tests/handoff/cycle21 greps this exact shape across
971
971
  // all four RUN.md consumers; keep `^(done|blocked)` as one expression.
972
972
  const terminal = String(phase).match(/^(done|blocked)\b/i)?.[1]?.toLowerCase() || '';
@@ -980,7 +980,8 @@ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), loc
980
980
  // must not be bounced into a stale run. Only a positive "scanned the whole
981
981
  // transcript, latest intent is not fullstack" releases; unknown (null) keeps
982
982
  // the block, so a real run is never released by a lost read.
983
- if (gate.scope === 'session' && await transcriptShowsHandoffRun(transcriptPath) === false) {
983
+ const transcriptOwner = gate.scope === 'session' ? await transcriptShowsHandoffRun(transcriptPath) : true;
984
+ if (gate.scope === 'session' && transcriptOwner === false) {
984
985
  return { kind: 'none' };
985
986
  }
986
987
 
@@ -1008,6 +1009,19 @@ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), loc
1008
1009
  return { kind: 'block', reason: lines.join('\n') };
1009
1010
  }
1010
1011
 
1012
+ // Nonterminal planning cursor (TASK-C97-006, FR-M2-003): a RUN.md whose Command
1013
+ // is EXACTLY `handoff-create` is the planner's own cursor — it does not demand
1014
+ // fullstack implementation even when the session's intent is unknown (no
1015
+ // transcript path, unreadable, known-null). Stop releases UNLESS the latest
1016
+ // structured intent positively reacquired the run (`fullstack`: an explicit
1017
+ // handoff-implement or handoff-fullstack turn). This is the ONLY command
1018
+ // exception: actual handoff-fullstack/implement/review and a missing/unknown
1019
+ // Command keep the legacy posture, including the unknown fail-closed block. The
1020
+ // terminal `done` branch above is untouched.
1021
+ if (String(command).trim().toLowerCase() === 'handoff-create' && transcriptOwner !== true) {
1022
+ return { kind: 'none' };
1023
+ }
1024
+
1011
1025
  const signature = `${phase}|${cursor}|${next}`;
1012
1026
  const streak = await bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs });
1013
1027
  if (streak !== null && streak > gate.maxStalledBlocks) {
@@ -138,9 +138,16 @@ mode only; daily flow does not get a Progress section.
138
138
  reports `undeclared-drift`.
139
139
  - **Milestone commit.** The commit lives on your worktree branch only (e.g.
140
140
  `handoff/task-xxx`). Use a `-m "milestone: <name>"` message. Never commit outside your
141
- worktree, never push. The orchestrator's copy-back step (`git diff --name-only` against
142
- base) diffs the working tree, so intermediate commits on the worktree branch are free —
143
- **copy-back is unaffected** by milestone commits.
141
+ worktree, never push. The orchestrator's copy-back step lands the branch's final
142
+ base-relative snapshot (`land-worktree.mjs` diffs the working tree against the immutable
143
+ base), so intermediate commits on the worktree branch are free — **copy-back is
144
+ unaffected** by milestone commits.
145
+ - **Final clean milestone commit (required before PASS).** End your run with one final
146
+ milestone commit so the worktree's snapshot equals its committed tree — nothing staged,
147
+ nothing unstaged, no nonignored untracked file left behind. A Git-clean snapshot is what
148
+ lets a later receipt-gated cleanup remove the worktree without `--force`; a tree dirty
149
+ against the snapshot is retained with a named reason, never forced. Do not report PASS on
150
+ a dirty worktree.
144
151
  - **Resume.** When a previous run aborted, you will be respawned. Read `## Progress`,
145
152
  jump straight to the last entry whose `last-green:` is not `none`, and continue from
146
153
  there. Do NOT re-plan, do NOT restart from RED, do NOT re-write tests that already