@ngockhoale/ukit 3.4.67 → 3.4.69

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 (63) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/manifests/documentation.yaml +33 -0
  3. package/manifests/instructionRules.yaml +10 -0
  4. package/manifests/platform.full.yaml +67 -0
  5. package/package.json +1 -1
  6. package/src/core/applyPlan.js +111 -2
  7. package/src/core/buildPlan.js +3 -1
  8. package/src/core/diffPlan.js +49 -1
  9. package/src/core/executionContracts.js +49 -8
  10. package/src/core/metadata.js +45 -0
  11. package/src/core/runInstallPipeline.js +30 -2
  12. package/src/core/uninstall.js +106 -2
  13. package/src/manifest/validateManifest.js +25 -1
  14. package/template_project/.claude/agents/code-reviewer.md +22 -9
  15. package/template_project/.claude/agents/handoff-planner.md +97 -8
  16. package/template_project/.claude/commands/ukit/handoff-create.md +114 -19
  17. package/template_project/.claude/commands/ukit/handoff-fullstack.md +247 -79
  18. package/template_project/.claude/commands/ukit/handoff-implement.md +75 -40
  19. package/template_project/.claude/commands/ukit/handoff-review.md +14 -10
  20. package/template_project/.claude/hooks/auto-allow-bash.sh +73 -12
  21. package/template_project/.claude/hooks/auto-prune-bash.sh +10 -0
  22. package/template_project/.claude/hooks/delegation-gate.mjs +21 -0
  23. package/template_project/.claude/hooks/handoff-model-guard.sh +22 -6
  24. package/template_project/.claude/hooks/handoff-resume.sh +141 -1
  25. package/template_project/.claude/hooks/record-execution.mjs +140 -9
  26. package/template_project/.claude/hooks/skill-router.sh +173 -8
  27. package/template_project/.claude/settings.json +2 -2
  28. package/template_project/.claude/skills/advisor-plan/REFERENCE.md +10 -0
  29. package/template_project/.claude/skills/advisor-plan/SKILL.md +2 -2
  30. package/template_project/.claude/ukit/index/allocate-task-worktree.mjs +15 -0
  31. package/template_project/.claude/ukit/index/amend-guard.mjs +116 -0
  32. package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
  33. package/template_project/.claude/ukit/index/land-worktree.mjs +243 -21
  34. package/template_project/.claude/ukit/index/plan-count.mjs +115 -0
  35. package/template_project/.claude/ukit/index/ready-gate.mjs +170 -0
  36. package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +293 -4
  37. package/template_project/.claude/ukit/index/route-resolver.mjs +48 -8
  38. package/template_project/.claude/ukit/index/route-task.mjs +180 -11
  39. package/template_project/.claude/ukit/index/source-snapshot.mjs +172 -0
  40. package/template_project/.claude/ukit/index/task-budget-validator.mjs +109 -3
  41. package/template_project/.claude/ukit/runtime/async-lock.mjs +3 -3
  42. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +20 -3
  43. package/template_project/.claude/ukit/runtime/handoff-task-scope.mjs +205 -0
  44. package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
  45. package/template_project/.claude/ukit/runtime/plan-dispatch.mjs +32 -5
  46. package/template_project/.claude/ukit/runtime/resumable-run.mjs +310 -21
  47. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +405 -105
  48. package/template_project/.claude/ukit/runtime/task-record-reconciliation.mjs +46 -0
  49. package/template_project/.claude/ukit/runtime/task-watchdog.mjs +28 -2
  50. package/template_project/.claude/ukit/runtime/worktree-allocation.mjs +75 -0
  51. package/template_project/.codex/README.md +25 -0
  52. package/template_project/.omp/RULES.md +22 -22
  53. package/template_project/.omp/agents/code-reviewer.md +22 -9
  54. package/template_project/.omp/agents/handoff-planner.md +102 -12
  55. package/template_project/.omp/hooks/pre/ukit-bridge.js +1 -1
  56. package/template_project/AGENTS.md +5 -1
  57. package/template_project/CLAUDE.md +1 -1
  58. package/template_project/docs/AI_HANDOFF/RULES.md +8 -0
  59. package/template_project/docs/AI_HANDOFF/tasks/_TEMPLATE.md +8 -1
  60. package/template_project/instructions/core.md +5 -1
  61. package/template_project/instructions/layout.yaml +2 -0
  62. package/template_project/instructions/overlays/omp-rules.md +22 -22
  63. package/template_project/ukit/storage/config.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 3.4.69 - 2026-10-25
