@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.
Files changed (27) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/manifests/documentation.yaml +11 -0
  3. package/package.json +2 -2
  4. package/scripts/release/verify-release.mjs +7 -0
  5. package/scripts/test/run-handoff-isolated.mjs +178 -0
  6. package/src/core/executionContracts.js +2 -2
  7. package/template_project/.claude/agents/code-reviewer.md +4 -1
  8. package/template_project/.claude/agents/handoff-planner.md +3 -0
  9. package/template_project/.claude/commands/ukit/handoff-clear.md +34 -35
  10. package/template_project/.claude/commands/ukit/handoff-create.md +40 -21
  11. package/template_project/.claude/commands/ukit/handoff-fullstack.md +141 -66
  12. package/template_project/.claude/commands/ukit/handoff-implement.md +27 -12
  13. package/template_project/.claude/commands/ukit/handoff-review.md +18 -8
  14. package/template_project/.claude/hooks/handoff-resume.sh +8 -0
  15. package/template_project/.claude/skills/advisor-plan/REFERENCE.md +33 -2
  16. package/template_project/.claude/skills/advisor-plan/SKILL.md +6 -4
  17. package/template_project/.claude/ukit/index/route-resolver.mjs +2 -2
  18. package/template_project/.claude/ukit/runtime/resumable-run.mjs +40 -7
  19. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +128 -44
  20. package/template_project/.claude/ukit/runtime/task-watchdog.mjs +1 -1
  21. package/template_project/.omp/agents/code-reviewer.md +4 -1
  22. package/template_project/.omp/agents/handoff-planner.md +3 -0
  23. package/template_project/AGENTS.md +2 -2
  24. package/template_project/CLAUDE.md +2 -2
  25. package/template_project/docs/AI_HANDOFF/RULES.md +24 -27
  26. package/template_project/docs/AI_HANDOFF/queued/README.md +52 -0
  27. 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 precondition.
40
- 2. Every `feasible`/`adapted` row names a task and a test or acceptance check.
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>/` 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.
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
- // verified terminal INDEX rows beside the archived task files (`archive/cycle-NN/INDEX.md`),
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 the tasks that belong to `archive/cycle-<NN>/` (task files
1037
- * already moved there, or still in `tasks/` and about to move) and stamp each archived task
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
- const dir = path.join(ai, 'archive', `cycle-${nn}`);
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 { prior = indexRows(await fs.readFile(snapPath, 'utf8')); } catch { /* first run */ }
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 (/^TASK-[\w.-]+\.md$/i.test(f)) ids.add(f.replace(/\.md$/i, '').toUpperCase());
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 { /* absent dir */ }
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
- export async function pendingQueuedBlueprints(runRoot) {
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 = String(text)
871
- .split('\n')
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
- const cells = row.split('|').map((c) => c.trim()).filter(Boolean);
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
- const cells = row.split('|').slice(1, -1).map((c) => c.trim()); // keep empty cells: columns must not shift
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 base = path.join(projectRoot, 'docs', 'AI_HANDOFF', 'archive');
909
- let cycles;
910
- try {
911
- cycles = (await fs.readdir(base, { withFileTypes: true })).filter((e) => e.isDirectory() && /^cycle-\d+$/.test(e.name));
912
- } catch {
913
- return { pass: false, detail: 'INDEX.md has no task rows' };
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
- try { files = await fs.readdir(path.join(base, cycle.name)); } catch { continue; }
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
- try { snapshot = parseIndexStatuses(await fs.readFile(path.join(base, cycle.name, 'INDEX.md'), 'utf8')); } catch { /* unreadable → header fallback */ }
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(base, cycle.name, file), 'utf8'); } catch { /* unreadable → not proven */ }
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) => /^\|\s*TASK-/i.test(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()).filter(Boolean);
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, no task file, no executor report — review the document itself.
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ự động mọi task chưa xong. Kết thúc cycle: docs sync → archive `archive/cycle-NN/` → `Phase: done`; `handoff-clear` bắt buộc đóng RUN.md. Full rules: `docs/AI_HANDOFF/RULES.md`.
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ự động mọi task chưa xong. Kết thúc cycle: docs sync → archive `archive/cycle-NN/` → `Phase: done`; `handoff-clear` bắt buộc đóng RUN.md. Full rules: `docs/AI_HANDOFF/RULES.md`.
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:`.