@ngockhoale/ukit 2.3.15 → 2.3.17

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.
@@ -0,0 +1,157 @@
1
+ // Pure checker for the executor milestone protocol.
2
+ //
3
+ // A TASK-xxx.md that carries an `## Executor Report` must also carry a `## Progress` section.
4
+ // Each `## Progress` entry uses a fixed format:
5
+ //
6
+ // - <ISO-8601 timestamp> · milestone: <name> · last-green: <what passed> · files: <paths> · drift: none|<reason>
7
+ //
8
+ // Rules implemented here (canonical wording for the planner/executor contract):
9
+ //
10
+ // 1. A task carrying an Executor Report must have ≥1 `## Progress` entry.
11
+ // Violation type: 'no-progress'.
12
+ //
13
+ // 2. The newest entry (max parsed timestamp, not last line) must be within
14
+ // `2 × milestoneIntervalMin` of `now`. The boundary is INCLUSIVE at exactly
15
+ // `2 × milestoneIntervalMin` old (still OK) and EXCLUSIVE one minute past
16
+ // (stale). "Strictly beyond" the boundary is stale.
17
+ // Violation type: 'stale-milestone'.
18
+ //
19
+ // 3. Every `files:` path in an entry must appear under `## Target Files`
20
+ // unless that entry's `drift:` field carries a non-`none` reason.
21
+ // Violation type: 'undeclared-drift'.
22
+ //
23
+ // 4. Entries with unparseable timestamps are reported as 'malformed-entry'
24
+ // and never throw.
25
+ //
26
+ // The module is intentionally tiny and pure: no fs, no Date.now() side effects.
27
+ // `now` defaults to Date.now() so call sites that want determinism (tests,
28
+ // future watchdog integration) can inject a fixed timestamp.
29
+
30
+ const PROGRESS_HEADING = /^##\s+Progress\s*$/m;
31
+ const REPORT_HEADING = /^##\s+Executor Report\s*$/m;
32
+ const TARGET_HEADING = /^##\s+Target Files\s*$/m;
33
+ const ENTRY_RE = /^-\s+(\S+)\s+·\s+milestone:\s*([^·]*)·\s+last-green:\s*([^·]*)·\s+files:\s*([^·]*)·\s+drift:\s*(.*)$/;
34
+
35
+ function parseEntry(line) {
36
+ const m = ENTRY_RE.exec(line);
37
+ if (!m) return null;
38
+ const [, ts, milestone, lastGreen, filesField, drift] = m;
39
+ const files = filesField
40
+ .split(',')
41
+ .map((s) => s.trim())
42
+ .filter(Boolean);
43
+ const tsNum = Date.parse(ts);
44
+ if (Number.isNaN(tsNum)) {
45
+ return { ok: false, raw: line };
46
+ }
47
+ return {
48
+ ok: true,
49
+ timestamp: tsNum,
50
+ milestone: milestone.trim(),
51
+ lastGreen: lastGreen.trim(),
52
+ files,
53
+ drift: drift.trim(),
54
+ };
55
+ }
56
+
57
+ function sliceSection(markdown, headingRe) {
58
+ const match = headingRe.exec(markdown);
59
+ if (!match) return null;
60
+ const start = match.index + match[0].length;
61
+ const rest = markdown.slice(start);
62
+ // Section ends at the next `## ` heading or EOF.
63
+ const nextHeading = /\n##\s+/.exec(rest);
64
+ const end = nextHeading ? start + nextHeading.index : markdown.length;
65
+ return markdown.slice(start, end);
66
+ }
67
+
68
+ function collectTargetFiles(markdown) {
69
+ const section = sliceSection(markdown, TARGET_HEADING);
70
+ if (!section) return new Set();
71
+ const files = new Set();
72
+ for (const line of section.split('\n')) {
73
+ // Lines look like "- `path/to/file.js` — what changes"
74
+ const m = /^\s*-\s+`?([^`\s]+(?:\.[^`\s]+)?)`?/.exec(line);
75
+ if (m) files.add(m[1]);
76
+ }
77
+ return files;
78
+ }
79
+
80
+ function collectProgressEntries(progressSection) {
81
+ const entries = [];
82
+ for (const line of progressSection.split('\n')) {
83
+ if (!line.trim().startsWith('- ')) continue;
84
+ const parsed = parseEntry(line.trim());
85
+ if (parsed) entries.push(parsed);
86
+ }
87
+ return entries;
88
+ }
89
+
90
+ export function checkMilestoneProgress({
91
+ markdown,
92
+ now = Date.now(),
93
+ milestoneIntervalMin = 5,
94
+ } = {}) {
95
+ const violations = [];
96
+
97
+ if (typeof markdown !== 'string' || markdown.length === 0) {
98
+ return { ok: false, violations: [{ type: 'no-progress', detail: 'markdown is empty' }] };
99
+ }
100
+
101
+ if (!REPORT_HEADING.test(markdown)) {
102
+ // No Executor Report — nothing to check.
103
+ return { ok: true, violations: [] };
104
+ }
105
+
106
+ if (!PROGRESS_HEADING.test(markdown)) {
107
+ return {
108
+ ok: false,
109
+ violations: [{ type: 'no-progress', detail: '## Progress section missing' }],
110
+ };
111
+ }
112
+
113
+ const progressSection = sliceSection(markdown, PROGRESS_HEADING);
114
+ const entries = collectProgressEntries(progressSection || '');
115
+
116
+ if (entries.length === 0) {
117
+ violations.push({ type: 'no-progress', detail: '## Progress section has no entries' });
118
+ return { ok: false, violations };
119
+ }
120
+
121
+ let newestTs = -Infinity;
122
+ for (const entry of entries) {
123
+ if (!entry.ok) {
124
+ violations.push({ type: 'malformed-entry', detail: `unparseable timestamp: ${entry.raw}` });
125
+ continue;
126
+ }
127
+ if (entry.timestamp > newestTs) newestTs = entry.timestamp;
128
+ }
129
+
130
+ if (Number.isFinite(newestTs)) {
131
+ const limitMs = 2 * milestoneIntervalMin * 60 * 1000;
132
+ const ageMs = now - newestTs;
133
+ // Boundary: ageMs === limitMs is still OK; ageMs > limitMs is stale.
134
+ if (ageMs > limitMs) {
135
+ violations.push({
136
+ type: 'stale-milestone',
137
+ detail: `newest entry is ${Math.round(ageMs / 60000)} min old; threshold is ${2 * milestoneIntervalMin} min`,
138
+ });
139
+ }
140
+ }
141
+
142
+ const targetFiles = collectTargetFiles(markdown);
143
+ for (const entry of entries) {
144
+ if (!entry.ok) continue;
145
+ const driftReason = entry.drift && entry.drift !== 'none' ? entry.drift : null;
146
+ for (const file of entry.files) {
147
+ if (!targetFiles.has(file) && !driftReason) {
148
+ violations.push({
149
+ type: 'undeclared-drift',
150
+ detail: `file '${file}' not in ## Target Files and entry has no drift reason`,
151
+ });
152
+ }
153
+ }
154
+ }
155
+
156
+ return { ok: violations.length === 0, violations };
157
+ }
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
 
3
3
  const VALID_ITEM_TYPES = new Set(['command', 'skill', 'agent', 'hook', 'config', 'link']);
4
- const VALID_MERGE_STRATEGIES = new Set(['overwrite_with_backup', 'skip', 'append', 'prepend']);
4
+ const VALID_MERGE_STRATEGIES = new Set(['overwrite_with_backup', 'skip', 'append', 'prepend', 'merge_env_overwrite_with_backup']);
5
5
 
6
6
  function isPlainObject(value) {
7
7
  return value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -117,6 +117,36 @@ what lets the cycle reach the end.
117
117
  - The next AI session (any tool, model from `handoff.reviewer.model`, MUST differ from executor) will pick `pending_review` task and run review.
118
118
  - Do NOT dispatch reviewer in-process unless your host explicitly supports it AND can guarantee a different model — file-based handoff is the default.
119
119
 
120
+ ## Milestone landing (mandatory — Handoff mode)
121
+
122
+ Long runs must land work to disk continuously, so a killed turn loses at most one milestone
123
+ and a respawn picks up exactly where the previous executor stopped. This applies in Handoff
124
+ mode only; daily flow does not get a Progress section.
125
+
126
+ - **Cadence.** Every `handoff.milestoneIntervalMin` (default 5) wall-clock minutes OR at
127
+ every RED → GREEN → verify milestone (whichever comes first), append a Progress entry to
128
+ the task file and make a milestone commit on YOUR worktree branch.
129
+ - **Entry format (fixed — `src/core/taskProgressGuard.js` parses it).**
130
+
131
+ `- <ISO-8601 local timestamp> · milestone: <name> · last-green: <what passed> · files: <comma-separated paths> · drift: none|<one line why>`
132
+
133
+ Re-state every file changed since the previous entry. If a file is not under
134
+ `## Target Files`, the `drift:` field MUST carry a one-line reason; otherwise the guard
135
+ reports `undeclared-drift`.
136
+ - **Milestone commit.** The commit lives on your worktree branch only (e.g.
137
+ `handoff/task-xxx`). Use a `-m "milestone: <name>"` message. Never commit outside your
138
+ worktree, never push. The orchestrator's copy-back step (`git diff --name-only` against
139
+ base) diffs the working tree, so intermediate commits on the worktree branch are free —
140
+ **copy-back is unaffected** by milestone commits.
141
+ - **Resume.** When a previous run aborted, you will be respawned. Read `## Progress`,
142
+ jump straight to the last entry whose `last-green:` is not `none`, and continue from
143
+ there. Do NOT re-plan, do NOT restart from RED, do NOT re-write tests that already
144
+ passed. The progress section is the resume contract.
145
+ - **Staleness.** If your newest entry is older than `2 × handoff.milestoneIntervalMin`,
146
+ the guard reports `stale-milestone`; the boundary is inclusive at exactly `2 ×` and
147
+ exclusive one minute beyond. Keep entries fresh — the cadence rule above is what
148
+ guarantees that.
149
+
120
150
  ## Rules
121
151
 
122
152
  - **Iron law (Handoff mode):** no `DONE` without fresh PASS output in the current turn.
@@ -116,6 +116,18 @@ Missing any field → `needs_breakdown`. Never mark incomplete tasks `ready`.
116
116
  - Chain: A → B → C runs as 3 sequential waves (1 task each, no parallelism)
117
117
  - Independent: A, B, C (all `none`) runs as 1 wave, all parallel
118
118
 
119
+ ### Task-budget validator — gate before any task becomes `ready`
120
+
121
+ Before marking a task `ready`, run the task-budget validator on the file you just wrote:
122
+
123
+ ```
124
+ node .claude/ukit/index/task-budget-validator.mjs docs/AI_HANDOFF/tasks/TASK-xxx.md
125
+ ```
126
+
127
+ The validator's first stdout line is `VERDICT: ok` or `VERDICT: needs_breakdown`, followed by `REASON:` lines. It always exits 0 (advisory). When the verdict is `needs_breakdown`, either split the task into smaller TASK files until each one passes, or record a one-line dismissal in that task's `## Discussion` thread explaining why the violation is acceptable. A `ready` task whose validator verdict is `needs_breakdown` is not actually ready — Phase 3 (state file write) must follow a `VERDICT: ok` (or a documented dismissal).
128
+
129
+ Numeric thresholds and the minute table live in `src/core/taskBudgetValidator.js` and the shipped CLI twin. Do not restate them here — `tests/consistency/configDocsSync.test.js` greps this file for stale numbers and will fail if a threshold is hard-coded. Reference the validator / PLAN §3 instead.
130
+
119
131
  ### Maximize wave width — dependencies are expensive
120
132
 
121
133
  Wave width is the single biggest lever on how long a cycle takes: a wave of 6 finishes in
@@ -262,7 +262,10 @@ TDD — mandatory:
262
262
  cd .worktrees/task-xxx && <each verification command>
263
263
  Paste full output.
264
264
 
265
- DO NOT run: git add, git commit, git push — leave files as-is in worktree.
265
+ Milestone commits INSIDE your own worktree (e.g. `handoff/task-xxx`) are REQUIRED by the
266
+ milestone protocol — see `## Milestone landing (mandatory)` in the feature-implementer
267
+ contract. Never commit outside your worktree, never push. Use `-m "milestone: <name>"`.
268
+ Copy-back diffs the working tree against base, so milestone commits do not affect it.
266
269
 
267
270
  Executor Report (append to task file — do NOT touch INDEX.md):
268
271
  ## Executor Report
@@ -0,0 +1,221 @@
1
+ #!/bin/bash
2
+ # task-watchdog.sh — wall-clock watchdog for handoff tasks.
3
+ #
4
+ # Why this exists:
5
+ # The Quality Gate lets handoff runs go on for a while, but a wedged task
6
+ # that never reports a blocker still burns the rest of the pipeline. This
7
+ # hook measures wall-clock per in_progress task against approved budgets
8
+ # (S 8/15, M 15/30, L 25/45 minutes by default) and either:
9
+ # - soft overrun: emits a VISIBLE "checkpoint milestone now + split remainder"
10
+ # advisory on Stop (systemMessage), never blocks.
11
+ # - hard overrun (split policy, default): emits Stop decision=block with a
12
+ # visible split instruction naming the follow-up
13
+ # TASK-<id>-b; keeps the cycle running instead of letting
14
+ # the agent stop silently mid-budget.
15
+ # - hard overrun (pause policy, selectable in config): emits a pause
16
+ # advisory on Stop, never blocks.
17
+ #
18
+ # PostToolUse NEVER emits decision — that would block the in-flight Edit.
19
+ # Stop is the only path that can hard-trip; PostToolUse stays advisory only.
20
+ #
21
+ # Everything fails open and stays inside the 4s hook-chain budget:
22
+ # - HOOK_DEADLINE_MS=3000 self-kill via setTimeout(...).unref()
23
+ # - all I/O is async (sync reads on a stalled mount would block the timer)
24
+ # - process.exit(0) on every path; never throws.
25
+ #
26
+ # Dispatch on hook_event_name:
27
+ # Stop → evaluate and may emit decision=block; never emits decision
28
+ # unless the trip crosses hardMin AND hardPolicy='split'.
29
+ # PostToolUse → advisory only (systemMessage); NEVER emits decision.
30
+ # anything else → exit 0 silently.
31
+
32
+ INPUT="$(cat)"
33
+ # Cap the payload before it reaches node's env: oversized stdin would exceed the
34
+ # exec environment limit and node would never start. A truncated payload simply
35
+ # fails JSON.parse inside node → {} → silent degrade exit 0 (documented posture).
36
+ INPUT="${INPUT:0:65536}"
37
+ PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
38
+
39
+ INPUT="$INPUT" PROJECT_ROOT="$PROJECT_ROOT" node <<'NODE' || true
40
+ 'use strict';
41
+
42
+ const fsp = require('fs/promises');
43
+ const path = require('path');
44
+ const { pathToFileURL } = require('url');
45
+
46
+ // Wall-clock watchdog. Anything that stalls here must end in clean exit 0 —
47
+ // a stuck hook is indistinguishable from a wedged pipeline to the orchestrator.
48
+ // Every read below is async on purpose: a sync read on a stalled mount would
49
+ // block the loop and the timer below would never fire.
50
+ const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || '', 10) || 3000;
51
+ setTimeout(() => process.exit(0), HOOK_DEADLINE_MS).unref();
52
+
53
+ (async () => {
54
+ const payload = (() => {
55
+ try {
56
+ const parsed = JSON.parse(process.env.INPUT || '');
57
+ return parsed && typeof parsed === 'object' ? parsed : {};
58
+ } catch {
59
+ return {};
60
+ }
61
+ })();
62
+
63
+ const hookEvent = typeof payload.hook_event_name === 'string' ? payload.hook_event_name : '';
64
+ const toolName = typeof payload.tool_name === 'string' ? payload.tool_name : '';
65
+ const projectRoot = process.env.PROJECT_ROOT || process.cwd();
66
+
67
+ // Silent degrade for non-relevant events — same posture as every other
68
+ // UKit advisory hook: only do work when the event type is ours.
69
+ if (hookEvent !== 'Stop' && hookEvent !== 'PostToolUse') {
70
+ process.exit(0);
71
+ }
72
+
73
+ const runtimePath = path.join(projectRoot, '.claude', 'ukit', 'runtime', 'task-watchdog.mjs');
74
+ // Async existence check — sync fs here would violate the all-async invariant
75
+ // (a stalled mount would block the loop and the 3s self-kill could not fire).
76
+ try {
77
+ await fsp.access(runtimePath);
78
+ } catch {
79
+ process.exit(0);
80
+ }
81
+
82
+ let runtime;
83
+ try {
84
+ runtime = await import(pathToFileURL(runtimePath).href);
85
+ } catch {
86
+ process.exit(0);
87
+ }
88
+
89
+ const configPath = path.join(projectRoot, '.ukit', 'storage', 'config.json');
90
+ const config = await runtime.loadConfig(configPath);
91
+
92
+ const stateDir = path.join(projectRoot, '.ukit', 'storage', 'cache', 'task-watchdog');
93
+ const statePath = path.join(stateDir, 'state.json');
94
+ const state = await runtime.readState(statePath);
95
+
96
+ let tasks = [];
97
+ try {
98
+ tasks = await runtime.listInProgressTasks(projectRoot);
99
+ } catch {
100
+ process.exit(0);
101
+ }
102
+
103
+ // PostToolUse: advisory only. NEVER emit decision — that would block the
104
+ // in-flight Edit and stall the run. We still re-evaluate so the runtime
105
+ // state stays warm and the next Stop sees an up-to-date view.
106
+ if (hookEvent === 'PostToolUse') {
107
+ const now = Date.now();
108
+ const evals = runtime.evaluateBudgets({ tasks, state, now, config });
109
+ const advisories = evals
110
+ .filter((r) => r.phase !== 'ok')
111
+ .map((r) => {
112
+ const task = tasks.find((t) => t.id === r.id);
113
+ if (!task) return null;
114
+ if (r.phase === 'hard') {
115
+ const blocks = Number(state.hardBlocks[r.id] || 0);
116
+ if (blocks >= (runtime.HARD_BLOCK_CAP || 2)) {
117
+ return runtime.describeDegraded({ id: r.id, hardBlocks: blocks });
118
+ }
119
+ return runtime.describeHardAdvisory(r, r.id);
120
+ }
121
+ return runtime.describeSoft(r, r.id);
122
+ })
123
+ .filter(Boolean);
124
+
125
+ if (advisories.length > 0) {
126
+ const message = advisories.join('\n');
127
+ try {
128
+ process.stdout.write(`${JSON.stringify({ systemMessage: message })}\n`);
129
+ } catch {}
130
+ }
131
+ process.exit(0);
132
+ }
133
+
134
+ // Stop: full evaluation, may emit decision=block under split policy.
135
+ const now = Date.now();
136
+ for (const task of tasks) {
137
+ try {
138
+ await runtime.ensureFirstSeen(state, task.id, now);
139
+ } catch {}
140
+ }
141
+
142
+ const evals = runtime.evaluateBudgets({ tasks, state, now, config });
143
+
144
+ // First save firstSeen updates so the next Stop sees the same anchored clock.
145
+ await runtime.writeState(statePath, state);
146
+
147
+ const hardResults = evals.filter((r) => r.phase === 'hard');
148
+ const softResults = evals.filter((r) => r.phase === 'soft');
149
+
150
+ if (hardResults.length === 0) {
151
+ // No hard trips. Emit a soft advisory if anything is in the soft zone,
152
+ // then exit 0 cleanly. We deliberately do not emit decision here — the
153
+ // completion gate owns Stop's continuation logic and we never want to
154
+ // double-block for the same reason.
155
+ if (softResults.length > 0) {
156
+ const advisory = softResults
157
+ .map((r) => runtime.describeSoft(r, r.id))
158
+ .join('\n');
159
+ try {
160
+ process.stdout.write(`${JSON.stringify({ systemMessage: advisory })}\n`);
161
+ } catch {}
162
+ }
163
+ process.exit(0);
164
+ }
165
+
166
+ // At least one hard-trip. Apply policy.
167
+ const hardPolicy = String(config.taskBudgets?.hardPolicy || 'split');
168
+ const firstHard = hardResults[0];
169
+ const blocks = Number(state.hardBlocks[firstHard.id] || 0);
170
+
171
+ if (hardPolicy !== 'split') {
172
+ // pause (or anything non-split) → advisory only, never block.
173
+ const advisory = hardResults
174
+ .map((r) => runtime.describePause({ id: r.id, result: r }))
175
+ .join('\n');
176
+ try {
177
+ process.stdout.write(`${JSON.stringify({ systemMessage: advisory })}\n`);
178
+ } catch {}
179
+ process.exit(0);
180
+ }
181
+
182
+ // split policy + hard-trip — but the per-task block cap has already been
183
+ // hit. Degrade to advisory so the orchestrator can hand back to the user.
184
+ if (blocks >= (runtime.HARD_BLOCK_CAP || 2)) {
185
+ await runtime.bumpHardBlocks(state, firstHard.id);
186
+ await runtime.writeState(statePath, state);
187
+ const advisory = runtime.describeDegraded({ id: firstHard.id, hardBlocks: blocks + 1 });
188
+ try {
189
+ process.stdout.write(`${JSON.stringify({ systemMessage: advisory })}\n`);
190
+ } catch {}
191
+ process.exit(0);
192
+ }
193
+
194
+ // Hard trip + split policy + under the cap → emit Stop decision=block
195
+ // with the visible split instruction. This is the "auto-split and continue"
196
+ // path: blocking the stop IS the continue mechanism — the executor / orchestrator
197
+ // performs the actual TASK-<id>-b cut on the next turn following the reason.
198
+ const newBlocks = await runtime.bumpHardBlocks(state, firstHard.id);
199
+ await runtime.writeState(statePath, state);
200
+
201
+ const task = tasks.find((t) => t.id === firstHard.id) || { id: firstHard.id };
202
+ const lastGreen = runtime.pickLastGreen(task);
203
+ const reason = runtime.describeSplitReason({
204
+ id: firstHard.id,
205
+ result: firstHard,
206
+ hardBlocks: newBlocks,
207
+ lastGreen,
208
+ });
209
+ try {
210
+ process.stdout.write(`${JSON.stringify({ decision: 'block', reason })}\n`);
211
+ } catch {}
212
+ process.exit(0);
213
+ })().catch(() => {
214
+ // Fail-open: never let an exception kill the hook.
215
+ try {
216
+ process.exit(0);
217
+ } catch {}
218
+ });
219
+ NODE
220
+
221
+ exit 0
@@ -151,6 +151,11 @@
151
151
  "type": "command",
152
152
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/record-execution.sh\"",
153
153
  "timeout": 4
154
+ },
155
+ {
156
+ "type": "command",
157
+ "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/task-watchdog.sh\"",
158
+ "timeout": 4
154
159
  }
155
160
  ]
156
161
  },
@@ -203,6 +208,11 @@
203
208
  "type": "command",
204
209
  "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/completion-gate.sh\"",
205
210
  "timeout": 4
211
+ },
212
+ {
213
+ "type": "command",
214
+ "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/task-watchdog.sh\"",
215
+ "timeout": 4
206
216
  }
207
217
  ]
208
218
  }