@ngockhoale/ukit 3.4.67 → 3.4.68
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 +12 -0
- package/manifests/documentation.yaml +33 -0
- package/manifests/instructionRules.yaml +10 -0
- package/manifests/platform.full.yaml +67 -0
- package/package.json +1 -1
- package/src/core/applyPlan.js +111 -2
- package/src/core/buildPlan.js +3 -1
- package/src/core/diffPlan.js +49 -1
- package/src/core/executionContracts.js +49 -8
- package/src/core/metadata.js +45 -0
- package/src/core/runInstallPipeline.js +30 -2
- package/src/core/uninstall.js +106 -2
- package/src/manifest/validateManifest.js +25 -1
- package/template_project/.claude/agents/code-reviewer.md +22 -9
- package/template_project/.claude/agents/handoff-planner.md +97 -8
- package/template_project/.claude/commands/ukit/handoff-create.md +114 -19
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +247 -79
- package/template_project/.claude/commands/ukit/handoff-implement.md +75 -40
- package/template_project/.claude/commands/ukit/handoff-review.md +14 -10
- package/template_project/.claude/hooks/auto-allow-bash.sh +73 -12
- package/template_project/.claude/hooks/auto-prune-bash.sh +10 -0
- package/template_project/.claude/hooks/delegation-gate.mjs +21 -0
- package/template_project/.claude/hooks/handoff-model-guard.sh +22 -6
- package/template_project/.claude/hooks/handoff-resume.sh +141 -1
- package/template_project/.claude/hooks/record-execution.mjs +140 -9
- package/template_project/.claude/hooks/skill-router.sh +173 -8
- package/template_project/.claude/settings.json +2 -2
- package/template_project/.claude/skills/advisor-plan/REFERENCE.md +10 -0
- package/template_project/.claude/skills/advisor-plan/SKILL.md +2 -2
- package/template_project/.claude/ukit/index/allocate-task-worktree.mjs +15 -0
- package/template_project/.claude/ukit/index/amend-guard.mjs +116 -0
- package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
- package/template_project/.claude/ukit/index/land-worktree.mjs +243 -21
- package/template_project/.claude/ukit/index/plan-count.mjs +115 -0
- package/template_project/.claude/ukit/index/ready-gate.mjs +170 -0
- package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +293 -4
- package/template_project/.claude/ukit/index/route-resolver.mjs +48 -8
- package/template_project/.claude/ukit/index/route-task.mjs +180 -11
- package/template_project/.claude/ukit/index/source-snapshot.mjs +172 -0
- package/template_project/.claude/ukit/index/task-budget-validator.mjs +109 -3
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +20 -3
- package/template_project/.claude/ukit/runtime/handoff-task-scope.mjs +205 -0
- package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
- package/template_project/.claude/ukit/runtime/plan-dispatch.mjs +32 -5
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +310 -21
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +405 -105
- package/template_project/.claude/ukit/runtime/task-record-reconciliation.mjs +46 -0
- package/template_project/.claude/ukit/runtime/task-watchdog.mjs +28 -2
- package/template_project/.claude/ukit/runtime/worktree-allocation.mjs +75 -0
- package/template_project/.codex/README.md +25 -0
- package/template_project/.omp/RULES.md +22 -22
- package/template_project/.omp/agents/code-reviewer.md +22 -9
- package/template_project/.omp/agents/handoff-planner.md +102 -12
- package/template_project/.omp/hooks/pre/ukit-bridge.js +1 -1
- package/template_project/AGENTS.md +5 -1
- package/template_project/CLAUDE.md +1 -1
- package/template_project/docs/AI_HANDOFF/RULES.md +8 -0
- package/template_project/docs/AI_HANDOFF/tasks/_TEMPLATE.md +8 -1
- package/template_project/instructions/core.md +5 -1
- package/template_project/instructions/layout.yaml +2 -0
- package/template_project/instructions/overlays/omp-rules.md +22 -22
- package/template_project/ukit/storage/config.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 3.4.68 - 2026-10-25
|
|
6
|
+
|
|
7
|
+
**Stable hardening release; outstanding live acceptance explicitly deferred by the owner for the internal six-person team. This is not certification of unattended Handoff.**
|
|
8
|
+
|
|
9
|
+
- Planning: durable source snapshots, recorded validator/independent-review ready gates, amendment/plan-count/overwrite controls, and realpath-safe installed helper entrypoints.
|
|
10
|
+
- 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.
|
|
11
|
+
- 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.
|
|
12
|
+
- Classification/continuation: read-only output specifications and scheduled permission reminders no longer manufacture implementation work; real edit orders retain evidence obligations.
|
|
13
|
+
- 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.
|
|
14
|
+
- 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.
|
|
15
|
+
- 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.
|
|
16
|
+
|
|
5
17
|
## 3.4.67 - 2026-10-23
|
|
6
18
|
|
|
7
19
|
- 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
package/src/core/applyPlan.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/core/buildPlan.js
CHANGED
|
@@ -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,
|
package/src/core/diffPlan.js
CHANGED
|
@@ -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
|
|
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|
|
|
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
|
-
|
|
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;
|
package/src/core/metadata.js
CHANGED
|
@@ -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';
|
|
@@ -442,13 +442,26 @@ export async function runInstallPipeline({
|
|
|
442
442
|
const trackedSettings = (Array.isArray(installMetaData?.files) ? installMetaData.files : [])
|
|
443
443
|
.some((e) => (typeof e === 'string' ? e : e?.p) === '.claude/settings.json');
|
|
444
444
|
const settingsMigration = { pending: trackedSettings && !installMetaData?.settingsDefaultsMigration };
|
|
445
|
-
const
|
|
445
|
+
const recordedManagedHashes = new Map(
|
|
446
|
+
(Array.isArray(installMetaData?.files) ? installMetaData.files : [])
|
|
447
|
+
.filter((e) => e && typeof e === 'object' && typeof e.h === 'string')
|
|
448
|
+
.map((e) => [path.join(pathConfig.projectRoot, e.p), e.h]),
|
|
449
|
+
);
|
|
450
|
+
// CR-13 provenance: sha256 of each `.ukit-new` sidecar UKit itself wrote.
|
|
451
|
+
// Absent record = the file is not UKit's; never overwrite or remove it.
|
|
452
|
+
const recordedSidecarHashes = new Map(
|
|
453
|
+
Object.entries(installMetaData?.sidecars ?? {})
|
|
454
|
+
.filter(([p, h]) => typeof p === 'string' && typeof h === 'string')
|
|
455
|
+
.map(([p, h]) => [path.join(pathConfig.projectRoot, p), h]),
|
|
456
|
+
);
|
|
457
|
+
const diffResults = await diffInstallPlan(plan, { ompOwnership, settingsMigration, recordedManagedHashes });
|
|
446
458
|
const plannedSummary = summarizeDiff(diffResults);
|
|
447
459
|
|
|
448
460
|
if (!dryRun) {
|
|
449
|
-
const { writes, skippedUpdates, skippedByAction } = await applyDiffResults(diffResults, {
|
|
461
|
+
const { writes, skippedUpdates, skippedByAction, sidecarHashes } = await applyDiffResults(diffResults, {
|
|
450
462
|
backupRoot: pathConfig.backupRoot,
|
|
451
463
|
projectRoot: pathConfig.projectRoot,
|
|
464
|
+
recordedSidecarHashes,
|
|
452
465
|
});
|
|
453
466
|
|
|
454
467
|
for (const entry of diffResults) {
|
|
@@ -550,8 +563,23 @@ export async function runInstallPipeline({
|
|
|
550
563
|
// adjusted counts never go negative.
|
|
551
564
|
const summary = adjustSummaryForSkips(plannedSummary, skippedByAction, skippedUpdates);
|
|
552
565
|
|
|
566
|
+
const managedHashes = Object.fromEntries(
|
|
567
|
+
diffResults
|
|
568
|
+
.filter((e) => e.managedHash && ['create', 'update', 'unchanged'].includes(e.action))
|
|
569
|
+
.map((e) => [path.relative(pathConfig.projectRoot, e.targetPath).split(path.sep).join('/'), e.managedHash]),
|
|
570
|
+
);
|
|
571
|
+
// Sidecar provenance map (relative path → sha256 UKit wrote this run). Written
|
|
572
|
+
// only after apply succeeded, so a failed create never claims ownership.
|
|
573
|
+
const sidecarRecords = Object.fromEntries(
|
|
574
|
+
Object.entries(sidecarHashes).map(([absPath, h]) => [
|
|
575
|
+
path.relative(pathConfig.projectRoot, absPath).split(path.sep).join('/'),
|
|
576
|
+
h,
|
|
577
|
+
]),
|
|
578
|
+
);
|
|
553
579
|
await writeInstallMetadata({
|
|
554
580
|
installMetaPath: pathConfig.installMetaPath,
|
|
581
|
+
managedHashes,
|
|
582
|
+
sidecarHashes: sidecarRecords,
|
|
555
583
|
packageVersion,
|
|
556
584
|
manifest,
|
|
557
585
|
stackContext,
|