6
+
7
+ **Narrow shared-lock cancellation repair; no new unattended-Handoff certification.**
8
+
9
+ - Fix: cancellation after a successful lock owner stamp now passes through token-checked, best-effort release before returning `aborted`; the callback remains unexecuted. Deterministic regression verifies lock absence and immediate reacquisition.
10
+ - Tests: managed Handoff template cases pin owner-byte preservation, exact sidecar bytes/provenance and no-write aligned behavior. Preflight fixtures now use an independent temporary Git repository with ownership-checked cleanup, avoiding default dirty-worktree/sandbox accumulation without touching retained evidence.
11
+ - Verification: independent original and cleanup-only reviews approved with minor findings; full release verifier passed (core 10,741 passed / three skipped, isolated handoff 351/351, artifact 39/39). Cleanup review retains a non-blocking concurrency-sensitive source-inventory assertion and fail-loud fixture cleanup caveats.
12
+ - Limits unchanged: native isolation dispatch, clean unattended lifecycle/native-host acceptance, historical `ukitcmd25` loss and unidentified `79/1` remain unresolved. This fix is not established as the cause of those incidents. No 3.4.68 live-gate waiver is inherited or live acceptance claimed.
13
+
14
+ ## 3.4.68 - 2026-10-25
15
+
16
+ **Stable hardening release; outstanding live acceptance explicitly deferred by the owner for the internal six-person team. This is not certification of unattended Handoff.**
17
+
18
+ - Planning: durable source snapshots, recorded validator/independent-review ready gates, amendment/plan-count/overwrite controls, and realpath-safe installed helper entrypoints.
19
+ - Runtime: authenticated asynchronous delegation completion, bounded recovery and claim fencing; landing coordinated with claims; explicit already-applied adoption and non-completing metadata reconciliation; completed-only archive/resume safeguards and three-engine adapter consistency.
20
+ - Owner preservation: managed Handoff templates refresh only with provenance, retain custom content/sidecars safely through update and uninstall; prevent shell-keyword/state-builtin permission minting, with provenance-only sanitizer.
21
+ - Classification/continuation: read-only output specifications and scheduled permission reminders no longer manufacture implementation work; real edit orders retain evidence obligations.
22
+ - Safety: existing-only delegation guard also honors restrictive runner policy without a RUN marker. Planning instructions omit isolation, but **actual native dispatch is not fixed**: the latest create10 failed at its first isolation-bearing Agent call (0/10 accepted, nine skipped). The guard remains; no payload stripping, trust/permission expansion or provider workaround.
23
+ - Known limits retained: clean unattended multi-plan execution, review-return/replan, blocked-independent scheduling, wait/no-duplicate, interruption/cycle resume, late-create frozen scope, pause/compact, current-candidate OMP lifecycle and missing native-host/platform coverage are not certified. Historical unidentified `79/1` and lost `ukitcmd25` (29/30) remain unexplained; later passing checks do not resolve them. Inspection-command friction also remains.
24
+ - Evidence: reviewed repair groups; full code-candidate suite 673 files / 10,771 tests passed, three skipped. Recovered roadmap 8/8, integration 61/61, completed-only archive and unchanged reinvocation remain recovery evidence, not a clean unattended PASS. The owner explicitly waived these outstanding live gates as release blockers, not technical/package/registry-consumer checks. No FAIL/BLOCKED evidence is relabeled PASS.
25
+
5
26
  ## 3.4.67 - 2026-10-23
6
27
 
7
28
  - Fix: a wait/monitor/hold-only request ("keep waiting on the verifier job, read output to the footer, do not edit while the candidate is frozen") is now classified verification-only (`READ_ONLY`, `verification-evidence`) instead of the `CODE_IMPLEMENTATION` default that demanded `write-evidence` and forced an Edit/Write. A wait that also carries a real order ("wait, then fix src/app.js") stays an implementation. Vietnamese "rồi" now splits clauses for this check. Both classifier copies (`src/core/executionContracts.js`, installed `route-resolver.mjs`) changed together; regression in `tests/hooks/gateIntentClassification.test.js`.
@@ -517,6 +517,17 @@ entries:
517
517
  validation: [none]
518
518
  archive_policy: immutable
519
519
  notes: 2026-10-09 handoff root-cause audit (create-scope authority + Stop verification semantics)
520
+ - id: docs-analysis-handoff-post-3467-trace
521
+ path: docs/analysis/HANDOFF-POST-3467-TRACE.md
522
+ class: derived
523
+ audience: [maintainer]
524
+ owner: runtime
525
+ source_of_truth: template_project/.claude/ukit/runtime/execution-ledger.mjs
526
+ merge_strategy: none
527
+ load_policy: on-demand
528
+ validation: [none]
529
+ archive_policy: date-rotate
530
+ notes: Field trace and per-host evidence for narrow post-3.4.67 task-binding and claim hardening
520
531
  - id: docs-analysis-handoff-remediation-plan
521
532
  path: docs/analysis/HANDOFF-REMEDIATION-PLAN.md
522
533
  class: archive
@@ -627,6 +638,28 @@ entries:
627
638
  validation: [manual]
628
639
  archive_policy: never
629
640
  notes: Current owner scope, hardening evidence and remaining release obligations; not runtime state
641
+ - id: docs-analysis-handoff-goal-coverage
642
+ path: docs/analysis/HANDOFF-GOAL-COVERAGE.md
643
+ class: canonical
644
+ audience: [maintainer]
645
+ owner: product
646
+ source_of_truth: docs/analysis/HANDOFF-GOAL-COVERAGE.md
647
+ merge_strategy: none
648
+ load_policy: on-demand
649
+ validation: [manual]
650
+ archive_policy: never
651
+ notes: Independent requirement oracle vs current handoff state; nine coverage rows, per-host termination outcomes T1..T11, mutation-proofed coverage checker
652
+ - id: docs-analysis-handoff-write-boundary
653
+ path: docs/analysis/HANDOFF-WRITE-BOUNDARY.md
654
+ class: canonical
655
+ audience: [maintainer]
656
+ owner: product
657
+ source_of_truth: docs/analysis/HANDOFF-WRITE-BOUNDARY.md
658
+ merge_strategy: none
659
+ load_policy: on-demand
660
+ validation: [manual]
661
+ archive_policy: never
662
+ notes: Native planning Write permission evidence, authorized configuration provenance and unresolved live acceptance boundaries
630
663
  - id: docs-analysis-orchestration-final-verification
631
664
  path: docs/analysis/ORCHESTRATION-FINAL-VERIFICATION.md
632
665
  class: archive
@@ -349,6 +349,16 @@ rules:
349
349
  marker_in: [template_project/CLAUDE.md, template_project/AGENTS.md, CLAUDE.md, AGENTS.md]
350
350
  anchor: 'RUN.md'
