@ngockhoale/ukit 3.4.22 → 3.4.23
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/CHANGELOG.md +15 -0
- package/manifests/platform.full.yaml +29 -0
- package/package.json +1 -1
- package/src/core/docContracts.js +60 -6
- package/template_project/.claude/agents/feature-implementer.md +10 -3
- package/template_project/.claude/commands/ukit/handoff-clear.md +27 -12
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +65 -32
- package/template_project/.claude/commands/ukit/handoff-implement.md +44 -23
- package/template_project/.claude/hooks/handoff-resume.sh +36 -11
- package/template_project/.claude/hooks/protect-files.sh +10 -2
- package/template_project/.claude/hooks/vision-router.sh +11 -1
- package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +418 -0
- package/template_project/.claude/ukit/index/extract-image.mjs +77 -22
- package/template_project/.claude/ukit/index/land-worktree.mjs +543 -0
- package/template_project/.claude/ukit/index/stale-spec-check.mjs +48 -1
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +86 -28
- package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +60 -0
- package/template_project/.claude/ukit/runtime/output-compression.mjs +46 -20
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +16 -2
- 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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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]
|
|
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).
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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 {
|
|
121
|
-
|
|
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 =
|
|
129
|
-
if (intent)
|
|
164
|
+
const intent = classifyUserText(block);
|
|
165
|
+
if (intent) latest = intent;
|
|
130
166
|
}
|
|
131
|
-
|
|
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
|
-
|
|
146
|
-
if (
|
|
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
|
-
|
|
153
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
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
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
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
|
-
|
|
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
|
|
142
|
-
base
|
|
143
|
-
**copy-back is
|
|
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
|