@ngockhoale/ukit 3.4.12 → 3.4.14
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 +64 -0
- package/manifests/documentation.yaml +10 -0
- package/manifests/platform.full.yaml +13 -0
- package/package.json +1 -1
- package/src/index/routeCatalog.js +18 -0
- package/src/index/taskRouting.js +82 -1
- package/template_project/.claude/commands/ukit/handoff-create.md +131 -12
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +37 -2
- package/template_project/.claude/hooks/handoff-model-guard.sh +272 -7
- package/template_project/.claude/hooks/handoff-resume.sh +47 -0
- package/template_project/.claude/hooks/skill-router.sh +90 -0
- package/template_project/.claude/skills/advisor-plan/REFERENCE.md +87 -0
- package/template_project/.claude/skills/advisor-plan/SKILL.md +94 -0
- package/template_project/.claude/ukit/index/route-catalog.mjs +18 -0
- package/template_project/.claude/ukit/index/route-task.mjs +83 -1
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +293 -0
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +33 -70
- package/template_project/docs/AI_HANDOFF/RULES.md +23 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: advisor-plan
|
|
3
|
+
description: Use when the user hands over an advisor opinion, reference material, roadmap or external plan document to turn into a plan, or asks to replan from advisor feedback (tư vấn / tài liệu tham khảo / lập kế hoạch theo tài liệu advisor / lên lại kế hoạch), or asks you to plan any task on request (lập kế hoạch cho bất kỳ task nào), not only advisor documents, especially inside /ukit:handoff-create or the create phase of /ukit:handoff-fullstack. Planning only - never implements. Do not use for concrete bug fixes or code review.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Advisor Plan
|
|
7
|
+
|
|
8
|
+
## Purpose
|
|
9
|
+
|
|
10
|
+
Convert a large advisor / reference / replan document into three artifacts, and nothing else: a requirement **LEDGER** (every row dispositioned), a bounded task set (at most 10 active tasks), and a **QUEUE** of deliberate-future work. Templates live in [REFERENCE.md](./REFERENCE.md).
|
|
11
|
+
|
|
12
|
+
This skill is **planning-only**. A create session that loads it edits docs and tests only.
|
|
13
|
+
|
|
14
|
+
## Binding rule — create sessions edit docs and tests only
|
|
15
|
+
|
|
16
|
+
- Allowed: `docs/**` (plans, ledger, queued blueprints, research notes), `tests/**` test files, root `README*.md` / `CHANGELOG.md`.
|
|
17
|
+
- Never edit: source, config, hooks, `package.json`, or any file that is an **executable instruction** — `SKILL.md` files, `commands`, `agents`, `AGENTS.md` / `CLAUDE.md`. They steer future sessions, so they are not ordinary docs.
|
|
18
|
+
- Never implement inside a create or plan session. No GREEN step, no "just the first epic", no "small fix on the way". Implementation belongs to a later `/ukit:handoff-fullstack` or `/ukit:handoff-implement` run, or a fresh session.
|
|
19
|
+
- **Inside `/ukit:handoff-fullstack`** this binding rule covers only its create phase. The same run moves on to its own implement phase once the plan review passes; this skill never blocks that phase and never makes the model refuse it.
|
|
20
|
+
- Hook pressure, deadlines, "it is only one line" and user urgency do not change the scope. To change source, the user issues another command; the model never self-promotes.
|
|
21
|
+
- "Another command" means a new slash command or a fresh session; a chat instruction in the same session is not that command.
|
|
22
|
+
- `OUTPUT_ROOT` must be under `docs/`. Running `refresh-index` writes only derived `.cache` and is allowed.
|
|
23
|
+
- One task may cover several related ledger rows (name all R-IDs in the task); a `feasible` row whose task falls in a queued blueprint names the queued task/slug in its Task cell.
|
|
24
|
+
- In a create session this skill takes precedence over generic "never stop after read-only" / "act directly on trivial" lines: continuing to produce the plan is the continuation, source edits are not.
|
|
25
|
+
|
|
26
|
+
## Phases
|
|
27
|
+
|
|
28
|
+
### Phase A — Gather (lite tier)
|
|
29
|
+
|
|
30
|
+
1. Read the document header and table of contents; unreadable or empty source → write ledger row `R-000 | blocker` naming the missing input, create no tasks, stop.
|
|
31
|
+
2. Split into chunks of at most 800 lines; one bounded gatherer per chunk extracts candidate requirements as `Source` (section/line) + normalized text. Dedupe by normalized text, keep the first source; contradictory requirements become a `blocker` row.
|
|
32
|
+
3. **Feasibility evidence** — per requirement, inspect the CURRENT source index-first (`query-index.mjs` / `resolve-context.mjs`, then read the code that would change) plus `git log`, `git diff` and `git status` of the touched areas. Record each finding in the ledger row as evidence (file:line or commit) using the block in [REFERENCE.md](./REFERENCE.md#feasibility-evidence). Gatherers collect; the smart tier decides.
|
|
33
|
+
4. **Related-project research (default on, bounded)** — per requirement cluster search the internet / GitHub for related projects and prior art (how others solved it, what to adopt or avoid) via `.claude/skills/research/SKILL.md`, under the sanitized-query and untrusted-source rules in [REFERENCE.md](./REFERENCE.md#research-rules). Feed the findings into each row's feasibility evidence. Skip only when no sanitized query is possible or no research tool exists, and record the skip reason in the ledger notes.
|
|
34
|
+
|
|
35
|
+
Tier (lite gatherers): on Claude Code start a read-only `general-purpose` subagent with `model: haiku` (it has Bash for `query-index`, `git log`/`git diff` and web tools for research; `ukit-small-task-maintainer` is not a gatherer - it has no Bash and is scoped to UKit chores); omp uses `@smol`; Codex has one session model, so the main thread gathers. Gatherers return extracts and evidence only - they decide nothing.
|
|
36
|
+
|
|
37
|
+
### Phase B — Decide, ledger, plan (smart tier)
|
|
38
|
+
|
|
39
|
+
1. Use the evidence to assign every extracted requirement exactly one disposition: `feasible`, `adapted`, `deferred`, or `blocker`. `adapted` lists 2-3 alternatives with the chosen one marked; `deferred` names its queue entry; `blocker` names the missing precondition.
|
|
40
|
+
2. Every `feasible`/`adapted` row names a task and a test or acceptance check.
|
|
41
|
+
3. Write `${OUTPUT_ROOT}LEDGER.md` with the footer `Coverage: N/N rows dispositioned` and the sentence "disposition coverage is not implementation coverage". Never claim "100% implemented".
|
|
42
|
+
4. **Candidate plans** — draft 2-3 candidate whole-plans (different decomposition, risk, ordering), score them on the rubric in [REFERENCE.md](./REFERENCE.md#candidate-plan-rubric), pick the best, and record the choice plus rejected alternatives in `PLAN.md`.
|
|
43
|
+
5. Write SPEC, PLAN and the `TASK-*.md` files through the existing `/ukit:handoff-create` planner. Every task carries detailed TDD test cases that PROVE it works (happy, edge and error cases with the expected result), never just "tests pass".
|
|
44
|
+
6. Queue rule: at most 10 tasks in total (short plan): all active for the next implement cycle; more than 10: the first 10 by dependency order are active and the rest become queued blueprints under `docs/AI_HANDOFF/queued/<slug>/` with the queue header from [REFERENCE.md](./REFERENCE.md#queue-header), drained gradually by later cycles.
|
|
45
|
+
7. Replan: append ledger deltas and supersede with `-R<n>` rules; never overwrite a task that carries an executor report.
|
|
46
|
+
|
|
47
|
+
Tier (smart decide): Claude Code uses the `opus`/`unic-smart` agent (`handoff-planner`); omp uses `@slow` (or `@default` when no slow role is configured).
|
|
48
|
+
|
|
49
|
+
Tier (code general work): the `sonnet`/`unic-code` tier (`@default` on omp) does the routine docs work of the create session - formatting task files from the decided plan, filling templates, drafting test-case tables - under the smart tier's decisions. It never edits source.
|
|
50
|
+
|
|
51
|
+
### Phase C — Review (independent smart tier)
|
|
52
|
+
|
|
53
|
+
Run the existing independent plan review (`code-reviewer` with `REVIEW_TARGET_TYPE=plan`, fresh context, separate from the planner). Findings go back to Phase B. The ledger is not final until the review passes; a row without a disposition means the skill must not finish.
|
|
54
|
+
|
|
55
|
+
### Phase D — Draft / implement (code tier, separate phase only)
|
|
56
|
+
|
|
57
|
+
Drafting code or running tasks is **never** done in the create or plan session. Intended code changes are written into the tasks (target files, interfaces, TDD test cases); applying them runs later under `/ukit:handoff-fullstack` or `/ukit:handoff-implement` with the code tier (`sonnet`/`unic-code` on Claude Code, `@default` on omp). The create session ends with tasks in `status=ready` and a report.
|
|
58
|
+
|
|
59
|
+
## Per-host honesty
|
|
60
|
+
|
|
61
|
+
| Host | Model split | Scope enforcement |
|
|
62
|
+
|------|-------------|-------------------|
|
|
63
|
+
| Claude Code | haiku / sonnet / opus agents chosen by each agent's `model:` field | enforced by the PreToolUse chain (`handoff-model-guard.sh`) for direct tools and conservative shell patterns |
|
|
64
|
+
| omp | `@smol` gather, `@default` code, `@slow` plan, via `modelRoles` | enforced through the bridge for mapped tools; unmapped shell paths are best effort |
|
|
65
|
+
| Codex | no per-phase model selection is available; one session model does every phase | Codex has no pre-tool hooks, so this is instruction-mediated only - advisory, not enforced |
|
|
66
|
+
|
|
67
|
+
**Recognition**: this skill is activated by the router's deterministic VN/EN advisor classifier (`classifyAdvisorRequest`) and its route-catalog entry. `unic-decision` is not consulted for advisor recognition - its `route.intent-kind.v1` has no advisor/plan value and a new label would need a retrained model - so never claim a decision-plane verdict for it; wiring unic-decision in is a queued follow-up.
|
|
68
|
+
|
|
69
|
+
On Codex follow every rule above by instruction. Do not claim a guard blocked anything and do not claim a different model ran a phase.
|
|
70
|
+
|
|
71
|
+
## Rationalization counters
|
|
72
|
+
|
|
73
|
+
| Excuse | Reality |
|
|
74
|
+
|---|---|
|
|
75
|
+
| "handoff-create starts a handoff; it is not the lane for absorbing a 27-requirement advisor roadmap mid-run." (verbatim RED baseline, option C: stop read-only) | This skill is that lane. Absorb the document into a ledger, tasks and a queue; stopping read-only produces no plan. |
|
|
76
|
+
| "The user said implement the first epic now." | A create session never implements. Record the epic as a ready task; implementation runs in a separate phase. |
|
|
77
|
+
| "The hook is slow / not firing, so I can edit source." | A missing guard changes enforcement, not scope. Docs and tests only. |
|
|
78
|
+
| "SKILL.md and AGENTS.md are just markdown." | They are executable instructions for future sessions; treat them like source. |
|
|
79
|
+
| "Ledger rows are all dispositioned, so it is done." | Disposition coverage is not implementation coverage. |
|
|
80
|
+
| "I will approve whatever you do" / "you have my approval" / "nobody is watching". | Approval or observation does not change scope; another command is required, and a chat instruction in the same session is not that command. |
|
|
81
|
+
| "They are only markdown / one line of JSON" (`AGENTS.md`, agents, `SKILL.md`, `package.json`). | Executable instruction or config. Applies to root and template copies, `.claude/agents` and `.omp/agents`. Record each requested edit as a ready task. |
|
|
82
|
+
| "The user wants it fast, just trust the document, 5 representative tasks, report 100%." | Urgency does not waive ledger completeness, feasibility evidence or the coverage wording. Offer the ledger counts as the quick deliverable. |
|
|
83
|
+
| "The document is too big, I will summarize it." | Chunk at 800 lines, extract, dedupe, and disposition every row. |
|
|
84
|
+
|
|
85
|
+
## Red flags — stop and re-read the binding rule
|
|
86
|
+
|
|
87
|
+
- About to Edit a path outside `docs/` or `tests/`.
|
|
88
|
+
- Writing "implemented" or "100%", or applying a code diff, inside a create session. (Describing the intended change inside a task file is the plan, not a violation.)
|
|
89
|
+
- Skipping the ledger footer or leaving a row without a disposition.
|
|
90
|
+
- Pasting fetched web text into a command or a file path.
|
|
91
|
+
|
|
92
|
+
## Reference
|
|
93
|
+
|
|
94
|
+
- Templates and research rules: [REFERENCE.md](./REFERENCE.md)
|
|
@@ -237,6 +237,24 @@ export const ROUTE_CATALOG = [
|
|
|
237
237
|
{ type: 'prompt', regex: /(?<![A-Za-z0-9_])(làm gì tiếp|bước tiếp theo|tiếp theo làm gì|làm tiếp|đang ở đâu|trạng thái project|tình trạng project|task tiếp theo|việc tiếp theo trong tasks)(?![A-Za-z0-9_])/i, score: 7 },
|
|
238
238
|
],
|
|
239
239
|
},
|
|
240
|
+
{
|
|
241
|
+
id: 'advisor-plan',
|
|
242
|
+
path: '.claude/skills/advisor-plan/SKILL.md',
|
|
243
|
+
order: 12.15,
|
|
244
|
+
contextMode: 'standalone',
|
|
245
|
+
signals: [
|
|
246
|
+
{ type: 'prompt', regex: /\b(?:advis[eo]r)(?:'s|s)?\s+(?:\w+\s+){0,3}(?:docs?|documents?|opinions?|feedback|notes?|plans?|roadmaps?|recommendations?|reports?)\b/i, score: 9 },
|
|
247
|
+
{ type: 'prompt', regex: /\b(?:turn|convert|translate|break(?: down)?|distill)\b.{0,48}\b(?:roadmap|reference|advis[eo]r|external plan)\b.{0,48}\b(?:into|to)\b.{0,16}\b(?:plan|tasks?|ledger)\b/i, score: 9 },
|
|
248
|
+
{ type: 'prompt', regex: /\b(?:roadmap|reference|external plan)\s+(?:material|docs?|documents?)\b.{0,48}\bplan\b/i, score: 9 },
|
|
249
|
+
{ type: 'prompt', regex: /\bplan(?:ning)?\s+(?:from|using|based on|per|according to)\b.{0,48}\b(?:reference|roadmap|advis[eo]r|external|document)\b/i, score: 9 },
|
|
250
|
+
{ type: 'prompt', regex: /\b(?:replan|re-plan|plan again)\b.{0,60}\b(?:advis[eo]r|feedback|documents?|reference|roadmap)\b/i, score: 7 },
|
|
251
|
+
{ type: 'prompt', regex: /\b(?:planning[- ]only|plan[- ]only|plan (?:any|this|the|a) (?:task|feature|work).{0,48}(?:do not|don't|without|never) implement\w*)\b/i, score: 7 },
|
|
252
|
+
{ type: 'prompt', regex: /\bplan\s+(?:this|these|the following)\s+(?:task|tasks|work|request|feature)\b/i, score: 7 },
|
|
253
|
+
{ type: 'prompt', regex: /(?<![A-Za-z0-9_])(?:lập kế hoạch|lap ke hoach|lên kế hoạch|len ke hoach)\s+cho\s+(?:task|việc|viec|tính năng|tinh nang|yêu cầu|yeu cau|feature)(?![A-Za-z0-9_])/i, score: 7 },
|
|
254
|
+
{ type: 'prompt', regex: /(?<![A-Za-z0-9_])(?:lập kế hoạch|lap ke hoach|lên lại kế hoạch|len lai ke hoach|lên kế hoạch lại|len ke hoach lai).{0,60}(?:tài liệu|tai lieu|tư vấn|tu van|advisor|tham khảo|tham khao|roadmap)/i, score: 9 },
|
|
255
|
+
{ type: 'prompt', regex: /(?<![A-Za-z0-9_])(?:tài liệu tư vấn|tai lieu tu van|tư vấn của advisor|tu van cua advisor|lên lại kế hoạch|len lai ke hoach|lập kế hoạch cho bất kỳ task nào|lap ke hoach cho bat ky task nao)/i, score: 7 },
|
|
256
|
+
],
|
|
257
|
+
},
|
|
240
258
|
{
|
|
241
259
|
id: 'update-status',
|
|
242
260
|
path: '.claude/skills/update-status/SKILL.md',
|
|
@@ -2754,9 +2754,29 @@ async function selectActiveSkills({ rootDir, promptText, commandText, targetFile
|
|
|
2754
2754
|
.filter((entry) => entry.score > 0)
|
|
2755
2755
|
.filter((entry) => shouldKeepRouteEntryForIntent(entry, intentMode))
|
|
2756
2756
|
.sort((a, b) => b.score - a.score || a.order - b.order);
|
|
2757
|
+
// FR-004: a classified advisor/reference/replan hand-off always loads
|
|
2758
|
+
// advisor-plan, even when no catalog signal fired. It takes the FIRST of the
|
|
2759
|
+
// MAX_ACTIVE_ROUTE_SKILLS slots; the best-scoring other route fills the second.
|
|
2760
|
+
const advisorKind = classifyAdvisorRequest({ promptText, commandText });
|
|
2761
|
+
const advisorCatalogEntry = advisorKind
|
|
2762
|
+
? ROUTE_CATALOG.find((entry) => entry.id === 'advisor-plan')
|
|
2763
|
+
: null;
|
|
2764
|
+
// A catalog-only advisor-plan hit on an implementation order ("implement the
|
|
2765
|
+
// advisor plan in docs/plan.md") must not load a planning-only skill.
|
|
2766
|
+
const dropCatalogAdvisor = !advisorKind
|
|
2767
|
+
&& hasMutationOrder({ promptText, commandText })
|
|
2768
|
+
&& !/\b(?:planning[- ]only|plan[- ]only)\b|\b(?:turn|convert|translate|break(?: down)?|distill)\b.{0,48}\b(?:into|to)\b.{0,16}\b(?:plan|tasks?|ledger)\b/i.test(String(promptText ?? ''));
|
|
2769
|
+
const candidates = advisorCatalogEntry
|
|
2770
|
+
? [
|
|
2771
|
+
scoreSkillRouteEntry(advisorCatalogEntry, routeSignals),
|
|
2772
|
+
...scoredEntries.filter((entry) => entry.id !== 'advisor-plan'),
|
|
2773
|
+
]
|
|
2774
|
+
: dropCatalogAdvisor
|
|
2775
|
+
? scoredEntries.filter((entry) => entry.id !== 'advisor-plan')
|
|
2776
|
+
: scoredEntries;
|
|
2757
2777
|
const active = [];
|
|
2758
2778
|
|
|
2759
|
-
for (const entry of
|
|
2779
|
+
for (const entry of candidates) {
|
|
2760
2780
|
if (await pathExists(path.join(rootDir, entry.path))) {
|
|
2761
2781
|
active.push(entry);
|
|
2762
2782
|
if (active.length >= MAX_ACTIVE_ROUTE_SKILLS) {
|
|
@@ -3776,6 +3796,10 @@ function buildRouteSummary({
|
|
|
3776
3796
|
workflowPolicyName ? `workflow=${workflowPolicyName}` : null,
|
|
3777
3797
|
].filter(Boolean).join(' | ');
|
|
3778
3798
|
|
|
3799
|
+
const advisorIntent = classifyAdvisorRequest({
|
|
3800
|
+
promptText: routingContext.promptText,
|
|
3801
|
+
commandText: routingContext.commandText,
|
|
3802
|
+
});
|
|
3779
3803
|
return {
|
|
3780
3804
|
primaryCommands,
|
|
3781
3805
|
fallbackCommands,
|
|
@@ -3838,6 +3862,9 @@ function buildRouteSummary({
|
|
|
3838
3862
|
// only when subagentOrchestrator.roleAdvice.stage != 'off' (conditional
|
|
3839
3863
|
// spread keeps the stage-off route byte-identical).
|
|
3840
3864
|
...(delegationAdvice ? { delegationAdvice } : {}),
|
|
3865
|
+
// FR-004 additive field — present only when the prompt is an advisor/reference/replan
|
|
3866
|
+
// request, so every other route stays byte-identical.
|
|
3867
|
+
...(advisorIntent ? { advisorIntent } : {}),
|
|
3841
3868
|
line: line || 'task=unknown',
|
|
3842
3869
|
};
|
|
3843
3870
|
}
|
|
@@ -4102,6 +4129,60 @@ function hasAdvisoryOnlyAssertion({ promptText = '', commandText = '' } = {}) {
|
|
|
4102
4129
|
const mutationOrder = /(?<![A-Za-z0-9_])(?:implement|apply|update|modify|add|create|ship|deliver|fix|refactor|remove|delete|rename|change|write|build|make|install|run|deploy|execute|edit|sua|them|tao|xoa|doi|thay\s+the|cap\s+nhat|viet|chay|cai|chinh|trien\s+khai|cau\s+hinh)(?![A-Za-z0-9_])/.test(residue);
|
|
4103
4130
|
return !mutationOrder;
|
|
4104
4131
|
}
|
|
4132
|
+
// <advisor-intent:begin>
|
|
4133
|
+
// FR-004: an advisor/reference/replan request hands over a document or roadmap
|
|
4134
|
+
// and wants a plan back (the advisor-plan route). Deterministic, no I/O.
|
|
4135
|
+
// Every kind needs plan/document context, so "advisory lock", "hang-advisory",
|
|
4136
|
+
// a "legal advisor" label or "replan the SQL index" stay null. Three copies
|
|
4137
|
+
// (taskRouting.js, route-task.mjs, skill-router.sh) — keep them identical.
|
|
4138
|
+
export function classifyAdvisorRequest({ promptText = '', commandText = '' } = {}) {
|
|
4139
|
+
const folded = `${promptText ?? ''}\n${commandText ?? ''}`
|
|
4140
|
+
.toLowerCase()
|
|
4141
|
+
.normalize('NFD')
|
|
4142
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
4143
|
+
.replace(/\u0111/g, 'd')
|
|
4144
|
+
.trim();
|
|
4145
|
+
if (!folded) return null;
|
|
4146
|
+
// A prompt that opens with a bug-fix/edit verb is a code change, not a plan request.
|
|
4147
|
+
if (/^(?:please\s+|pls\s+)?(?:fix|debug|rename|remove|delete|refactor|edit|sua\s+loi|xoa|doi\s+ten)(?![a-z0-9_])/.test(folded)) return null;
|
|
4148
|
+
const text = folded.replace(/\b(?:legal|financial|tax|investment|academic|career|mortgage)\s+advisors?\b/g, ' ');
|
|
4149
|
+
const PLAN = '(?:plans?|planning|ke\\s+hoach|phuong\\s+an|lo\\s+trinh|roadmaps?|blueprints?|specs?)';
|
|
4150
|
+
const hasPlan = new RegExp(`\\b${PLAN}\\b`).test(text);
|
|
4151
|
+
const hasDoc = /\b(?:tai\s+lieu|documents?|docs?|feedback|gop\s+y)\b|\.md\b/.test(text);
|
|
4152
|
+
const replanToken = /\bre-?plan(?:ning|ned)?\b/;
|
|
4153
|
+
if ((replanToken.test(text)
|
|
4154
|
+
&& new RegExp(`\\b(?:advisors?|feedback|gop\\s+y|reference|handoff(?:-create)?|backlog|${PLAN})\\b`).test(text.replace(replanToken, ' ')))
|
|
4155
|
+
|| /\b(?:lap|len|vach|xay\s+dung)\s+lai\s+(?:ke\s+hoach|plan|phuong\s+an|lo\s+trinh|roadmap)\b/.test(text)
|
|
4156
|
+
|| /\b(?:revise|rework|redo|rewrite)\s+(?:(?:the|this|my|our|that)\s+)?(?:plan|roadmap)\b/.test(text)) {
|
|
4157
|
+
return 'replan';
|
|
4158
|
+
}
|
|
4159
|
+
// An explicit "plan this task" order stands on its own; the document kinds below
|
|
4160
|
+
// are gated so an implementation order that merely mentions a plan, spec, doc or
|
|
4161
|
+
// advisor ("implement the plan from the spec") stays null. Plan-making verbs
|
|
4162
|
+
// ("create a plan", "handoff-create") are not implementation orders.
|
|
4163
|
+
if (/\bplan\s+(?:this|these|the\s+following)\s+(?:task|tasks|work|request|feature)\b/.test(text)
|
|
4164
|
+
|| /\b(?:lap|len)\s+ke\s+hoach\s+cho\s+(?:(?:task|viec|yeu\s+cau)\s+nay|task|viec|tinh\s+nang|yeu\s+cau|feature)\b/.test(text)) {
|
|
4165
|
+
return 'advisor';
|
|
4166
|
+
}
|
|
4167
|
+
const orderText = text
|
|
4168
|
+
.replace(/\bhandoff-create\b/g, ' handoff ')
|
|
4169
|
+
.replace(/\b(?:create|build|make|write|draft|produce|generate|tao|viet|soan|lap)\s+(?:(?:a|an|the|me|my|our|one|new|detailed|full|giup|cho|toi|ra)\s+){0,3}(?:plans?|roadmaps?|blueprints?|specs?|ke\s+hoach|phuong\s+an|lo\s+trinh)\b/g, ' ');
|
|
4170
|
+
if (hasMutationOrder({ promptText: orderText })) return null;
|
|
4171
|
+
if ((/\badvisors?\b/.test(text) && (hasPlan || hasDoc || /\bhandoff-create\b/.test(text)))
|
|
4172
|
+
|| (/\b(?:tu|co)\s+van\b(?!\s+de\b)/.test(text) && (hasPlan || /\btai\s+lieu\b|\bhandoff-create\b/.test(text)))
|
|
4173
|
+
|| (/\bhandoff-create\b/.test(text) && (hasPlan || hasDoc))) {
|
|
4174
|
+
return 'advisor';
|
|
4175
|
+
}
|
|
4176
|
+
if (/\breference\s+(?:docs?|documents?|roadmaps?|plans?|specs?|material|brief)\b/.test(text)
|
|
4177
|
+
|| /\b(?:roadmap|plan|spec|document|doc)\s+(?:as|for)\s+(?:a\s+|the\s+)?reference\b/.test(text)
|
|
4178
|
+
|| (/\btai\s+lieu\s+tham\s+khao\b/.test(text) && hasPlan)
|
|
4179
|
+
|| /\b(?:plan|ke\s+hoach)\b[^.\n]{0,40}\b(?:from|based\s+on|according\s+to|theo|dua\s+(?:tren|vao))\s+(?:(?:this|the|a|nay)\s+)?(?:roadmap|spec|brief|document|doc|tai\s+lieu)\b/.test(text)) {
|
|
4180
|
+
return 'reference';
|
|
4181
|
+
}
|
|
4182
|
+
return null;
|
|
4183
|
+
}
|
|
4184
|
+
// <advisor-intent:end>
|
|
4185
|
+
|
|
4105
4186
|
// C85-019: mutation vocabulary ≠ mutation order. The same residue logic the
|
|
4106
4187
|
// advisory gate uses — quotes, negated clauses, conditionals (nếu/để/cho/if),
|
|
4107
4188
|
// reported demands (yêu cầu/ép/requires), necessity phrases (cần được/must be),
|
|
@@ -5495,6 +5576,7 @@ export function compactRouteSummary(routeSummary = null) {
|
|
|
5495
5576
|
// persisted route state keeps the redacted outcome. Conditional spread —
|
|
5496
5577
|
// stage-off output stays byte-identical (no key emitted when absent).
|
|
5497
5578
|
...(routeSummary.decisionPlane ? { decisionPlane: routeSummary.decisionPlane } : {}),
|
|
5579
|
+
...(routeSummary.advisorIntent ? { advisorIntent: routeSummary.advisorIntent } : {}),
|
|
5498
5580
|
// C89 TASK-005: the typed delegation advice survives compaction so
|
|
5499
5581
|
// persisted route state keeps the shadow verdict. Conditional spread —
|
|
5500
5582
|
// stage-off output stays byte-identical (no key emitted when absent).
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// handoff-intent.mjs — durable intent separation for the handoff pipeline.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists: a single session can carry more than one handoff command over
|
|
4
|
+
// its life. The Stop gate (stop-coordinator.mjs) and the SessionStart resume hook
|
|
5
|
+
// (handoff-resume.sh) must react to the session's CURRENT intent, not to any
|
|
6
|
+
// historical one. A session that ran `/ukit:handoff-fullstack` earlier and is now
|
|
7
|
+
// planning with `/ukit:handoff-create` must NOT be bounced into implementing a
|
|
8
|
+
// stale RUN.md — while a session actively driving a fullstack run must still be
|
|
9
|
+
// held to completion. "Session ran fullstack once" is therefore not ownership;
|
|
10
|
+
// "the most recent handoff intent is fullstack" is.
|
|
11
|
+
//
|
|
12
|
+
// The intent is derived from STRUCTURED transcript evidence only — real user-turn
|
|
13
|
+
// slash-command tags (Claude Code), native command-expansion headings (omp, Codex),
|
|
14
|
+
// Skill tool calls, and SessionStart / host-injected hook context. Raw text matching
|
|
15
|
+
// is deliberately NOT used: the marker strings also appear in tool output, bash
|
|
16
|
+
// commands and source files that any session may read (TASK-007 established this;
|
|
17
|
+
// this module keeps it). Each host is recognised by its OWN real envelope shape; an
|
|
18
|
+
// unknown format returns `null` rather than a guess, so fail-closed callers keep
|
|
19
|
+
// behaving fail-closed.
|
|
20
|
+
//
|
|
21
|
+
// Exports (independently testable — callers import the module directly):
|
|
22
|
+
// HANDOFF_FULLSTACK_TAG / HANDOFF_CREATE_TAG — the two user-turn command tags
|
|
23
|
+
// HANDOFF_RESUME_BANNER — the SessionStart resume marker
|
|
24
|
+
// classifyHandoffEntry(entry) — 'fullstack' | 'create' | null for one JSONL entry
|
|
25
|
+
// classifyHandoffLine(line) — same, from a raw JSONL line
|
|
26
|
+
// transcriptHandoffIntent(path, opts) — { intent, known } for a whole transcript;
|
|
27
|
+
// `intent` is the MOST RECENT qualifying intent, `known=false` when the scan
|
|
28
|
+
// could not complete (missing/unreadable path, budget overrun) — callers keep
|
|
29
|
+
// their fail-closed posture on `known=false`.
|
|
30
|
+
|
|
31
|
+
import fs from 'node:fs/promises';
|
|
32
|
+
|
|
33
|
+
export const HANDOFF_FULLSTACK_TAG = /<command-name>\/?(?:ukit:)?handoff-fullstack\b/;
|
|
34
|
+
export const HANDOFF_CREATE_TAG = /<command-name>\/?(?:ukit:)?handoff-create\b/;
|
|
35
|
+
export const HANDOFF_RESUME_BANNER = 'UKIT HANDOFF RESUME — an unfinished handoff-fullstack run';
|
|
36
|
+
|
|
37
|
+
// omp and Codex do NOT wrap a slash command in `<command-name>` tags the way
|
|
38
|
+
// Claude Code's user turn does. Their native evidence is the command *expansion*:
|
|
39
|
+
// omp replaces `/ukit:handoff-fullstack` with the command body, whose FIRST
|
|
40
|
+
// non-empty line is the H1 `# /ukit:handoff-fullstack — …`; Codex records the same
|
|
41
|
+
// expanded body as a `response_item`/`message` with `input_text` blocks. Matching
|
|
42
|
+
// 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/;
|
|
45
|
+
|
|
46
|
+
// The unique customType the omp bridge stamps on host-injected UKit hook context
|
|
47
|
+
// (`sendContext` → `hookContextMessage`). A resume banner only counts as intent
|
|
48
|
+
// when it arrives inside one of these — an `irc:incoming` / `advisor` message that
|
|
49
|
+
// happens to quote the banner is another agent's text, not this session's intent.
|
|
50
|
+
const UKIT_HOOK_CONTEXT_TYPE = 'ukit-hook-context';
|
|
51
|
+
|
|
52
|
+
// Block types that carry TOOL OUTPUT rather than the user's own words. A user
|
|
53
|
+
// turn that contains one of these is a tool_result round-trip (the harness wraps
|
|
54
|
+
// it in `role: user`), never a command the user typed — so it is never intent.
|
|
55
|
+
const TOOL_OUTPUT_BLOCK_TYPES = new Set(['tool_result', 'toolResult', 'tool-result']);
|
|
56
|
+
|
|
57
|
+
/** First non-empty line of a string, trimmed' or ''. */
|
|
58
|
+
function firstNonEmptyLine(text) {
|
|
59
|
+
for (const line of String(text).split('\n')) {
|
|
60
|
+
if (line.trim()) return line;
|
|
61
|
+
}
|
|
62
|
+
return '';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Does this text open with a native (omp/Codex) command-expansion heading?
|
|
67
|
+
* Only the FIRST non-empty line is inspected, so an H1 that appears later in the
|
|
68
|
+
* document body — e.g. a marketing heading inside the command's own markdown —
|
|
69
|
+
* is never treated as the session's intent.
|
|
70
|
+
* @returns {'create' | 'fullstack' | null}
|
|
71
|
+
*/
|
|
72
|
+
function nativeCommandIntent(text) {
|
|
73
|
+
const m = firstNonEmptyLine(text).match(NATIVE_COMMAND_HEADING);
|
|
74
|
+
if (!m) return null;
|
|
75
|
+
return m[1] === 'handoff-create' ? 'create' : 'fullstack';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Bounds for the tail scan. Recency is what matters, so the reader walks the
|
|
79
|
+
// NEWEST lines first and stops at the first qualifying intent. A transcript
|
|
80
|
+
// larger than `maxBytes` is scanned from its tail only: a recent intent is still
|
|
81
|
+
// found, but "no intent in the window" is reported as `known=false` (fail-closed),
|
|
82
|
+
// since the session's single command tag may predate the window. A deadline
|
|
83
|
+
// overrun also returns `known=false` rather than a guess.
|
|
84
|
+
export const INTENT_SCAN_BUDGET_MS = 1200;
|
|
85
|
+
export const INTENT_SCAN_MAX_BYTES = 32 * 1024 * 1024;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Split a user turn's content into (a) the user's own words, (b) the individual
|
|
89
|
+
* text blocks a native command expansion may arrive in, and (c) whether any block
|
|
90
|
+
* is TOOL OUTPUT. A `tool_result` round-trip is the harness wrapping tool output in
|
|
91
|
+
* a `role: user` turn — it is never something the user typed, so it never carries
|
|
92
|
+
* intent (TASK-007). Both Claude Code (`text`) and Codex (`input_text`) text blocks
|
|
93
|
+
* are user words.
|
|
94
|
+
*/
|
|
95
|
+
function splitUserContent(content) {
|
|
96
|
+
if (typeof content === 'string') return { text: content, blocks: content ? [content] : [], hasToolOutput: false };
|
|
97
|
+
if (!Array.isArray(content)) return { text: '', blocks: [], hasToolOutput: false };
|
|
98
|
+
let text = '';
|
|
99
|
+
let hasToolOutput = false;
|
|
100
|
+
const blocks = [];
|
|
101
|
+
for (const b of content) {
|
|
102
|
+
if (!b || typeof b !== 'object') continue;
|
|
103
|
+
if (TOOL_OUTPUT_BLOCK_TYPES.has(b.type)) { hasToolOutput = true; continue; }
|
|
104
|
+
if ((b.type === 'text' || b.type === 'input_text') && typeof b.text === 'string') {
|
|
105
|
+
text += (text ? '\n' : '') + b.text;
|
|
106
|
+
blocks.push(b.text);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { text, blocks, hasToolOutput };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 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.
|
|
117
|
+
* @returns {'fullstack' | 'create' | null}
|
|
118
|
+
*/
|
|
119
|
+
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.
|
|
127
|
+
for (const block of blocks) {
|
|
128
|
+
const intent = nativeCommandIntent(block);
|
|
129
|
+
if (intent) return intent;
|
|
130
|
+
}
|
|
131
|
+
if (hasToolOutput) return null;
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Classify assistant tool-call blocks. Handles both Claude Code (`tool_use`, name
|
|
137
|
+
* `Skill`, input.skill) and omp (`toolCall`, name/arguments.skill).
|
|
138
|
+
*/
|
|
139
|
+
function classifyAssistantBlocks(blocks) {
|
|
140
|
+
if (!Array.isArray(blocks)) return null;
|
|
141
|
+
for (const b of blocks) {
|
|
142
|
+
if (!b || typeof b !== 'object') continue;
|
|
143
|
+
if (b.type === 'tool_use' && b.name === 'Skill') {
|
|
144
|
+
const skill = String(b.input?.skill ?? '');
|
|
145
|
+
if (/^(?:ukit:)?handoff-create$/.test(skill)) return 'create';
|
|
146
|
+
if (/^(?:ukit:)?handoff-fullstack$/.test(skill)) return 'fullstack';
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (b.type === 'toolCall') {
|
|
150
|
+
const name = String(b.name ?? '');
|
|
151
|
+
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';
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Classify one transcript entry as a handoff intent.
|
|
161
|
+
*
|
|
162
|
+
* Recognises the REAL shapes of the three supported hosts, and refuses to guess on
|
|
163
|
+
* any other envelope:
|
|
164
|
+
* - Claude Code: `{ type:'user', message:{ role:'user', content } }`,
|
|
165
|
+
* `{ type:'assistant', message:{ content:[tool_use…] } }`,
|
|
166
|
+
* `{ type:'attachment', attachment:{ hookEvent:'SessionStart', … } }`
|
|
167
|
+
* - omp: `{ type:'message', message:{ role:'user'|'assistant', content:[…] } }`
|
|
168
|
+
* (content blocks `text` / `toolCall`), and host-injected hook
|
|
169
|
+
* context `{ type:'custom_message', customType:'ukit-hook-context', … }`
|
|
170
|
+
* - Codex: `{ type:'response_item', payload:{ type:'message', role:'user', content:[input_text…] } }`
|
|
171
|
+
*
|
|
172
|
+
* 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.
|
|
174
|
+
* @returns {'fullstack' | 'create' | null}
|
|
175
|
+
*/
|
|
176
|
+
export function classifyHandoffEntry(entry) {
|
|
177
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
178
|
+
if (entry.type === 'user' && entry.message && entry.message.role === 'user') {
|
|
179
|
+
return classifyUserTurn(entry.message.content);
|
|
180
|
+
}
|
|
181
|
+
if (entry.type === 'message' && entry.message && entry.message.role === 'user') {
|
|
182
|
+
return classifyUserTurn(entry.message.content);
|
|
183
|
+
}
|
|
184
|
+
if (entry.type === 'message' && entry.message && entry.message.role === 'assistant') {
|
|
185
|
+
return classifyAssistantBlocks(entry.message.content);
|
|
186
|
+
}
|
|
187
|
+
if (entry.type === 'response_item' && entry.payload) {
|
|
188
|
+
const payload = entry.payload;
|
|
189
|
+
if (payload.type === 'message' && payload.role === 'user') {
|
|
190
|
+
return classifyUserTurn(payload.content);
|
|
191
|
+
}
|
|
192
|
+
// Codex records tool calls as separate `function_call` items (string args), not
|
|
193
|
+
// a Skill tool call — they cannot carry a typed slash-command intent.
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
if (entry.type === 'assistant' && Array.isArray(entry.message?.content)) {
|
|
197
|
+
return classifyAssistantBlocks(entry.message.content);
|
|
198
|
+
}
|
|
199
|
+
if (entry.type === 'attachment' && entry.attachment && entry.attachment.hookEvent === 'SessionStart') {
|
|
200
|
+
const a = entry.attachment;
|
|
201
|
+
if ([a.content, a.stdout, a.text].some((v) => typeof v === 'string' && v.includes(HANDOFF_RESUME_BANNER))) {
|
|
202
|
+
return 'fullstack';
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// omp host-injected hook context (the bridge's `ukit-hook-context` customType).
|
|
206
|
+
// Gated on the customType so another agent's `irc:incoming` / an `advisor` message
|
|
207
|
+
// that merely quotes the banner cannot register as this session's intent.
|
|
208
|
+
if (entry.type === 'custom_message' && entry.customType === UKIT_HOOK_CONTEXT_TYPE) {
|
|
209
|
+
if (typeof entry.content === 'string' && entry.content.includes(HANDOFF_RESUME_BANNER)) {
|
|
210
|
+
return 'fullstack';
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Classify one raw JSONL transcript line. Returns null when the line is not
|
|
218
|
+
* parseable or carries no handoff intent.
|
|
219
|
+
*/
|
|
220
|
+
export function classifyHandoffLine(line) {
|
|
221
|
+
try {
|
|
222
|
+
return classifyHandoffEntry(JSON.parse(line));
|
|
223
|
+
} catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Read a session transcript and return its MOST RECENT handoff intent.
|
|
230
|
+
* @returns {Promise<{ intent: 'fullstack'|'create'|null, known: boolean }>}
|
|
231
|
+
* `known=false` means the scan could not be completed (no path, unreadable,
|
|
232
|
+
* budget overrun); callers keep their existing blocking behaviour there.
|
|
233
|
+
*/
|
|
234
|
+
export async function transcriptHandoffIntent(transcriptPath, {
|
|
235
|
+
budgetMs = INTENT_SCAN_BUDGET_MS,
|
|
236
|
+
maxBytes = INTENT_SCAN_MAX_BYTES,
|
|
237
|
+
} = {}) {
|
|
238
|
+
if (typeof transcriptPath !== 'string' || !transcriptPath.trim()) {
|
|
239
|
+
return { intent: null, known: false };
|
|
240
|
+
}
|
|
241
|
+
let handle;
|
|
242
|
+
try {
|
|
243
|
+
handle = await fs.open(transcriptPath, 'r');
|
|
244
|
+
const stat = await handle.stat();
|
|
245
|
+
const size = Number(stat.size) || 0;
|
|
246
|
+
const start = Math.max(0, size - maxBytes);
|
|
247
|
+
const deadline = Date.now() + budgetMs;
|
|
248
|
+
const chunk = Buffer.allocUnsafe(Math.min(Math.max(1, size - start), 4 * 1024 * 1024));
|
|
249
|
+
let text = '';
|
|
250
|
+
let pos = start;
|
|
251
|
+
while (pos < size) {
|
|
252
|
+
if (Date.now() > deadline) return { intent: null, known: false };
|
|
253
|
+
const toRead = Math.min(chunk.length, size - pos);
|
|
254
|
+
const { bytesRead } = await handle.read(chunk, 0, toRead, pos);
|
|
255
|
+
if (!bytesRead) break;
|
|
256
|
+
text += chunk.toString('utf8', 0, bytesRead);
|
|
257
|
+
pos += bytesRead;
|
|
258
|
+
}
|
|
259
|
+
let lines = text.split('\n');
|
|
260
|
+
// A tail window starts mid-line: the first fragment is not a whole entry.
|
|
261
|
+
if (start > 0) lines = lines.slice(1);
|
|
262
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
263
|
+
const line = lines[i];
|
|
264
|
+
if (!line) continue;
|
|
265
|
+
// 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')) {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const intent = classifyHandoffLine(line);
|
|
270
|
+
if (intent) return { intent, known: true };
|
|
271
|
+
}
|
|
272
|
+
// Nothing found. Only a scan of the WHOLE transcript proves "no handoff
|
|
273
|
+
// intent"; a tail window that missed it (the invoking turn usually appears
|
|
274
|
+
// once, near the start of an omp session) cannot, so it stays unknown and
|
|
275
|
+
// callers keep blocking.
|
|
276
|
+
return { intent: null, known: start === 0 };
|
|
277
|
+
} catch {
|
|
278
|
+
return { intent: null, known: false };
|
|
279
|
+
} finally {
|
|
280
|
+
try { await handle?.close(); } catch {}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Back-compat boolean view of the same scan: did this transcript ever show the
|
|
286
|
+
* session driving (or being handed) a handoff-fullstack run? Returns true, false,
|
|
287
|
+
* or null (unknown) exactly as the original stop-coordinator helper did.
|
|
288
|
+
*/
|
|
289
|
+
export async function transcriptShowsHandoffRun(transcriptPath, opts = {}) {
|
|
290
|
+
const { intent, known } = await transcriptHandoffIntent(transcriptPath, opts);
|
|
291
|
+
if (!known) return null;
|
|
292
|
+
return intent === 'fullstack';
|
|
293
|
+
}
|