@ngockhoale/ukit 3.4.65 → 3.4.67
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 +13 -0
- package/manifests/documentation.yaml +11 -0
- package/package.json +2 -2
- package/scripts/release/verify-release.mjs +7 -0
- package/scripts/test/run-handoff-isolated.mjs +178 -0
- package/src/core/executionContracts.js +2 -2
- package/template_project/.claude/agents/code-reviewer.md +4 -1
- package/template_project/.claude/agents/handoff-planner.md +3 -0
- package/template_project/.claude/commands/ukit/handoff-clear.md +34 -35
- package/template_project/.claude/commands/ukit/handoff-create.md +40 -21
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +141 -66
- package/template_project/.claude/commands/ukit/handoff-implement.md +27 -12
- package/template_project/.claude/commands/ukit/handoff-review.md +18 -8
- package/template_project/.claude/hooks/handoff-resume.sh +8 -0
- package/template_project/.claude/skills/advisor-plan/REFERENCE.md +33 -2
- package/template_project/.claude/skills/advisor-plan/SKILL.md +6 -4
- package/template_project/.claude/ukit/index/route-resolver.mjs +2 -2
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +40 -7
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +128 -44
- package/template_project/.claude/ukit/runtime/task-watchdog.mjs +1 -1
- package/template_project/.omp/agents/code-reviewer.md +4 -1
- package/template_project/.omp/agents/handoff-planner.md +3 -0
- package/template_project/AGENTS.md +2 -2
- package/template_project/CLAUDE.md +2 -2
- package/template_project/docs/AI_HANDOFF/RULES.md +24 -27
- package/template_project/docs/AI_HANDOFF/queued/README.md +52 -0
- package/template_project/instructions/core.md +2 -2
|
@@ -28,6 +28,7 @@ This skill is **planning-only**. A create session that loads it edits docs and t
|
|
|
28
28
|
### Phase A — Gather (lite tier)
|
|
29
29
|
|
|
30
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
|
+
1a. **Source snapshot** — before chunking, write `${OUTPUT_ROOT}SOURCE.md` with the original path (or the durable verbatim message origin for chat input), the `sha256` of the original bytes, and a verbatim copy of the full source text (see [REFERENCE.md](./REFERENCE.md#source-snapshot)). Chunks and citations point into this copy, never into a paraphrase.
|
|
31
32
|
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
33
|
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
34
|
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.
|
|
@@ -36,13 +37,14 @@ Tier (lite gatherers): on Claude Code start a read-only `general-purpose` subage
|
|
|
36
37
|
|
|
37
38
|
### Phase B — Decide, ledger, plan (smart tier)
|
|
38
39
|
|
|
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
|
|
40
|
-
2.
|
|
40
|
+
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 and a justification; `blocker` names the missing prerequisite or owner conflict.
|
|
41
|
+
2. Only `feasible`/`adapted` rows map to executable tasks, and each maps to an executable acceptance criterion and a test or verification command. `deferred` rows need a justification and a queue entry, not a task. `blocker` rows need the missing prerequisite or owner decision named, not a task. Rows the source marks as proposal, example, or rejected are not promoted to tasks: they stay as classified NOTES entries with their rationale.
|
|
41
42
|
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
43
|
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
44
|
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
|
|
45
|
-
7. Replan: append ledger deltas and supersede with `-R<n>` rules; never overwrite a task that carries an executor report.
|
|
45
|
+
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>/`. Mandatory overflow (`feasible`/`adapted` rows) is queued WITHOUT `QueueIntent` (no `Queue*` header line), so fullstack folds it in as a legacy blueprint. Only `deferred` rows get the deliberate-future header from [REFERENCE.md](./REFERENCE.md#queue-header) (`QueueIntent: deliberate-future`), which fullstack reports as backlog and never folds.
|
|
46
|
+
7. Replan: append ledger deltas and supersede with `-R<n>` rules; never overwrite a task that carries an executor report. A replan that overwrites a prior PLAN.md first copies the prior files to `amendments/rev-<NN>/` and records the prior requirement, decision and task versions, what changed, and the `invalidated` evidence under `## Amendment History` in PLAN.md.
|
|
47
|
+
8. **Semantic record (mandatory; Source-only notes live in NOTES.md)** — every classification, exclusion, and its rationale must be recorded somewhere durable. The mandatory record is the LEDGER (dispositions and rationale) plus the rationale for every non-task item (proposal, example, rejected, superseded, assumption, question). The file name `${OUTPUT_ROOT}NOTES.md` (see [REFERENCE.md](./REFERENCE.md#source-only-notes)) is the default place for those non-task items; an equivalent record under another name is acceptable, but the content is not optional.
|
|
46
48
|
|
|
47
49
|
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
50
|
|
|
@@ -1158,12 +1158,12 @@ function declaredReadOnly(promptText, releasePolicy) {
|
|
|
1158
1158
|
// "template/install", "source change") as mutation orders. Order is structural: a request whose
|
|
1159
1159
|
// command-position verbs are verification verbs, with no explicit order (hasExplicitOrder) and no
|
|
1160
1160
|
// change-leading clause, orders no change - it owes verification evidence, not a write.
|
|
1161
|
-
const VERIFY_LEAD = /^(?:[-*+•>\d.)\s]*)(?:(?:please|hay|vui long|now|also|first|then)\s+)*(?:verify|re-?run|confirm|xac nhan|validate|double-?check)\b/;
|
|
1161
|
+
const VERIFY_LEAD = /^(?:[-*+•>\d.)\s]*)(?:(?:please|hay|vui long|now|also|first|then)\s+)*(?:(?:keep|continue|tiep tuc)\s+)?(?:verify|re-?run|confirm|xac nhan|validate|double-?check|wait(?:ing)?|monitor|watch|cho(?=\s+(?:dung\s+)?(?:job|verifier|build|test|suite|gate|ci|run|task|ket qua|xong|output)\b))\b/;
|
|
1162
1162
|
const CHANGE_LEAD = /^(?:[-*+•>\d.)\s]*)(?:(?:please|hay|vui long|now|also|first|then)\s+)*(?:make|rebuild|redo|rewrite|restore|revert|correct|clean|patch|resolve|ensure|run(?!(?=[^.]*\b(?:tests?|suite|gate|checks?|lint|typecheck|pilot|smoke|e2e|verif\w*|vitest|jest|audit)\b)))\b/;
|
|
1163
1163
|
export function isVerificationOnlyRequest(promptText) {
|
|
1164
1164
|
const plain = String(promptText ?? '').normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[đĐ]/g, 'd').toLowerCase()
|
|
1165
1165
|
.replace(/```[\s\S]*?```/g, ' ').replace(/^[ \t]*>.*$/gm, ' ');
|
|
1166
|
-
const clauses = neutralizeNonOrders(plain).split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/)
|
|
1166
|
+
const clauses = neutralizeNonOrders(plain).split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b|\broi\b)\s*/)
|
|
1167
1167
|
.map((clause) => clause.split(/\bwithout\b/)[0]);
|
|
1168
1168
|
return clauses.some((clause) => VERIFY_LEAD.test(clause)) && !hasExplicitOrder(clauses)
|
|
1169
1169
|
&& !clauses.some((clause) => CHANGE_LEAD.test(clause));
|
|
@@ -1014,7 +1014,7 @@ export async function countActiveTaskClaims(projectRoot, { now = Date.now() } =
|
|
|
1014
1014
|
// --- Archive-time status snapshot ---------------------------------------------------
|
|
1015
1015
|
// INDEX.md owns the task lifecycle; a task file's own `- Status:` header is executor-written
|
|
1016
1016
|
// and goes stale (`ready`) once the task is archived. `archiveCycleSnapshot` persists the
|
|
1017
|
-
//
|
|
1017
|
+
// current INDEX rows beside the archived task files (`ARCHIVE/cycle-NN/INDEX.md`),
|
|
1018
1018
|
// which is what the Stop predicate (stop-coordinator `evalFinalizedCycles`) reads. Idempotent,
|
|
1019
1019
|
// crash-safe (run it BEFORE the INDEX reset; a retry after a partial archive just re-derives),
|
|
1020
1020
|
// and fail-closed: a non-terminal live row is recorded as-is, never promoted.
|
|
@@ -1033,20 +1033,49 @@ function indexRows(text) {
|
|
|
1033
1033
|
}
|
|
1034
1034
|
|
|
1035
1035
|
/**
|
|
1036
|
-
* Snapshot the live INDEX rows of
|
|
1037
|
-
*
|
|
1036
|
+
* Snapshot the live INDEX rows of tasks already moved into `ARCHIVE/cycle-<NN>/`
|
|
1037
|
+
* (or an existing legacy lowercase cycle) and stamp each archived task
|
|
1038
1038
|
* header with its terminal INDEX status. Returns `{ ok, cycle, snapshotted, stamped, unchanged }`.
|
|
1039
1039
|
*/
|
|
1040
1040
|
export async function archiveCycleSnapshot(projectRoot, cycle) {
|
|
1041
1041
|
const nn = String(cycle).padStart(2, '0');
|
|
1042
1042
|
if (!/^\d{2,}$/.test(nn)) return { ok: false, reason: 'bad-cycle' };
|
|
1043
1043
|
const ai = path.join(projectRoot, 'docs', 'AI_HANDOFF');
|
|
1044
|
-
|
|
1044
|
+
let dir = path.join(ai, 'ARCHIVE', `cycle-${nn}`);
|
|
1045
|
+
// Recovery of an already-moved legacy cycle must not lose its task records on
|
|
1046
|
+
// case-sensitive hosts. New cycles are created by F3 under ARCHIVE only.
|
|
1047
|
+
try {
|
|
1048
|
+
await fs.access(dir);
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
if (error?.code !== 'ENOENT') return { ok: false, reason: 'archive-unreadable' };
|
|
1051
|
+
const legacy = path.join(ai, 'archive', `cycle-${nn}`);
|
|
1052
|
+
try { await fs.access(legacy); dir = legacy; } catch (legacyError) {
|
|
1053
|
+
if (legacyError?.code !== 'ENOENT') return { ok: false, reason: 'legacy-archive-unreadable' };
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
// Archive writes must never follow a project record symlink into another workspace.
|
|
1057
|
+
for (const target of [path.join(projectRoot, 'docs'), ai, path.dirname(dir), dir, path.join(dir, 'INDEX.md')]) {
|
|
1058
|
+
try {
|
|
1059
|
+
if ((await fs.lstat(target)).isSymbolicLink()) return { ok: false, reason: 'archive-symlink' };
|
|
1060
|
+
} catch (error) {
|
|
1061
|
+
if (error?.code !== 'ENOENT') return { ok: false, reason: 'archive-unreadable' };
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1045
1064
|
const snapPath = path.join(dir, 'INDEX.md');
|
|
1046
1065
|
let live = new Map();
|
|
1047
1066
|
try { live = indexRows(await fs.readFile(path.join(ai, 'INDEX.md'), 'utf8')); } catch { /* header-only / missing → rely on prior snapshot */ }
|
|
1048
1067
|
let prior = new Map();
|
|
1049
|
-
try {
|
|
1068
|
+
try {
|
|
1069
|
+
const priorText = await fs.readFile(snapPath, 'utf8');
|
|
1070
|
+
const taskRows = priorText.split('\n').map((line) => line.trim()).filter((line) => /^\|\s*TASK-/i.test(line));
|
|
1071
|
+
if (taskRows.length === 0 || taskRows.some((row) => !row.endsWith('|') || row.split('|').slice(1, -1).length !== 8)) {
|
|
1072
|
+
return { ok: false, reason: 'prior-snapshot-malformed' };
|
|
1073
|
+
}
|
|
1074
|
+
prior = indexRows(priorText);
|
|
1075
|
+
if (prior.size !== taskRows.length) return { ok: false, reason: 'prior-snapshot-malformed' };
|
|
1076
|
+
} catch (error) {
|
|
1077
|
+
if (error?.code !== 'ENOENT') return { ok: false, reason: 'prior-snapshot-unreadable' };
|
|
1078
|
+
}
|
|
1050
1079
|
// Only task files already inside THIS cycle's archive folder are snapshotted (F3 moves them
|
|
1051
1080
|
// first). Task ids restart per cycle, so the live `tasks/` and live INDEX rows of a LATER
|
|
1052
1081
|
// cycle must never be attributed here: an existing snapshot row is immutable (prior wins);
|
|
@@ -1054,9 +1083,13 @@ export async function archiveCycleSnapshot(projectRoot, cycle) {
|
|
|
1054
1083
|
const ids = new Set();
|
|
1055
1084
|
try {
|
|
1056
1085
|
for (const f of await fs.readdir(dir)) {
|
|
1057
|
-
if (
|
|
1086
|
+
if (!/^TASK-[\w.-]+\.md$/i.test(f)) continue;
|
|
1087
|
+
if ((await fs.lstat(path.join(dir, f))).isSymbolicLink()) return { ok: false, reason: 'archive-symlink' };
|
|
1088
|
+
ids.add(f.replace(/\.md$/i, '').toUpperCase());
|
|
1058
1089
|
}
|
|
1059
|
-
} catch {
|
|
1090
|
+
} catch (error) {
|
|
1091
|
+
return { ok: false, reason: error?.code === 'ENOENT' ? 'no-task-files' : 'archive-unreadable' };
|
|
1092
|
+
}
|
|
1060
1093
|
if (ids.size === 0) return { ok: false, reason: 'no-task-files' };
|
|
1061
1094
|
const merged = new Map();
|
|
1062
1095
|
for (const id of [...ids].sort()) {
|
|
@@ -797,9 +797,31 @@ function parseRunCursor(text) {
|
|
|
797
797
|
next: field('Next'),
|
|
798
798
|
exitPredicate: field('ExitPredicate'),
|
|
799
799
|
goalScope: field('GoalScope').toLowerCase(),
|
|
800
|
+
scopeQueue: field('ScopeQueue'),
|
|
801
|
+
cycle: field('Cycle'),
|
|
800
802
|
};
|
|
801
803
|
}
|
|
802
804
|
|
|
805
|
+
// Additive optional RUN field `Cycle:` (e.g. `cycle-02` or `02`) pins the archived cycle this
|
|
806
|
+
// run owns. Blank → null (legacy: every archived cycle counts). Normalised to `cycle-NN`.
|
|
807
|
+
function parseRunCycle(value) {
|
|
808
|
+
const raw = String(value ?? '').trim();
|
|
809
|
+
if (!raw) return null;
|
|
810
|
+
const m = raw.match(/^(?:cycle-)?(\d+)$/i);
|
|
811
|
+
return m ? `cycle-${m[1].padStart(2, '0')}` : `invalid:${raw}`;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Frozen queue membership (optional RUN field `ScopeQueue:`). Comma-separated blueprint slugs
|
|
815
|
+
// the run agreed to consume. Absent/blank → null (legacy: every queued blueprint counts).
|
|
816
|
+
// `none` → empty set (nothing in scope). A blueprint queued later is NOT in the set and so
|
|
817
|
+
// never extends this run's scope.
|
|
818
|
+
export function parseScopeQueue(value) {
|
|
819
|
+
const raw = String(value ?? '').trim();
|
|
820
|
+
if (!raw) return null;
|
|
821
|
+
if (/^none$/i.test(raw)) return new Set();
|
|
822
|
+
return new Set(raw.split(',').map((s) => s.trim()).filter(Boolean));
|
|
823
|
+
}
|
|
824
|
+
|
|
803
825
|
// ─── Goal backlog beyond the active cycle ──────────────────────────────────
|
|
804
826
|
// A finalized cycle resets INDEX.md to a header-only table, so "empty INDEX" proves nothing
|
|
805
827
|
// about the GOAL. For a run-all goal (`GoalScope: run-all` in RUN.md) the authoritative
|
|
@@ -807,7 +829,9 @@ function parseRunCursor(text) {
|
|
|
807
829
|
// docs/AI_HANDOFF/queued/<slug>/ — excluding `QueueIntent: deliberate-future` ones (separate
|
|
808
830
|
// backlog by contract) and blueprints already moved to archive/. Unreadable → null (unknown is
|
|
809
831
|
// never "nothing left").
|
|
810
|
-
|
|
832
|
+
// `scope` (optional Set from parseScopeQueue): when given, only the frozen slugs count, so a
|
|
833
|
+
// blueprint queued after the run froze its scope never extends that run. null = legacy, all count.
|
|
834
|
+
export async function pendingQueuedBlueprints(runRoot, scope = null) {
|
|
811
835
|
const dir = path.join(runRoot, 'docs', 'AI_HANDOFF', 'queued');
|
|
812
836
|
let entries;
|
|
813
837
|
try {
|
|
@@ -818,6 +842,7 @@ export async function pendingQueuedBlueprints(runRoot) {
|
|
|
818
842
|
const pending = [];
|
|
819
843
|
for (const entry of entries) {
|
|
820
844
|
if (!entry.isDirectory()) continue;
|
|
845
|
+
if (scope && !scope.has(entry.name)) continue;
|
|
821
846
|
let header = '';
|
|
822
847
|
try {
|
|
823
848
|
header = await fs.readFile(path.join(dir, entry.name, 'HANDOFF.md'), 'utf8');
|
|
@@ -860,21 +885,23 @@ export function parseExitPredicate(expr) {
|
|
|
860
885
|
* terminal-success status (`done` or `cancelled_superseded`). An unreadable or
|
|
861
886
|
* taskless INDEX cannot prove all-done, so the term fails.
|
|
862
887
|
*/
|
|
863
|
-
async function evalIndexAllDone(projectRoot, { goalScope = '' } = {}) {
|
|
888
|
+
async function evalIndexAllDone(projectRoot, { goalScope = '', scopeQueue = '', cycleField = '' } = {}) {
|
|
864
889
|
let text;
|
|
865
890
|
try {
|
|
866
891
|
text = await fs.readFile(path.join(projectRoot, 'docs', 'AI_HANDOFF', 'INDEX.md'), 'utf8');
|
|
867
892
|
} catch {
|
|
868
893
|
return { pass: false, detail: 'INDEX.md unreadable' };
|
|
869
894
|
}
|
|
870
|
-
const rows =
|
|
871
|
-
|
|
872
|
-
.map((line) => line.trim())
|
|
873
|
-
.filter((line) => /^\|\s*TASK-/i.test(line));
|
|
874
|
-
if (rows.length === 0) return evalFinalizedCycles(projectRoot, goalScope);
|
|
895
|
+
const rows = indexTaskRows(text);
|
|
896
|
+
if (rows.length === 0) return evalFinalizedCycles(projectRoot, goalScope, scopeQueue, cycleField);
|
|
875
897
|
const failing = [];
|
|
876
898
|
for (const row of rows) {
|
|
877
|
-
|
|
899
|
+
if (!indexTaskRowIsWellFormed(row)) {
|
|
900
|
+
const shape = row.startsWith('|') ? 'malformed task row' : 'unsupported task row (missing outer leading pipe)';
|
|
901
|
+
failing.push(`${shape} (expected ${INDEX_TASK_COLUMNS} columns): ${row.slice(0, 60)}`);
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
const cells = indexTaskCells(row);
|
|
878
905
|
const status = (cells[4] || '').toLowerCase();
|
|
879
906
|
if (status !== 'done' && status !== 'cancelled_superseded') {
|
|
880
907
|
failing.push(`${cells[0]}=${cells[4] || '?'}`);
|
|
@@ -886,12 +913,16 @@ async function evalIndexAllDone(projectRoot, { goalScope = '' } = {}) {
|
|
|
886
913
|
}
|
|
887
914
|
|
|
888
915
|
/** Task id → lower-cased status from the rows of an INDEX.md (or archive INDEX snapshot). */
|
|
916
|
+
// Returns Map<id, status>, or null when any TASK row is not the 8-column writer shape: a
|
|
917
|
+
// malformed snapshot proves nothing and must fail closed rather than shift Status onto
|
|
918
|
+
// another column.
|
|
889
919
|
function parseIndexStatuses(text) {
|
|
890
920
|
const map = new Map();
|
|
891
921
|
for (const line of String(text).split('\n')) {
|
|
892
922
|
const row = line.trim();
|
|
893
923
|
if (!/^\|\s*TASK-/i.test(row)) continue;
|
|
894
|
-
|
|
924
|
+
if (!indexTaskRowIsWellFormed(row)) return null;
|
|
925
|
+
const cells = indexTaskCells(row);
|
|
895
926
|
map.set(String(cells[0]).toUpperCase(), String(cells[4] || '').toLowerCase());
|
|
896
927
|
}
|
|
897
928
|
return map;
|
|
@@ -904,26 +935,62 @@ function parseIndexStatuses(text) {
|
|
|
904
935
|
* least one archived task exists, every archived task is terminal-success, and — for a run-all
|
|
905
936
|
* goal — no consumable queued blueprint remains. No archive evidence → still unprovable (fail).
|
|
906
937
|
*/
|
|
907
|
-
async function evalFinalizedCycles(projectRoot, goalScope) {
|
|
908
|
-
const
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
938
|
+
async function evalFinalizedCycles(projectRoot, goalScope, scopeQueue = '', cycleField = '') {
|
|
939
|
+
const cycles = [];
|
|
940
|
+
const seen = new Set();
|
|
941
|
+
const assignedCycle = parseRunCycle(cycleField);
|
|
942
|
+
if (assignedCycle && assignedCycle.startsWith('invalid:')) {
|
|
943
|
+
return { pass: false, detail: `RUN Cycle field is not a cycle id: ${assignedCycle.slice(8)}` };
|
|
944
|
+
}
|
|
945
|
+
// Read legacy history without migrating it. Case-insensitive hosts alias both roots;
|
|
946
|
+
// realpath de-duplicates those aliases while Linux retains distinct histories.
|
|
947
|
+
for (const spelling of ['ARCHIVE', 'archive']) {
|
|
948
|
+
const base = path.join(projectRoot, 'docs', 'AI_HANDOFF', spelling);
|
|
949
|
+
try {
|
|
950
|
+
const real = await fs.realpath(base);
|
|
951
|
+
if (seen.has(real)) continue;
|
|
952
|
+
seen.add(real);
|
|
953
|
+
for (const entry of await fs.readdir(base, { withFileTypes: true })) {
|
|
954
|
+
if (entry.isDirectory() && /^cycle-\d+$/.test(entry.name)) {
|
|
955
|
+
// Explicit Cycle pins the run to its own archive; other histories are unrelated.
|
|
956
|
+
if (assignedCycle && entry.name.toLowerCase() !== assignedCycle) continue;
|
|
957
|
+
cycles.push({ name: entry.name, dir: path.join(base, entry.name) });
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
} catch (error) {
|
|
961
|
+
if (error?.code !== 'ENOENT') return { pass: false, detail: 'archive history unreadable' };
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
if (cycles.length === 0) {
|
|
965
|
+
return assignedCycle
|
|
966
|
+
? { pass: false, detail: `assigned archive ${assignedCycle} not found` }
|
|
967
|
+
: { pass: false, detail: 'INDEX.md has no task rows' };
|
|
914
968
|
}
|
|
915
969
|
let total = 0;
|
|
916
970
|
const failing = [];
|
|
917
971
|
for (const cycle of cycles) {
|
|
918
972
|
let files;
|
|
919
|
-
|
|
973
|
+
// A cycle was ENUMERATED above, so it is evidence we owe its obligations. Any readdir failure
|
|
974
|
+
// here — including ENOENT (the directory vanished after listing) — cannot prove them and must
|
|
975
|
+
// fail closed; silently skipping it would let an unrelated done cycle release this one.
|
|
976
|
+
try { files = await fs.readdir(path.join(cycle.dir)); } catch {
|
|
977
|
+
return { pass: false, detail: `archive cycle unreadable: ${cycle.name}` };
|
|
978
|
+
}
|
|
920
979
|
// Authoritative status = the INDEX snapshot taken at archive time (resumable-run.mjs
|
|
921
980
|
// archive-cycle). A task file's own `- Status:` header is executor-written and goes stale
|
|
922
981
|
// (`ready`) once INDEX.md owns the lifecycle; it is only the fallback for legacy archives
|
|
923
982
|
// that carry no snapshot. A snapshot row that is NOT terminal always wins (fail closed).
|
|
924
983
|
let snapshot = null;
|
|
925
984
|
if (files.includes('INDEX.md')) {
|
|
926
|
-
|
|
985
|
+
// The snapshot was listed, so it exists as evidence. Any read failure — including ENOENT
|
|
986
|
+
// (vanished after listing) — or a malformed snapshot fails closed; only a snapshot that was
|
|
987
|
+
// never listed (the no-INDEX.md branch below) is the legacy header fallback.
|
|
988
|
+
let raw;
|
|
989
|
+
try { raw = await fs.readFile(path.join(cycle.dir, 'INDEX.md'), 'utf8'); } catch {
|
|
990
|
+
return { pass: false, detail: `archive INDEX snapshot unreadable: ${cycle.name}` };
|
|
991
|
+
}
|
|
992
|
+
snapshot = parseIndexStatuses(raw);
|
|
993
|
+
if (snapshot === null) return { pass: false, detail: `archive INDEX snapshot malformed: ${cycle.name}` };
|
|
927
994
|
}
|
|
928
995
|
for (const file of files.filter((f) => /^TASK-[\w.-]+\.md$/i.test(f))) {
|
|
929
996
|
total += 1;
|
|
@@ -933,7 +1000,7 @@ async function evalFinalizedCycles(projectRoot, goalScope) {
|
|
|
933
1000
|
let status = snapshot ? (snapshot.get(id) || '') : '';
|
|
934
1001
|
if (!snapshot) {
|
|
935
1002
|
let body = '';
|
|
936
|
-
try { body = await fs.readFile(path.join(
|
|
1003
|
+
try { body = await fs.readFile(path.join(cycle.dir, file), 'utf8'); } catch { /* unreadable → not proven */ }
|
|
937
1004
|
status = (body.match(/^-\s*Status:\s*`?([\w-]+)`?/im)?.[1] || '').toLowerCase();
|
|
938
1005
|
}
|
|
939
1006
|
if (status !== 'done' && status !== 'cancelled_superseded') {
|
|
@@ -944,7 +1011,7 @@ async function evalFinalizedCycles(projectRoot, goalScope) {
|
|
|
944
1011
|
if (total === 0) return { pass: false, detail: 'INDEX.md has no task rows' };
|
|
945
1012
|
if (failing.length > 0) return { pass: false, detail: `archived task not done: ${failing.join(', ')}` };
|
|
946
1013
|
if (String(goalScope).toLowerCase() === 'run-all') {
|
|
947
|
-
const remaining = await pendingQueuedBlueprints(projectRoot);
|
|
1014
|
+
const remaining = await pendingQueuedBlueprints(projectRoot, parseScopeQueue(scopeQueue));
|
|
948
1015
|
if (!Array.isArray(remaining)) return { pass: false, detail: 'queued backlog unreadable' };
|
|
949
1016
|
if (remaining.length > 0) return { pass: false, detail: `queued blueprint(s) remain: ${remaining.join(', ')}` };
|
|
950
1017
|
}
|
|
@@ -1084,7 +1151,7 @@ async function evalVerifyPass(projectRoot) {
|
|
|
1084
1151
|
* malformed (unsatisfiable). Advisory like the rest of the lane — a thrown
|
|
1085
1152
|
* error becomes a failed term, never an exception.
|
|
1086
1153
|
*/
|
|
1087
|
-
async function evaluateExitPredicate(projectRoot, expr, { goalScope = '' } = {}) {
|
|
1154
|
+
async function evaluateExitPredicate(projectRoot, expr, { goalScope = '', scopeQueue = '', cycleField = '' } = {}) {
|
|
1088
1155
|
const terms = parseExitPredicate(expr);
|
|
1089
1156
|
if (!terms) {
|
|
1090
1157
|
return { ok: false, terms: [{ term: String(expr || '').trim() || '(empty)', pass: false, detail: 'malformed predicate' }] };
|
|
@@ -1093,7 +1160,7 @@ async function evaluateExitPredicate(projectRoot, expr, { goalScope = '' } = {})
|
|
|
1093
1160
|
for (const term of terms) {
|
|
1094
1161
|
let r;
|
|
1095
1162
|
try {
|
|
1096
|
-
if (term === 'index:all-done') r = await evalIndexAllDone(projectRoot, { goalScope });
|
|
1163
|
+
if (term === 'index:all-done') r = await evalIndexAllDone(projectRoot, { goalScope, scopeQueue, cycleField });
|
|
1097
1164
|
else if (term === 'git:clean') r = evalGitClean(projectRoot);
|
|
1098
1165
|
else if (term === 'agents:none-running') r = await evalAgentsNoneRunning(projectRoot);
|
|
1099
1166
|
else if (term === 'verify:pass') r = await evalVerifyPass(projectRoot);
|
|
@@ -1235,19 +1302,31 @@ export function deriveGoalStatus({ runCursor = null, tasks = null, ledger = null
|
|
|
1235
1302
|
// reconciled in, so the stall signature tracks goal progress, not just cursor
|
|
1236
1303
|
// wording.
|
|
1237
1304
|
|
|
1305
|
+
// A task row is any line that starts with a TASK id, with or without the outer leading pipe.
|
|
1306
|
+
// The outer-less form is not a supported writer shape: it is still returned here so the
|
|
1307
|
+
// well-formedness check below rejects it clearly, instead of silently dropping the task.
|
|
1238
1308
|
function indexTaskRows(text) {
|
|
1239
1309
|
return String(text)
|
|
1240
1310
|
.split('\n')
|
|
1241
1311
|
.map((line) => line.trim())
|
|
1242
|
-
.filter((line) =>
|
|
1312
|
+
.filter((line) => /^\|?\s*TASK-/i.test(line));
|
|
1243
1313
|
}
|
|
1244
1314
|
|
|
1245
1315
|
function indexTaskCells(row) {
|
|
1246
|
-
return row.split('|').map((c) => c.trim())
|
|
1316
|
+
return row.split('|').slice(1, -1).map((c) => c.trim());
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// The writer (INDEX.md header + resumable-run indexRows) emits exactly 8 positional columns.
|
|
1320
|
+
// Any other width means a dropped or extra delimiter, so every positional read after it
|
|
1321
|
+
// (Status especially) would be shifted onto the wrong column: such a row is malformed.
|
|
1322
|
+
const INDEX_TASK_COLUMNS = 8;
|
|
1323
|
+
function indexTaskRowIsWellFormed(row) {
|
|
1324
|
+
return indexTaskCells(row).length === INDEX_TASK_COLUMNS;
|
|
1247
1325
|
}
|
|
1248
1326
|
|
|
1249
1327
|
function indexTasks(indexText) {
|
|
1250
1328
|
return indexTaskRows(indexText).map((row) => {
|
|
1329
|
+
if (!indexTaskRowIsWellFormed(row)) return { id: '', status: 'malformed' };
|
|
1251
1330
|
const cells = indexTaskCells(row);
|
|
1252
1331
|
return { id: cells[0] || '', status: (cells[4] || '').toLowerCase() };
|
|
1253
1332
|
});
|
|
@@ -1414,7 +1493,7 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
|
|
|
1414
1493
|
} catch {
|
|
1415
1494
|
return { kind: 'none' };
|
|
1416
1495
|
}
|
|
1417
|
-
const { command, goal, phase, cursor, next, exitPredicate, goalScope } = parseRunCursor(text);
|
|
1496
|
+
const { command, goal, phase, cursor, next, exitPredicate, goalScope, scopeQueue, cycle: cycleField } = parseRunCursor(text);
|
|
1418
1497
|
// Shared terminal predicate — tests/handoff/cycle21 greps this exact shape across
|
|
1419
1498
|
// all four RUN.md consumers; keep `^(done|blocked)` as one expression.
|
|
1420
1499
|
const terminal = String(phase).match(/^(done|blocked)\b/i)?.[1]?.toLowerCase() || '';
|
|
@@ -1426,6 +1505,27 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
|
|
|
1426
1505
|
// task index — the default `all(index:all-done)` applies so a run-all goal
|
|
1427
1506
|
// cannot claim done over an unfinished backlog. No INDEX.md at all → the
|
|
1428
1507
|
// legacy phase-only release (nothing in scope to prove).
|
|
1508
|
+
// Session scope: a run owned by another session must not hijack an unrelated session. Decided
|
|
1509
|
+
// before pause and before every `done` branch, so a non-owner session never receives the pause
|
|
1510
|
+
// advisory. Only a positive "not fullstack" releases; unknown (null) keeps the block.
|
|
1511
|
+
const transcriptOwner = gate.scope === 'session' ? await transcriptShowsHandoffRun(transcriptPath) : true;
|
|
1512
|
+
if (gate.scope === 'session' && transcriptOwner === false) {
|
|
1513
|
+
return { kind: 'none' };
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// PAUSED run: never force continuation. Pause outranks every `done` branch below — the
|
|
1517
|
+
// default-predicate early returns (empty/header-only INDEX → `none`) included — so a user-paused
|
|
1518
|
+
// run is never reported as completed. Advisory, never `none`: no completion is minted here.
|
|
1519
|
+
const pausedIntent = await loadHandoffIntentModule();
|
|
1520
|
+
if (pausedIntent?.runCursorPaused?.(text) === true) {
|
|
1521
|
+
return {
|
|
1522
|
+
kind: 'advisory',
|
|
1523
|
+
waitForApproval: true,
|
|
1524
|
+
systemMessage: '[ukit-stop-coordinator] handoff run is PAUSED: stop allowed, nothing is resumed or forced. '
|
|
1525
|
+
+ 'Only an explicit user instruction (/ukit:handoff-fullstack or "continue") lifts the pause.',
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1429
1529
|
let effectivePredicate = exitPredicate;
|
|
1430
1530
|
let predicateIsDefault = false;
|
|
1431
1531
|
let openNextCycle = null;
|
|
@@ -1438,7 +1538,7 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
|
|
|
1438
1538
|
// Header-only INDEX = this CYCLE is finalized, not necessarily the GOAL: a run-all goal
|
|
1439
1539
|
// continues while consumable queued blueprints remain (or the backlog is unreadable).
|
|
1440
1540
|
if (goalScope !== 'run-all') return { kind: 'none' };
|
|
1441
|
-
const remaining = await pendingQueuedBlueprints(runRoot);
|
|
1541
|
+
const remaining = await pendingQueuedBlueprints(runRoot, parseScopeQueue(scopeQueue));
|
|
1442
1542
|
if (Array.isArray(remaining) && remaining.length === 0) return { kind: 'none' };
|
|
1443
1543
|
openNextCycle = { remaining };
|
|
1444
1544
|
} else {
|
|
@@ -1452,11 +1552,6 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
|
|
|
1452
1552
|
// must not be bounced into a stale run. Only a positive "scanned the whole
|
|
1453
1553
|
// transcript, latest intent is not fullstack" releases; unknown (null) keeps
|
|
1454
1554
|
// the block, so a real run is never released by a lost read.
|
|
1455
|
-
const transcriptOwner = gate.scope === 'session' ? await transcriptShowsHandoffRun(transcriptPath) : true;
|
|
1456
|
-
if (gate.scope === 'session' && transcriptOwner === false) {
|
|
1457
|
-
return { kind: 'none' };
|
|
1458
|
-
}
|
|
1459
|
-
|
|
1460
1555
|
if (terminal === 'done' && openNextCycle) {
|
|
1461
1556
|
const names = openNextCycle.remaining;
|
|
1462
1557
|
return {
|
|
@@ -1477,7 +1572,7 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
|
|
|
1477
1572
|
if (terminal === 'done') {
|
|
1478
1573
|
let predicate;
|
|
1479
1574
|
try {
|
|
1480
|
-
predicate = await evaluateExitPredicate(runRoot, effectivePredicate, { goalScope });
|
|
1575
|
+
predicate = await evaluateExitPredicate(runRoot, effectivePredicate, { goalScope, scopeQueue, cycleField });
|
|
1481
1576
|
} catch {
|
|
1482
1577
|
return { kind: 'none' }; // advisory lane: evaluator failure never wedges a stop
|
|
1483
1578
|
}
|
|
@@ -1528,17 +1623,6 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
|
|
|
1528
1623
|
return { kind: 'none' };
|
|
1529
1624
|
}
|
|
1530
1625
|
|
|
1531
|
-
// PAUSED run: never force continuation.
|
|
1532
|
-
const intentMod = await loadHandoffIntentModule();
|
|
1533
|
-
if (intentMod?.runCursorPaused?.(text) === true) {
|
|
1534
|
-
return {
|
|
1535
|
-
kind: 'advisory',
|
|
1536
|
-
waitForApproval: true,
|
|
1537
|
-
systemMessage: '[ukit-stop-coordinator] handoff run is PAUSED: stop allowed, nothing is resumed or forced. '
|
|
1538
|
-
+ 'Only an explicit user instruction (/ukit:handoff-fullstack or "continue") lifts the pause.',
|
|
1539
|
-
};
|
|
1540
|
-
}
|
|
1541
|
-
|
|
1542
1626
|
// No-progress breaker inputs: the stall signature now carries the INDEX
|
|
1543
1627
|
// ids+statuses hash, so an unchanged task snapshot accrues toward the cap even
|
|
1544
1628
|
// when the cursor wording keeps moving — and reconciling a new task or moving
|
|
@@ -1573,7 +1657,7 @@ export async function evaluateHandoffCursor({ projectRoot, runRoot = projectRoot
|
|
|
1573
1657
|
let predicateState = 'no ExitPredicate recorded';
|
|
1574
1658
|
if (effectivePredicate) {
|
|
1575
1659
|
try {
|
|
1576
|
-
predicateState = formatPredicateState(await evaluateExitPredicate(runRoot, effectivePredicate, { goalScope }));
|
|
1660
|
+
predicateState = formatPredicateState(await evaluateExitPredicate(runRoot, effectivePredicate, { goalScope, scopeQueue, cycleField }));
|
|
1577
1661
|
} catch {
|
|
1578
1662
|
predicateState = 'predicate evaluation failed';
|
|
1579
1663
|
}
|
|
@@ -206,7 +206,7 @@ export async function mutateWatchdogState(statePath, updater, { signal, deadline
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
// ─── Task parser ──────────────────────────────────────────────────────────
|
|
209
|
-
const TASK_ID_REGEX = /^#\s+TASK-([0-9]+[A-Za-z0-9._-]*)\b/m;
|
|
209
|
+
const TASK_ID_REGEX = /^#\s+TASK-((?:C\d+-)?[0-9]+[A-Za-z0-9._-]*)\b/m;
|
|
210
210
|
const STATUS_REGEX = /^-\s*Status:\s*`?([a-z_]+)`?/m;
|
|
211
211
|
const SIZE_REGEX = /^-\s*Size:\s*`?([SML])`?/m;
|
|
212
212
|
|
|
@@ -211,8 +211,9 @@ additions:
|
|
|
211
211
|
|
|
212
212
|
### Inputs you expect
|
|
213
213
|
|
|
214
|
-
- Path to the spec/plan document (e.g. `docs/plans/*.md`). No diff
|
|
214
|
+
- Path to the spec/plan document (e.g. `docs/plans/*.md`). No diff and no executor report.
|
|
215
215
|
- When invoked from the handoff pipeline you get BOTH `docs/AI_HANDOFF/SPEC.md` and `docs/AI_HANDOFF/PLAN.md`. Review them as one unit: the spec is the contract, the plan is the decomposition. Verdicts still append to PLAN.md's `## Plan Review Log`.
|
|
216
|
+
- A plan is not reviewable from SPEC+PLAN alone. Start from the original: `SOURCE.md` (verbatim original bytes; verify the recorded sha256 against them, and for chat input the durable verbatim message origin stands in for the file), then the classification in `LEDGER.md` (every row dispositioned, feasible/adapted rows carry evidence), `NOTES.md` when present (optional source-only notes; a missing note is caught because the chain starts at SOURCE, not at NOTES), the `tasks/TASK-*.md` files of that plan (spec references, acceptance and verification), and the current source the Target Files touch (index-first). Compare the chain SOURCE → classification (LEDGER) → tasks; check NOTES against the same chain. A `feasible`/`adapted` requirement that the ledger or source shows but no task covers is a Completeness finding, unless it is exempt when their queue entry, owner decision, or classification is recorded (deferred, blocker, rejected, proposal, and example rows are not tasks); a task whose verification does not prove its acceptance is a Clarity finding.
|
|
216
217
|
|
|
217
218
|
### Review order
|
|
218
219
|
|
|
@@ -240,6 +241,8 @@ Only flag issues that would cause real problems during implementation planning.
|
|
|
240
241
|
|
|
241
242
|
Append (do NOT overwrite) this block to the end of the reviewed document under a `## Plan Review Log` section — create the section if it doesn't exist yet, keep all prior round entries:
|
|
242
243
|
|
|
244
|
+
Every finding bullet is written as `<location> :: <claim>` (location = the SOURCE/LEDGER/NOTES/TASK/SPEC/PLAN line or section it cites; claim = the defect in one sentence). The planner derives the finding signature from this exact pair (lowercased, whitespace collapsed, claim truncated to 120 characters), so keep it stable across rounds for an unchanged defect.
|
|
245
|
+
|
|
243
246
|
```
|
|
244
247
|
### Round <N> — <YYYY-MM-DD> · <your model>
|
|
245
248
|
Status: Approved | Issues Found
|
|
@@ -286,5 +286,8 @@ budget is the constraint on the whole run. Detail belongs in `PLAN.md`, not in t
|
|
|
286
286
|
|
|
287
287
|
- Do NOT implement. Job ends when all tasks are `ready` or `needs_breakdown`.
|
|
288
288
|
- Undone tasks in INDEX.md → continue that cycle; plan only what is still missing. Do not ask whether to start fresh, and do not overwrite task files that already carry an executor report or reviewer verdict.
|
|
289
|
+
- An independent new request is never an overwrite of a ready plan. Overwrite only when the human explicitly names the existing plan to amend. First copy every prior file (PLAN.md, SPEC.md, LEDGER.md, NOTES.md, SOURCE.md, tasks/TASK-*.md, when present) to the next unused `amendments/rev-<NN>/` (scan existing rev dirs, take max+1, never reuse one). Then compose the revised PLAN.md in full: carry the existing `## Amendment History` forward and append this amendment's entry (prior review rounds and verdicts; prior requirement, decision and task versions with what changed and why; each change's `invalidated` evidence: tasks, executor reports, verdicts that no longer hold). Write the revised PLAN.md only after that entry is in the composed text. Otherwise write to the queued root.
|
|
290
|
+
- A task that already carries an executor report is superseded, never overwritten: write the replacement as `TASK-<id>-R<n>.md` and keep the original file with its report.
|
|
291
|
+
- Round cap: count only completed reviewer verdicts (`### Round` entries with a `PLAN_REVIEW:` or verdict line). Repair markers (`### Repair <N>`, `### Escalation <N>`) are never counted. When two verdicts exist and no escalation is recorded, record `### Escalation 1 — <reason>`, apply the findings with a changed strategy (`### Repair <N> — changed strategy`), and let the reviewer run once more. If the escalation is already spent, do not request another round: mark still-flagged tasks `needs_breakdown` and write `PLAN_REVIEW: Unresolved — …`, never `Approved`. Identical findings after a repair (the finding signature set is unchanged) are non-progress: do not run another identical loop; retain Unresolved with a precise diagnostic. **Recovery exception (explicit, at most once per plan):** it runs only when the escalation is spent, the latest verdict is still `Issues Found`, the latest repair changed the signature set, and the Plan Review Log has no `### Recovery 1 — consumed` marker. Then, in order: (1) append `### Recovery 1 — consumed <YYYY-MM-DD>` before any other work; (2) run a root-cause investigation and a different decomposition; (3) request one fresh independent review of the changed artifact and append its verdict as `### Round <N>`. The marker is never removed or re-created. If the marker already exists, or the signature set is unchanged, do not request another review: retain Unresolved with a precise diagnostic. An owner conflict routes an owner decision. Never mark a plan `Approved` by the cap alone. After every verdict, append `### Signatures Round <N>: <sig>; <sig>; …` where each `<sig>` is the reviewer's `<location> :: <claim>` pair normalized (lowercased, whitespace collapsed, claim first 120 characters). Mandatory overflow (feasible/adapted rows beyond the 10-task dispatch window) is grouped by dependency chain into `queued/<plan-slug>-overflow-<NN>/` (max+1 index; on collision append `-2`, `-3`, …), never a deliberate-future header.
|
|
289
292
|
- Cannot determine test cases → `needs_breakdown` + Discussion thread note. Never mark a task `ready` with vague tests just to keep the pipeline moving — a task with fake tests passes review and ships a bug, which costs far more than one blocked task.
|
|
290
293
|
- You may be invoked unattended. Ask nothing; where the caller allowed questions, that window closed before you were spawned. Resolve ambiguity by reading the codebase, choose the option you would recommend, and record the choice and its rationale in `PLAN.md §3`.
|
|
@@ -110,7 +110,7 @@ Chỉ active khi task đi qua `docs/AI_HANDOFF/` ("execute task TASK-xxx" hoặc
|
|
|
110
110
|
<!-- RULE: UNATTENDED-02 -->
|
|
111
111
|
- `maxAttemptsPerFailure: 2`, `maxRecoveryStrategies: 2` distinct fix strategies before reassessing — retry the same edit, then fix the cause differently; never loop one strategy.
|
|
112
112
|
<!-- RULE: UNATTENDED-03 -->
|
|
113
|
-
- BLOCKED only on true external blockers: missing credentials, dead service, unreachable network/API. Test failures, lint errors, review findings, ambiguity are NOT blockers — feed FIX.
|
|
113
|
+
- BLOCKED only on true external blockers: missing credentials/permissions, dead service, unreachable network/API, or a genuine requirement conflict that needs an owner decision. Test failures, lint errors, review findings, plan findings, ambiguity within scope are NOT blockers — feed FIX (bounded strategies, then investigate/replan) and continue independent scope tasks.
|
|
114
114
|
<!-- RULE: UNATTENDED-04 -->
|
|
115
115
|
- A deny (hook/gate/permission) never becomes a question: pick the next safer alternative satisfying the intent, record the substitution. Never emit "do you want me to…" — decide and proceed.
|
|
116
116
|
|
|
@@ -152,7 +152,7 @@ DuraOne skill chỉ active khi pack `duraone` được cài hoặc `.claude/skil
|
|
|
152
152
|
|
|
153
153
|
## Handoff Fullstack Rules
|
|
154
154
|
<!-- RULE: HAND-02 -->
|
|
155
|
-
- `docs/AI_HANDOFF/RUN.md` là run cursor có thẩm quyền; `Phase:` ≠ `done`/`blocked` = run còn sống — Stop gate từ chối stop, trả về `Next:`. Chỉ `HANDOFF FULLSTACK COMPLETE`/`BLOCKED` kết thúc run — recap/checkpoint không phải completion; resume tự
|
|
155
|
+
- `docs/AI_HANDOFF/RUN.md` là run cursor có thẩm quyền; `Phase:` ≠ `done`/`blocked` = run còn sống — Stop gate từ chối stop, trả về `Next:`. Chỉ `HANDOFF FULLSTACK COMPLETE`/`BLOCKED` kết thúc run — recap/checkpoint không phải completion; resume chỉ tiếp tục frozen scope khi `Paused: no` và session thuộc ownership (resume marker hoặc `/ukit:handoff-fullstack` tường minh), không tự inject ở startup/clear mới. Kết thúc cycle: docs sync → archive completed-only `ARCHIVE/cycle-NN/` → `Phase: done`; `handoff-clear` khi còn việc mở đặt `Paused: yes` (giữ phase/scope/recovery, không mint completion, không xóa cursor). Full rules: `docs/AI_HANDOFF/RULES.md`.
|
|
156
156
|
|
|
157
157
|
## Compact Instructions
|
|
158
158
|
Compact giữa handoff run: giữ goal, RUN.md path + phase, task inventory + task đang làm, `Next:` step, blockers, verification evidence, commits, worktree/copy-back state, "compact không phải completion". Sau compact: đọc lại RUN.md + INDEX.md rồi chạy `Next:`.
|
|
@@ -110,7 +110,7 @@ Chỉ active khi task đi qua `docs/AI_HANDOFF/` ("execute task TASK-xxx" hoặc
|
|
|
110
110
|
<!-- RULE: UNATTENDED-02 -->
|
|
111
111
|
- `maxAttemptsPerFailure: 2`, `maxRecoveryStrategies: 2` distinct fix strategies before reassessing — retry the same edit, then fix the cause differently; never loop one strategy.
|
|
112
112
|
<!-- RULE: UNATTENDED-03 -->
|
|
113
|
-
- BLOCKED only on true external blockers: missing credentials, dead service, unreachable network/API. Test failures, lint errors, review findings, ambiguity are NOT blockers — feed FIX.
|
|
113
|
+
- BLOCKED only on true external blockers: missing credentials/permissions, dead service, unreachable network/API, or a genuine requirement conflict that needs an owner decision. Test failures, lint errors, review findings, plan findings, ambiguity within scope are NOT blockers — feed FIX (bounded strategies, then investigate/replan) and continue independent scope tasks.
|
|
114
114
|
<!-- RULE: UNATTENDED-04 -->
|
|
115
115
|
- A deny (hook/gate/permission) never becomes a question: pick the next safer alternative satisfying the intent, record the substitution. Never emit "do you want me to…" — decide and proceed.
|
|
116
116
|
|
|
@@ -144,7 +144,7 @@ DuraOne skill chỉ active khi pack `duraone` được cài hoặc `.claude/skil
|
|
|
144
144
|
|
|
145
145
|
## Handoff Fullstack Rules
|
|
146
146
|
<!-- RULE: HAND-02 -->
|
|
147
|
-
- `docs/AI_HANDOFF/RUN.md` là run cursor có thẩm quyền; `Phase:` ≠ `done`/`blocked` = run còn sống — Stop gate từ chối stop, trả về `Next:`. Chỉ `HANDOFF FULLSTACK COMPLETE`/`BLOCKED` kết thúc run — recap/checkpoint không phải completion; resume tự
|
|
147
|
+
- `docs/AI_HANDOFF/RUN.md` là run cursor có thẩm quyền; `Phase:` ≠ `done`/`blocked` = run còn sống — Stop gate từ chối stop, trả về `Next:`. Chỉ `HANDOFF FULLSTACK COMPLETE`/`BLOCKED` kết thúc run — recap/checkpoint không phải completion; resume chỉ tiếp tục frozen scope khi `Paused: no` và session thuộc ownership (resume marker hoặc `/ukit:handoff-fullstack` tường minh), không tự inject ở startup/clear mới. Kết thúc cycle: docs sync → archive completed-only `ARCHIVE/cycle-NN/` → `Phase: done`; `handoff-clear` khi còn việc mở đặt `Paused: yes` (giữ phase/scope/recovery, không mint completion, không xóa cursor). Full rules: `docs/AI_HANDOFF/RULES.md`.
|
|
148
148
|
|
|
149
149
|
## Compact Instructions
|
|
150
150
|
Compact giữa handoff run: giữ goal, RUN.md path + phase, task inventory + task đang làm, `Next:` step, blockers, verification evidence, commits, worktree/copy-back state, "compact không phải completion". Sau compact: đọc lại RUN.md + INDEX.md rồi chạy `Next:`.
|