@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.
- package/CHANGELOG.md +122 -19
- package/manifests/platform.full.yaml +26 -1
- package/package.json +1 -1
- package/src/cli/commands/doctor.js +49 -2
- package/src/cli/commands/install.js +5 -0
- package/src/core/applyPlan.js +1 -1
- package/src/core/diffPlan.js +80 -0
- package/src/core/gatewayProbe.js +441 -0
- package/src/core/gatewayResilienceEnv.js +292 -0
- package/src/core/runInstallPipeline.js +11 -0
- package/src/core/taskBudgetValidator.js +241 -0
- package/src/core/taskProgressGuard.js +157 -0
- package/src/manifest/validateManifest.js +1 -1
- package/templates/.claude/agents/feature-implementer.md +30 -0
- package/templates/.claude/agents/handoff-planner.md +12 -0
- package/templates/.claude/commands/ukit/handoff-fullstack.md +4 -1
- package/templates/.claude/hooks/task-watchdog.sh +221 -0
- package/templates/.claude/settings.json +10 -0
- package/templates/.claude/ukit/index/task-budget-validator.mjs +236 -0
- package/templates/.claude/ukit/runtime/task-watchdog.mjs +299 -0
- package/templates/.gitignore +5 -4
- package/templates/.omp/agents/feature-implementer.md +30 -0
- package/templates/.omp/agents/handoff-planner.md +12 -0
- package/templates/.omp/hooks/pre/ukit-bridge.js +2 -1
- package/templates/docs/AI_HANDOFF/tasks/_TEMPLATE.md +17 -0
- package/templates/ukit/storage/config.json +8 -1
|
@@ -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
|
-
|
|
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
|
}
|