351
351
  paths: [docs/AI_HANDOFF/**]
352
+ - id: HAND-03
353
+ title: Handoff planning gates are mechanical and read from disk
354
+ kind: supporting
355
+ statement: >-
356
+ Tasks flip needs_breakdown to ready only on a recorded validator VERDICT ok
357
+ plus PLAN_REVIEW Approved; ready-gate.mjs must print READY_GATE ok before
358
+ any flip; owner docs persist through source-snapshot.mjs; work lands
359
+ through land-worktree.mjs; rewrites need an amendments/rev copy
360
+ (amend-guard.mjs) — all bare single node commands.
361
+ marker_in: [template_project/AGENTS.md, AGENTS.md]
352
362
  - id: BUDGET-01
353
363
  title: Context budget per task class
354
364
  kind: critical
@@ -171,6 +171,9 @@ items:
171
171
  type: config
172
172
  sourceTemplate: docs/AI_HANDOFF
173
173
  targetPath: docs/AI_HANDOFF
174
+ managedChildStrategies:
175
+ RULES.md: managed_template
176
+ queued/README.md: managed_template
174
177
  requires:
175
178
  - docs-project
176
179
  - docs-memory
@@ -2096,6 +2099,70 @@ items:
2096
2099
  packs:
2097
2100
  - core
2098
2101
 
2102
+ # Roadmap-create field regression (2026-10-24): create runs recorded no SOURCE.md /
2103
+ # sha256. The planner docs call this snapshot helper as ONE simple command — same
2104
+ # manifest rule as the validator above, or autoDiscoverTemplates:false skips it and
2105
+ # the documented command 404s on installs.
2106
+ - id: ukit-index-source-snapshot-script
2107
+ type: config
2108
+ sourceTemplate: .claude/ukit/index/source-snapshot.mjs
2109
+ targetPath: .claude/ukit/index/source-snapshot.mjs
2110
+ requires: []
2111
+ mergeStrategy: overwrite_with_backup
2112
+ variables: []
2113
+ enabledByDefault: true
2114
+ packs:
2115
+ - core
2116
+
2117
+ # CR-06: the flip-to-ready gate reads both readiness gates from disk. The create /
2118
+ # fullstack / planner docs invoke it before every flip — same manifest rule as the
2119
+ # validator above, or the documented command 404s on installs.
2120
+ - id: ukit-index-allocate-task-worktree-script
2121
+ type: config
2122
+ sourceTemplate: .claude/ukit/index/allocate-task-worktree.mjs
2123
+ targetPath: .claude/ukit/index/allocate-task-worktree.mjs
2124
+ requires: []
2125
+ mergeStrategy: overwrite_with_backup
2126
+ variables: []
2127
+ enabledByDefault: true
2128
+ packs:
2129
+ - core
2130
+
2131
+ - id: ukit-index-ready-gate-script
2132
+ type: config
2133
+ sourceTemplate: .claude/ukit/index/ready-gate.mjs
2134
+ targetPath: .claude/ukit/index/ready-gate.mjs
2135
+ requires: []
2136
+ mergeStrategy: overwrite_with_backup
2137
+ variables: []
2138
+ enabledByDefault: true
2139
+ packs:
2140
+ - core
2141
+
2142
+ # CR-10: the amendments/rev copy check that must run before PLAN/SPEC/LEDGER rewrites.
2143
+ - id: ukit-index-amend-guard-script
2144
+ type: config
2145
+ sourceTemplate: .claude/ukit/index/amend-guard.mjs
2146
+ targetPath: .claude/ukit/index/amend-guard.mjs
2147
+ requires: []
2148
+ mergeStrategy: overwrite_with_backup
2149
+ variables: []
2150
+ enabledByDefault: true
2151
+ packs:
2152
+ - core
2153
+
2154
+ # CR-11: verifies an owner-stated plan count is delivered as that many real plan units.
2155
+ - id: ukit-index-plan-count-script
2156
+ type: config
2157
+ sourceTemplate: .claude/ukit/index/plan-count.mjs
2158
+ targetPath: .claude/ukit/index/plan-count.mjs
2159
+ requires: []
2160
+ mergeStrategy: overwrite_with_backup
2161
+ variables: []
2162
+ enabledByDefault: true
2163
+ packs:
2164
+ - core
2165
+
2099
2166
  # Mechanic #10 (TASK-004): both scripts ship in template_project/ and must be
2100
2167
  # manifest-listed or autoDiscoverTemplates:false skips them and the documented
2101
2168
  # `node .claude/ukit/index/handoff-doc-validator.mjs` gate 404s on installs —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.4.67",
3
+ "version": "3.4.69",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,7 +1,32 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
3
4
  import { writeFileAtomic, writeFileExclusive, copyFileSafe, createDirectoryLink, removeLinkOnly } from './fileOps.js';
4
5
 
6
+ // CR-13 provenance: a `<file>.ukit-new` sidecar is only UKit's when install.json
7
+ // records its sha256 (metadata.sidecars). The filename alone proves nothing — an
8
+ // owner-authored file at that path must never be overwritten or removed.
9
+ const sha256Hex = (buf) => createHash('sha256').update(buf).digest('hex');
10
+
11
+ // lstat-first: a symlink/non-regular sidecar is never read through or replaced.
12
+ async function readSidecarState(sidecarPath) {
13
+ let stat;
14
+ try {
15
+ stat = await fs.lstat(sidecarPath);
16
+ } catch (error) {
17
+ return error?.code === 'ENOENT' ? { state: 'absent' } : { state: 'unreadable', error };
18
+ }
19
+ if (stat.isSymbolicLink() || !stat.isFile()) {
20
+ return { state: 'non-regular' };
21
+ }
22
+ try {
23
+ const bytes = await fs.readFile(sidecarPath);
24
+ return { state: 'file', hash: sha256Hex(bytes) };
25
+ } catch (error) {
26
+ return { state: 'unreadable', error };
27
+ }
28
+ }
29
+
5
30
  // legacy: '.antigravity/' stays TCC-protected — Antigravity adapter removed in v2.2.0, but a
6
31
  // stale install may still have the dir and must not lose TCC protection.
7
32
  const TCC_PROTECTED_PREFIXES = ['.codex/', '.antigravity/', '.omp/'];
@@ -80,12 +105,96 @@ export async function pruneOldBackups(backupRoot, {
80
105
  return { scanned: files.length, removed };
81
106
  }
82
107
 
83
- export async function applyDiffResults(diffResults, { backupRoot, projectRoot } = {}) {
108
+ // Writes the new template beside an owner-edited managed file. Returns the recorded
109
+ // sidecar hash when UKit created/refreshed it, or null when it was preserved as owner
110
+ // content. Absent sidecar → exclusive create (race-safe: an appearing owner file is
111
+ // never overwritten). Existing sidecar → update ONLY when install.json recorded its
112
+ // current hash; otherwise preserve and report a conflict.
113
+ async function writeManagedTemplateSidecar(sidecarPath, content, recordedSidecarHash) {
114
+ const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8');
115
+ const nextHash = sha256Hex(bytes);
116
+ const state = await readSidecarState(sidecarPath);
117
+
118
+ if (state.state === 'absent') {
119
+ try {
120
+ await writeFileExclusive(sidecarPath, content);
121
+ return nextHash;
122
+ } catch (error) {
123
+ if (error?.code === 'EEXIST') {
124
+ // Lost the race to an owner file — never overwrite it.
125
+ console.warn(`[UKit] Warning: ${sidecarPath} appeared during install; left untouched (possible owner content).`);
126
+ return null;
127
+ }
128
+ throw error;
129
+ }
130
+ }
131
+ if (state.state === 'file' && typeof recordedSidecarHash === 'string' && recordedSidecarHash === state.hash) {
132
+ if (state.hash === nextHash) {
133
+ return nextHash; // already the current template — nothing to write
134
+ }
135
+ // Re-verify immediately before the rename: an owner edit landing between the
136
+ // first read and this write must not be silently replaced (TOCTOU window).
137
+ const recheck = await readSidecarState(sidecarPath);
138
+ if (recheck.state !== 'file' || recheck.hash !== recordedSidecarHash) {
139
+ console.warn(`[UKit] Warning: ${sidecarPath} changed during install; left untouched (possible owner content).`);
140
+ return null;
141
+ }
142
+ await writeFileAtomic(sidecarPath, content);
143
+ return nextHash;
144
+ }
145
+ // Owner-authored, owner-modified, symlink, or unreadable sidecar: keep it.
146
+ console.warn(
147
+ `[UKit] Warning: ${sidecarPath} is not UKit-managed (${state.state === 'file' ? 'content changed since UKit wrote it' : state.state}); preserved. Review it manually.`,
148
+ );
149
+ return null;
150
+ }
151
+
152
+ // Removes a sidecar only when install.json recorded the hash that is on disk now.
153
+ // Unrecorded/owner-modified/symlink sidecars are preserved and reported.
154
+ async function removeManagedTemplateSidecar(sidecarPath, recordedSidecarHash) {
155
+ const state = await readSidecarState(sidecarPath);
156
+ if (state.state === 'absent') {
157
+ return true;
158
+ }
159
+ if (state.state === 'file' && typeof recordedSidecarHash === 'string' && recordedSidecarHash === state.hash) {
160
+ await fs.rm(sidecarPath, { force: true });
161
+ return true;
162
+ }
163
+ console.warn(
164
+ `[UKit] Warning: keeping ${sidecarPath} — ${state.state === 'file' ? 'its content no longer matches the UKit-written copy' : `it is ${state.state}`}; remove it manually if it is no longer needed.`,
165
+ );
166
+ return false;
167
+ }
168
+
169
+ export async function applyDiffResults(diffResults, { backupRoot, projectRoot, recordedSidecarHashes = null, sidecarHashes = {} } = {}) {
84
170
  const writes = [];
85
171
  let skippedUpdates = 0;
86
172
  const skippedByAction = { create: 0, update: 0 };
87
173
 
88
174
  for (const entry of diffResults) {
175
+ // CR-13: owner-edited managed template — keep the file, write the new template beside it.
176
+ // Provenance is the recorded sidecar hash, never the filename (see writeManagedTemplateSidecar).
177
+ if (entry.action === 'sidecar') {
178
+ const sidecarPath = `${entry.targetPath}.ukit-new`;
179
+ const recorded = recordedSidecarHashes?.get(sidecarPath) ?? null;
180
+ const writtenHash = await writeManagedTemplateSidecar(sidecarPath, entry.renderedContent, recorded);
181
+ if (writtenHash) {
182
+ sidecarHashes[sidecarPath] = writtenHash;
183
+ console.log(`[UKit] Kept your edited ${path.basename(entry.targetPath)} unchanged; the new UKit template is at ${path.basename(sidecarPath)} — review and merge it.`);
184
+ } else {
185
+ console.log(`[UKit] Kept your edited ${path.basename(entry.targetPath)} unchanged; an owner file already exists at ${path.basename(sidecarPath)} and was preserved.`);
186
+ }
187
+ skippedUpdates += 1;
188
+ skippedByAction.update += 1;
189
+ continue;
190
+ }
191
+ if (entry.action === 'unchanged' && entry.removeSidecar) {
192
+ // Only a UKit-generated, unmodified sidecar is cleaned up; owner files stay.
193
+ await removeManagedTemplateSidecar(
194
+ `${entry.targetPath}.ukit-new`,
195
+ recordedSidecarHashes?.get(`${entry.targetPath}.ukit-new`) ?? null,
196
+ );
197
+ }
89
198
  if (entry.action !== 'create' && entry.action !== 'update') {
90
199
  continue;
91
200
  }
@@ -224,5 +333,5 @@ export async function applyDiffResults(diffResults, { backupRoot, projectRoot }
224
333
  });
225
334
  }
226
335
 
227
- return { writes, skippedUpdates, skippedByAction };
336
+ return { writes, skippedUpdates, skippedByAction, sidecarHashes };
228
337
  }
@@ -231,8 +231,10 @@ export async function buildInstallPlan({
231
231
  const targetPath = safeResolve(projectRoot, path.join(item.targetPath, relativeChildPath));
232
232
  manifestSourcePaths.add(nestedSourcePath);
233
233
  explicitTargetPaths.add(targetPath);
234
+ const childKey = relativeChildPath.split(path.sep).join('/');
234
235
  fileItems.push({
235
236
  item,
237
+ mergeStrategy: item.managedChildStrategies?.[childKey],
236
238
  sourcePath: nestedSourcePath,
237
239
  targetPath,
238
240
  sourceMode: nestedStats[index].mode & 0o777,
@@ -270,7 +272,7 @@ export async function buildInstallPlan({
270
272
  type: item.type,
271
273
  sourcePath,
272
274
  targetPath,
273
- mergeStrategy: item.mergeStrategy,
275
+ mergeStrategy: fileItems[i].mergeStrategy ?? item.mergeStrategy,
274
276
  renderedContent,
275
277
  requires: item.requires ?? [],
276
278
  mode: fileItems[i].sourceMode,
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import fs from 'node:fs/promises';
2
3
  import YAML from 'yaml';
3
4
  import { isSymlinkTo } from './fileOps.js';
@@ -14,6 +15,49 @@ const GATEWAY_RESILIENCE_KEYS = new Set(Object.keys(GATEWAY_RESILIENCE_ENV_DEFAU
14
15
  // look identical to "missing" and the apply step would either overwrite the
15
16
  // live file atomically with no .bak (W2-C4) or crash mid-install on a 'wx'
16
17
  // create (W2-C6). Unreadable targets get a non-write action instead.
18
+ // CR-13: managed AI_HANDOFF templates. Refresh only when the installed file is still the
19
+ // content UKit last recorded (its sha256 in install.json); an owner-edited or legacy
20
+ // (no recorded hash) file stays byte-identical and the new template goes to a sidecar.
21
+ const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
22
+
23
+ async function resolveManagedTemplateAction(entry, recordedHashes) {
24
+ const template = Buffer.isBuffer(entry.renderedContent) ? entry.renderedContent : Buffer.from(entry.renderedContent, 'utf8');
25
+ const templateHash = sha256(template);
26
+
27
+ // A symlink or non-regular target is owner content, never a managed copy we may
28
+ // replace: following it would read/write outside the project. lstat first so a
29
+ // symlink to a readable file is not mistaken for a normal managed file.
30
+ let lstat = null;
31
+ try {
32
+ lstat = await fs.lstat(entry.targetPath);
33
+ } catch (error) {
34
+ if (error?.code !== 'ENOENT') {
35
+ return conflictResult(entry, error); // unreadable parent etc. — never treat as absent
36
+ }
37
+ }
38
+ if (lstat && (lstat.isSymbolicLink() || !lstat.isFile())) {
39
+ return conflictResult(entry, { code: lstat.isSymbolicLink() ? 'ESYMLINK' : 'ENOTFILE' });
40
+ }
41
+
42
+ const read = await readFileOrStatus(entry.targetPath, null);
43
+ if (read.status === 'missing') {
44
+ // ONLY a confirmed absence permits create; unreadable must never overwrite.
45
+ return { ...entry, exists: false, action: 'create', existingContent: null, managedHash: templateHash };
46
+ }
47
+ if (read.status !== 'ok') {
48
+ return conflictResult(entry, read.error);
49
+ }
50
+ const installed = read.content;
51
+ const installedHash = sha256(installed);
52
+ if (installedHash === templateHash) {
53
+ return { ...entry, exists: true, action: 'unchanged', existingContent: installed, managedHash: templateHash, removeSidecar: true };
54
+ }
55
+ if (recordedHashes?.get(entry.targetPath) === installedHash) {
56
+ return { ...entry, exists: true, action: 'update', existingContent: installed, managedHash: templateHash, removeSidecar: true };
57
+ }
58
+ return { ...entry, exists: true, action: 'sidecar', existingContent: installed, managedHash: null };
59
+ }
60
+
17
61
  async function readFileOrStatus(filePath, encoding = 'utf8') {
18
62
  try {
19
63
  if (encoding === null) {
@@ -358,7 +402,7 @@ async function resolveSkipEntryAction(entry, ompOwnership = null) {
358
402
  return resolveFileAction(entry, read.content, ompOwnership);
359
403
  }
360
404
 
361
- export async function diffInstallPlan(plan, { ompOwnership = null, settingsMigration = null } = {}) {
405
+ export async function diffInstallPlan(plan, { ompOwnership = null, settingsMigration = null, recordedManagedHashes = null } = {}) {
362
406
  // Check all entries in parallel
363
407
  const results = await Promise.all(
364
408
  plan.entries.map(async (entry) => {
@@ -371,6 +415,10 @@ export async function diffInstallPlan(plan, { ompOwnership = null, settingsMigra
371
415
  return resolveSkipEntryAction(entry, ompOwnership);
372
416
  }
373
417
 
418
+ if (entry.mergeStrategy === 'managed_template') {
419
+ return resolveManagedTemplateAction(entry, recordedManagedHashes);
420
+ }
421
+
374
422
  const read = await readFileOrStatus(
375
423
  entry.targetPath,
376
424
  Buffer.isBuffer(entry.renderedContent) ? null : 'utf8',
@@ -207,10 +207,16 @@ const VERIFICATION_GOAL = /(?:^|\n)[ \t*#_-]*(?:muc tieu|goal|purpose|objective)
207
207
  const CONDITIONAL_BRANCH = /(?:^|\n|\.\s+)[ \t]*(?:\d+[.)]\s*)?(?:neu\s+(?:phat hien\s+(?:false-block|loi|bug|defect)|co\s+(?:source fix|sua doi))|if\s+(?:(?:a\s+)?(?:bug|defect|failure)\s+is\s+found|(?:you\s+)?find\s+(?:a\s+)?(?:bug|defect)))\b[^\n]*?(?=\.\s+|$|\n)/g;
208
208
  const GOAL_LABEL = /\b(?:muc tieu|goal|purpose|objective)\s*:/g;
209
209
  const DISPOSABLE_SETUP = /\b(?:tao|create)\s+(?:mot\s+)?(?:project|du an)\s+(?:thu nghiem|test|disposable|doc lap)\b(?:\s+(?:doc lap|independent))?/g;
210
+ // Round-2b (FS-01 B5): a bare "no <verb>, <verb>, ..." enumeration names operations that are NOT
211
+ // ordered ("no commit, release, publish"). The clause splitter cuts at commas, so without this the
212
+ // tails ("release", "publish") would look like separate imperative clauses to the request-wide order
213
+ // guard. Only a "no"-headed verb list is consumed; a real order never starts with "no". Built lazily
214
+ // because ORDER_VERBS is declared below this function.
215
+ const noVerbList = () => new RegExp(`\\bno\\s+(?:${ORDER_VERBS})\\b(?:\\s*,\\s*(?:${ORDER_VERBS})\\b)*`, 'g');
210
216
  function neutralizeNonOrders(text) {
211
217
  let source = String(text).replace(/```[\s\S]*?```/g, ' ').replace(/^[ \t]*>.*$/gm, ' ');
212
218
  if (VERIFICATION_GOAL.test(source)) source = source.replace(GOAL_LABEL, ' ').replace(CONDITIONAL_BRANCH, '\n').replace(DISPOSABLE_SETUP, ' ');
213
- return source
219
+ return source.replace(noVerbList(), ' ')
214
220
  // A list item that DESCRIBES test cases ("Negative controls: yêu cầu thực sự chỉnh config/restart/deploy ...") is
215
221
  // specification of the work, not an order; it runs to the next bullet or blank line.
216
222
  .replace(/^[ \t]*(?:[-*+\u2022]|\d+[.)])[ \t]*(?:negative|positive)\s+controls?\b[^\n]*(?:\n(?![ \t]*(?:[-*+\u2022]|\d+[.)])|[ \t]*\n)[^\n]*)*/gm, ' ')
@@ -225,8 +231,26 @@ function neutralizeNonOrders(text) {
225
231
  .replace(/[^.;\n]*\b(?:nao|gi|sao|the nao|bao nhieu)\b[^.;\n]*\?/g, ' ')
226
232
  .replace(/\bchinh\s+(?:xac|sach)\b|\b(?:phuong an|cach|de xuat|ban)\s+(?:sua|fix)\b|\b(?:diem|cho|muc|phan)\s+(?:phai|can|nen)\s+(?:sua|fix)\b/g, ' ')
227
233
  .replace(/\bwrite\s*\/\s*verification\b|\bverification\s*\/\s*write\b|\bwrite\s+(?:or|and)\s+verification\b/g, ' ')
234
+ .replace(/\bwrite\s+permission\b/g, 'permission')
228
235
  .replace(/\b(?:state|status)\s+changes?\b/g, ' ')
229
- .replace(/\b(?:proposed|suggested|recommended|minimal|each|the|an?|any|that|this|no|which|exact|smallest|possible)\s+fix(?:es)?\b/g, ' ');
236
+ .replace(/\b(?:proposed|suggested|recommended|minimal|each|the|an?|any|that|this|no|which|exact|smallest|possible)\s+fix(?:es)?\b/g, ' ')
237
+ // An output/deliverable spec ("Output: fix direction", "each: title, fix direction", "Output:
238
+ // fixes / fix list / acceptance to close") NAMES the artifacts the answer must contain; inside
239
+ // such a clause "fix" is a deliverable noun, not a mutation order, so a read-only audit with a
240
+ // fix-shaped deliverable list must not owe write evidence (FS-07, src twin of the FS-01 fix in
241
+ // route-resolver.mjs). A determiner object inside the span ("Output: fix the bug in parser.ts")
242
+ // stays a real order - fail closed.
243
+ // Round-2 (FS-01 B1-B4, src twin): a span runs through a file-extension dot ("fix items.ts"), a bare
244
+ // singular "fix" is an order (never a noun), and a span keeps its order unless EVERY fix token in it is a
245
+ // deliverable noun and no order verb / "then" remains in the clause ("fix list, commit and push").
246
+ .replace(/(?:^|[\n.;])[ \t]*(?:[-*+•]|\d+[.)])?[ \t]*(?:outputs?|reports?|deliverables?|each|lists?|includes?|fields?|format|sections?|columns?)(?:\s+\w+){0,3}?\s*:(?:[^\n.;]|\.(?=\w))*/g,
247
+ (span) => {
248
+ const fixNouns = /\bfix(?:es)?(?:\s+applied\b|\s+(?:directions?|lists?|plans?|suggestions?|proposals?|items?|entries?|notes?|findings?|acceptance|recommendations?)\b)(?=\s*(?:,|$))|\bfixes\b(?=\s*(?:,|$))/g;
249
+ const residual = span.replace(fixNouns, ' ');
250
+ if (residual === span) return span;
251
+ if (new RegExp(`\\b(?:then|${NON_ORDER_VERBS}|${ORDER_VERBS})\\b`).test(residual)) return span;
252
+ return residual;
253
+ });
230
254
  }
231
255
 
232
256
  // Explicit scope declaration (intent ownership): when the request itself declares it is read-only
@@ -236,13 +260,25 @@ function neutralizeNonOrders(text) {
236
260
  // An explicit order (imperative-initial or modal request) keeps the request MIXED/IMPLEMENTATION:
237
261
  // the declaration never overrides a real instruction, and fenced/quoted/blockquoted text is never one.
238
262
  // A bare verb with no object is the tail of a negated list ("no commit, release, publish"), not an order.
239
- const READ_ONLY_DECLARATION = /\bread[\s-]*only\s+(?:audit|investigation|review|analysis|inspection|task|request|session|pass|check|diagnos\w+|verification|forensics?)\b|(?:^|\n)[\s*#>_-]*read[\s-]*only\s*[.:!—-]|\b(?:audit|investigation|review|analysis)[\s-]*only\b|[(,]\s*read[\s-]*only\s*[).]|\bchi\s+(?:doc|audit|kiem tra|phan tich|dieu tra)\b|\b(?:make|with)\s+no\s+(?:source\s+|code\s+|file\s+)?(?:changes|edits|modifications)\b/;
263
+ const READ_ONLY_DECLARATION = /\bread[\s-]*only(?:\s+\w+){0,3}?\s+(?:audit|investigation|review|analysis|inspection|inventory|task|request|session|pass|check|diagnos\w+|verification|forensics?|reports?)\b|(?:^|\n)[\s*#>_-]*read[\s-]*only\s*[.:!—-]|\b(?:audit|investigation|review|analysis|inventory)[\s-]*only\b|[(,]\s*read[\s-]*only\s*[).]|\bchi\s+(?:doc|audit|kiem tra|kiem ke|phan tich|dieu tra)\b|\bkhong\s+sua\b|\b(?:make|with)\s+no\s+(?:source\s+|code\s+|file\s+)?(?:changes|edits|modifications)\b|\b(?:do\s+not|don't|dont|never)\s+(?:\w+\s+){0,2}edits?\b/;
240
264
  const ORDER_VERBS = 'implement|fix|apply|refactor|modify|edit|write|update|add|create|build|install|deploy|redeploy|restart|push|commit|rename|remove|delete|replace|change|sua|viet|chinh|them|tao|xoa|enable|disable|set|toggle|configure|migrate|upgrade|publish|release|trien khai|khoi dong lai|(?:run|execute)\\s+(?:git\\s+(?:push|clean|commit|reset|rebase|merge|tag|stash|checkout|restore|cherry-pick)|(?:npm|yarn)\\s+publish)|git\\s+(?:push|clean|commit|reset|rebase|merge|tag|stash|checkout|restore|cherry-pick)';
241
265
  const IMPERATIVE_ORDER = new RegExp(`^(?:[-*+\\u2022>\\d.)\\s]*)(?:(?:please|hay|vui long|now|also|first|then)\\s+)*(?:${ORDER_VERBS})\\b\\s+\\S`);
242
266
  const MODAL_ORDER = new RegExp(`\\b(?:please|hay|vui long|need to|needs to|must|should|want(?:\\s+you)? to|toi muon|can phai|yeu cau)\\s+(?:[\\w-]+\\s+){0,2}(?:${ORDER_VERBS})\\b`);
243
267
  function hasExplicitOrder(clauses) {
244
268
  return clauses.some((clause) => IMPERATIVE_ORDER.test(clause) || MODAL_ORDER.test(clause));
245
269
  }
270
+ // Round-2b (FS-01 B5): a clause AFTER the output span still orders a change ("Output: fix lists\ncommit
271
+ // the changes", "Output: fix lists; commit"). Order detection must be request-wide, not span-local: any
272
+ // non-negated clause whose command position is an ORDER verb - with or without an object, so a bare
273
+ // trailing "commit" counts - is an explicit order. The vocabulary is the order class the read-only
274
+ // declaration must never absorb (git/publish/release/apply/run-implementation); noun-ish scaffolding
275
+ // ("tao timeline ...", "create a list") is NOT an order and must not defeat a read-only audit.
276
+ // Negated lists ("no commit, release, publish", "Khong npm publish, git push, release") are blanked
277
+ // first by neutralizeNonOrders, so their bare tails never read as orders.
278
+ const CLAUSE_ORDER_LEAD = /^(?:[-*+•>\d.)\s]*)(?:(?:please|hay|vui long|now|also|first|then)\s+)*(?:commit|push(?!\s+back\b)|publish|release(?!\s+(?:notes?|process|works?|plan|candidate|checklist)\b)|apply|(?:run|execute)\s+(?:the\s+)?(?:implementation|build|migration|deploy(?:ment)?|publish|release|script|command))\b/;
279
+ function ordersChangeAcrossClauses(clauses) {
280
+ return clauses.some((clause) => CLAUSE_ORDER_LEAD.test(clause));
281
+ }
246
282
  function declaredReadOnly(promptText, releasePolicy) {
247
283
  if (releasePolicy) return false;
248
284
  const plain = String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd').toLowerCase()
@@ -251,9 +287,10 @@ function declaredReadOnly(promptText, releasePolicy) {
251
287
  const text = neutralizeNonOrders(plain);
252
288
  const clauses = text.split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/)
253
289
  .map((clause) => clause.split(/\bwithout\b/)[0])
254
- .filter((clause) => !/\b(?:do not|don't|never|khong|dung)\b/.test(clause));
255
- return /\b(?:audit|inspect|review|analyze|analyse|explain|read|investigate|investigation|forensic|root cause|kiem tra|phan tich|dieu tra|doc log|tim hieu)\b/.test(text)
256
- && !hasExplicitOrder(clauses);
290
+ .filter((clause) => !/\b(?:do not|don't|never|khong)\b|^[\s*+•>\d.)-]*(?:(?:vui long|xin|hay|please)\s+)*dung\b/.test(clause));
291
+ return /\b(?:audit|inspect|review|analyze|analyse|explain|read|investigate|investigation|inventory|forensic|root cause|kiem tra|kiem ke|phan tich|dieu tra|doc log|tim hieu)\b/.test(text)
292
+ && !hasExplicitOrder(clauses)
293
+ && !ordersChangeAcrossClauses(clauses);
257
294
  }
258
295
 
259
296
  // Verification-only request. Root cause of a false `write-evidence` block on a "verify / re-run /
@@ -297,7 +334,8 @@ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {})
297
334
  ? { kind: 'conditional-release', preflight } : null;
298
335
  const text = neutralizeBannedLists(neutralizeNonOrders(String(promptText).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd').toLowerCase()));
299
336
  const clauses = text.split(/\s*(?:[;\n,]|\.(?:\s+|$)|\band\b|\bthen\b|\bva\b)\s*/);
300
- const actionable = clauses.map((clause) => clause.split(/\bwithout\b/)[0]).filter((clause) => !/\b(?:do not|don't|never|khong|dung)\b/.test(clause)
337
+ // Folded đúng (correctly) is also "dung"; only command-position đừng negates an order.
338
+ const actionable = clauses.map((clause) => clause.split(/\bwithout\b/)[0]).filter((clause) => !/\b(?:do not|don't|never|khong)\b|^[\s*+•>\d.)-]*(?:(?:vui long|xin|hay|please)\s+)*dung\b/.test(clause)
301
339
  && !/^[\s*+•>\d.)-]*(?:audit|inspect|review|analyze|analyse|explain|read|kiem tra|phan tich)\b/.test(clause));
302
340
  if (declaredReadOnly(promptText, releasePolicy)) return { taskKind: 'READ_ONLY' };
303
341
  const sourceOrder = actionable.some((clause) => /\b(?:implement|fix(?:es)?|apply|refactor|modify|edit|write|update|sua|viet|chinh)\b/.test(clause)
@@ -397,8 +435,11 @@ export function classifyTaskSemantics({ promptText = '', targetFile = '' } = {})
397
435
  const statusReport = (configReportQuestion || (!mutationWord
398
436
  && /\b(?:check|status|report|show|summari[sz]e|result|results|kiem tra|bao cao|trang thai|ket qua|da len chua)\b/.test(text)))
399
437
  && !operation && !config;
438
+ // Round-2b (FS-01 B5): order detection is request-wide. A later clause that commands an order verb
439
+ // ("Output: fix lists\ncommit the changes", "...; publish") keeps the mutation even when the output
440
+ // span itself was neutralised to deliverable nouns; a true noun-only audit output stays read-only.
400
441
  const readOnly = (/\b(?:audit|inspect|review|analyze|analyse|explain|read|investigate|debug|forensic|root cause|kiem tra|phan tich|dieu tra|doc log|tim hieu)\b/.test(text.replace(/\bread[\s-]*only\b/g, ' ')) || statusReport)
401
- && !sourceOrder && !codeChangeVerb && !operation && !config;
442
+ && !sourceOrder && !codeChangeVerb && !operation && !config && !ordersChangeAcrossClauses(actionable);
402
443
  const taskKind = operation ? (sourceOrder || (nativeDomain !== 'scheduled-job' && codeChangeVerb) ? 'MIXED' : 'OPERATIONAL')
403
444
  : config ? 'CONFIGURATION' : documentation ? 'DOCUMENTATION_OUTPUT' : readOnly ? 'READ_ONLY' : 'CODE_IMPLEMENTATION';
404
445
  const id = text.match(/\b[a-f0-9]{8}(?:[a-f0-9-]{0,28})\b/)?.[0] || null;
@@ -1,4 +1,6 @@
1
1
  import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+ import { createHash } from 'node:crypto';
2
4
  import { ensureDir, writeJson, readJsonIfExists, withFileLock } from './fileOps.js';
3
5
 
4
6
  // FR-015 (W2-C2): install.json is the project's install ledger — a corrupt
@@ -70,6 +72,8 @@ export async function writeInstallMetadata({
70
72
  retainedManagedRelativePaths = [],
71
73
  ompConfigOwnership = null,
72
74
  settingsDefaultsMigration = null,
75
+ managedHashes = null,
76
+ sidecarHashes = null,
73
77
  lockStaleMs,
74
78
  lockMaxWaitMs,
75
79
  }) {
@@ -121,6 +125,17 @@ export async function writeInstallMetadata({
121
125
  return !newPaths.has(p);
122
126
  });
123
127
  const allFiles = [...retained, ...newEntries];
128
+ // CR-13: optional content hash (`h`) for managed templates; absent = legacy entry.
129
+ const hashEntries = Object.entries(managedHashes ?? {});
130
+ if (hashEntries.length > 0) {
131
+ const normalized = allFiles.map((e) => (typeof e === 'string' ? { p: e, t: 'config' } : e));
132
+ for (const [p, h] of hashEntries) {
133
+ const found = normalized.find((e) => normalizeTrackedRelativePath(e.p) === p);
134
+ if (found) found.h = h;
135
+ else normalized.push({ p, t: 'config', h });
136
+ }
137
+ allFiles.splice(0, allFiles.length, ...normalized);
138
+ }
124
139
 
125
140
  const metadata = {
126
141
  tool: 'ukit',
@@ -143,6 +158,36 @@ export async function writeInstallMetadata({
143
158
  const migrationMarker = settingsDefaultsMigration ?? (previousData?.tool === 'ukit' ? previousData.settingsDefaultsMigration : null);
144
159
  if (migrationMarker) metadata.settingsDefaultsMigration = migrationMarker;
145
160
 
161
+ // CR-13 provenance for `.ukit-new` sidecars. Only hashes recorded for a sidecar
162
+ // UKit actually created this run are kept; every other recorded sidecar is
163
+ // re-verified against disk so a record never outlives (or claims) owner content.
164
+ // A sidecar whose bytes changed is dropped — it is owner-modified from then on.
165
+ const previousSidecars = previousData?.tool === 'ukit' && previousData.sidecars && typeof previousData.sidecars === 'object'
166
+ ? previousData.sidecars
167
+ : {};
168
+ const mergedSidecars = { ...previousSidecars };
169
+ for (const [relPath, hash] of Object.entries(sidecarHashes ?? {})) {
170
+ if (typeof hash === 'string') mergedSidecars[normalizeTrackedRelativePath(relPath)] = hash;
171
+ }
172
+ const verifiedSidecars = {};
173
+ if (projectRoot) {
174
+ for (const [relPath, hash] of Object.entries(mergedSidecars)) {
175
+ if (typeof hash !== 'string' || !relPath) continue;
176
+ const absPath = path.join(projectRoot, relPath);
177
+ try {
178
+ const stat = await fs.lstat(absPath);
179
+ if (stat.isSymbolicLink() || !stat.isFile()) continue;
180
+ const current = await fs.readFile(absPath);
181
+ if (createHash('sha256').update(current).digest('hex') === hash) {
182
+ verifiedSidecars[relPath] = hash;
183
+ }
184
+ } catch {
185
+ // vanished/unreadable — forget the record
186
+ }
187
+ }
188
+ }
189
+ if (Object.keys(verifiedSidecars).length > 0) metadata.sidecars = verifiedSidecars;
190
+
146
191
  await ensureDir(path.dirname(installMetaPath));
147
192
  await writeJson(installMetaPath, metadata);
148
193
  return 'written';