ai-optimize-master 0.0.0-stage → 0.3.0

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/lib/apply.mjs ADDED
@@ -0,0 +1,264 @@
1
+ // Turn scan findings into an ordered, reviewable plan; apply it under a
2
+ // hash-bound, journaled, reversible transaction.
3
+ import { resolve, sep, dirname } from 'node:path'
4
+ import { sha256Hex, isInside, expandHome, realpathOrNull } from './util.mjs'
5
+ import { archiveSkillDirectory, restoreSkillDirectory, pathState } from './archive.mjs'
6
+
7
+ const RISK_ORDER = { low: 0, medium: 1, high: 2 }
8
+ const VALID_RISK = new Set(Object.keys(RISK_ORDER))
9
+ const VALID_KINDS = new Set(['archive-skill'])
10
+
11
+ /**
12
+ * Supported step kinds in v0.2:
13
+ * - archive-skill: move a whole skill directory under the optimizer's archive root.
14
+ * Only skills that (a) exist inside a declared skill root, (b) have a SKILL.md,
15
+ * and (c) were explicitly selected by the caller are eligible.
16
+ */
17
+ export function buildPlan(scanResult, options = {}) {
18
+ const {
19
+ home = resolve(expandHome('~/.dsh-optimizer')),
20
+ selections = [],
21
+ includeFindings = true
22
+ } = options
23
+
24
+ const archiveRoot = resolve(home, 'archive')
25
+ const steps = []
26
+ const skipped = []
27
+
28
+ const byName = new Map()
29
+ const authorizedRoots = []
30
+ for (const root of scanResult.roots ?? []) {
31
+ authorizedRoots.push({ id: root.id, dir: resolve(root.dir) })
32
+ for (const skill of root.skills ?? []) {
33
+ const list = byName.get(skill.name) ?? []
34
+ list.push({ root: root.id, rootDir: root.dir, ...skill })
35
+ byName.set(skill.name, list)
36
+ }
37
+ }
38
+
39
+ const duplicateInfo = new Map((scanResult.duplicates ?? []).map(d => [d.name, d]))
40
+
41
+ for (const selection of selections) {
42
+ const name = typeof selection === 'string' ? selection : selection?.name
43
+ const wantedRoot = typeof selection === 'string' ? null : selection?.root
44
+ const copies = byName.get(name) ?? []
45
+ if (!copies.length) {
46
+ skipped.push({ name, reason: 'no-such-skill' })
47
+ continue
48
+ }
49
+ const chosen = wantedRoot ? copies.filter(c => c.root === wantedRoot) : copies
50
+ if (chosen.length !== 1) {
51
+ skipped.push({ name, reason: chosen.length ? 'ambiguous-root' : 'root-has-no-copy', candidates: chosen.map(c => c.root) })
52
+ continue
53
+ }
54
+ const target = chosen[0]
55
+ if (!target.hasSkillMd) {
56
+ skipped.push({ name, reason: 'no-skill-md' })
57
+ continue
58
+ }
59
+ const dir = resolve(target.dir)
60
+ if (!isInside(resolve(target.rootDir), dir)) {
61
+ skipped.push({ name, reason: 'outside-skill-root' })
62
+ continue
63
+ }
64
+ const duplicate = duplicateInfo.get(name)
65
+ const risk = duplicate?.classification === 'identical-copies' ? 'low' : 'medium'
66
+ steps.push({
67
+ id: `archive-skill:${target.root}:${name}`,
68
+ kind: 'archive-skill',
69
+ title: `Archive skill ${name} from ${target.root}`,
70
+ preview: { from: dir, to: resolve(archiveRoot, `${target.root}__${name}`) },
71
+ risk,
72
+ requiresAdmin: false,
73
+ reversible: true,
74
+ evidence: {
75
+ skillMdHash: target.skillMdHash,
76
+ duplicates: duplicate ? duplicate.classification : 'single-copy',
77
+ brokenReferences: target.brokenReferences ?? []
78
+ },
79
+ rationale: duplicate
80
+ ? 'Duplicate skill definition across tool roots; archive keeps a recoverable copy.'
81
+ : 'Explicitly selected single-copy skill. Archiving is reversible, but review why it was selected.'
82
+ })
83
+ }
84
+
85
+ steps.sort((a, b) => (RISK_ORDER[a.risk] - RISK_ORDER[b.risk]) || a.id.localeCompare(b.id))
86
+
87
+ const findings = includeFindings ? (scanResult.findings ?? []) : []
88
+ const plan = {
89
+ schema: 'dsh-optimizer/plan/v0.2',
90
+ platform: scanResult.platform,
91
+ archiveRoot,
92
+ authorizedRoots,
93
+ createdAt: new Date().toISOString(),
94
+ steps,
95
+ skipped,
96
+ findingIds: findings.map(f => f.id)
97
+ }
98
+ plan.planHash = sha256Hex(plan)
99
+ return plan
100
+ }
101
+
102
+ export function verifyPlanHash(plan) {
103
+ const { planHash, ...rest } = plan
104
+ return sha256Hex(rest) === planHash
105
+ }
106
+
107
+ /**
108
+ * Structural validation. Unknown values are rejected, never treated as "low":
109
+ * `RISK_ORDER[undefined] > 0` is false, so an unvalidated risk would slip through.
110
+ */
111
+ export function validatePlan(plan) {
112
+ const problems = []
113
+ if (!plan || typeof plan !== 'object') return { ok: false, problems: ['plan-not-object'] }
114
+ if (plan.schema !== 'dsh-optimizer/plan/v0.2') problems.push('bad-schema')
115
+ if (typeof plan.planHash !== 'string' || !/^[0-9a-f]{64}$/.test(plan.planHash)) problems.push('bad-plan-hash')
116
+ if (typeof plan.archiveRoot !== 'string' || !plan.archiveRoot) problems.push('missing-archive-root')
117
+ if (!Array.isArray(plan.authorizedRoots) || !plan.authorizedRoots.length) problems.push('missing-authorized-roots')
118
+ if (!Array.isArray(plan.steps)) problems.push('steps-not-array')
119
+ for (const [index, step] of (plan.steps ?? []).entries()) {
120
+ const where = `steps[${index}]`
121
+ if (!step || typeof step !== 'object') { problems.push(`${where}:not-object`); continue }
122
+ if (typeof step.id !== 'string' || !step.id) problems.push(`${where}:bad-id`)
123
+ if (!VALID_KINDS.has(step.kind)) problems.push(`${where}:bad-kind`)
124
+ if (!VALID_RISK.has(step.risk)) problems.push(`${where}:bad-risk`)
125
+ if (typeof step.requiresAdmin !== 'boolean') problems.push(`${where}:bad-requires-admin`)
126
+ if (!step.preview || typeof step.preview.from !== 'string' || typeof step.preview.to !== 'string') problems.push(`${where}:bad-preview`)
127
+ }
128
+ return { ok: problems.length === 0, problems }
129
+ }
130
+
131
+ async function authorizeStep(step, plan) {
132
+ const from = resolve(step.preview.from)
133
+ const to = resolve(step.preview.to)
134
+ const archiveRoot = resolve(plan.archiveRoot)
135
+
136
+ if (!isInside(archiveRoot, to)) return { ok: false, reason: 'archive-target-outside-root' }
137
+ if (from === to) return { ok: false, reason: 'source-equals-target' }
138
+ if (isInside(from, archiveRoot)) return { ok: false, reason: 'archive-inside-source' }
139
+
140
+ const roots = plan.authorizedRoots.map(r => resolve(r.dir))
141
+ if (!roots.some(root => isInside(root, from))) return { ok: false, reason: 'source-outside-authorized-roots' }
142
+
143
+ // Symlinked ancestors can escape a prefix check; compare real paths when the
144
+ // path already exists.
145
+ const realFrom = await realpathOrNull(from)
146
+ const realRoots = []
147
+ for (const root of roots) realRoots.push(await realpathOrNull(root))
148
+ if (realFrom && !realRoots.filter(Boolean).some(root => isInside(root, realFrom))) {
149
+ return { ok: false, reason: 'source-realpath-outside-authorized-roots' }
150
+ }
151
+ const state = await pathState(from)
152
+ if (state.symlink) return { ok: false, reason: 'source-is-symlink' }
153
+ if (!state.exists) return { ok: false, reason: 'source-missing' }
154
+ if (!state.directory) return { ok: false, reason: 'source-not-directory' }
155
+ if ((await pathState(to)).exists) return { ok: false, reason: 'archive-target-exists' }
156
+ return { ok: true }
157
+ }
158
+
159
+ /**
160
+ * Apply a plan with a staged, journaled transaction.
161
+ * - missing expect -> code 4 (denied)
162
+ * - planHash mismatch -> code 3 (diverged)
163
+ * - invalid plan/shape -> code 1 (usage) before any write
164
+ * - unknown risk/allow -> rejected, never silently downgraded
165
+ * - intent is persisted before the first destructive operation
166
+ * - the journal is rewritten after every step, so a crash still leaves a record
167
+ */
168
+ export async function applyPlan(plan, options) {
169
+ const { expect, allow = 'low', journal, isAdmin = false } = options
170
+
171
+ if (!expect) return { ok: false, code: 4, reason: 'missing-expected-plan-hash', journal: null }
172
+ if (!VALID_RISK.has(allow)) return { ok: false, code: 1, reason: 'invalid-allow-value', journal: null }
173
+
174
+ const validated = validatePlan(plan)
175
+ if (!validated.ok) return { ok: false, code: 1, reason: 'invalid-plan', problems: validated.problems, journal: null }
176
+ if (!verifyPlanHash(plan) || plan.planHash !== expect) return { ok: false, code: 3, reason: 'plan-diverged', journal: null }
177
+
178
+ const maxRisk = RISK_ORDER[allow]
179
+ const journalDoc = {
180
+ schema: 'dsh-optimizer/journal/v0.2',
181
+ planHash: plan.planHash,
182
+ createdAt: new Date().toISOString(),
183
+ archiveRoot: plan.archiveRoot,
184
+ steps: [],
185
+ results: []
186
+ }
187
+
188
+ // Persist intent before touching disk: a crash mid-run must be explainable.
189
+ journalDoc.phase = 'intent'
190
+ await journal.write(journalDoc)
191
+ journalDoc.phase = 'running'
192
+
193
+ for (const step of plan.steps) {
194
+ const risk = RISK_ORDER[step.risk] ?? Infinity
195
+ if (risk > maxRisk) {
196
+ journalDoc.results.push({ id: step.id, status: 'skipped', blocked: 'risk-not-allowed', risk: step.risk })
197
+ continue
198
+ }
199
+ if (step.requiresAdmin && !isAdmin) {
200
+ journalDoc.results.push({ id: step.id, status: 'blocked', blocked: 'requires-admin' })
201
+ continue
202
+ }
203
+ const authorization = await authorizeStep(step, plan)
204
+ if (!authorization.ok) {
205
+ journalDoc.results.push({ id: step.id, status: 'blocked', blocked: authorization.reason })
206
+ continue
207
+ }
208
+ const outcome = await archiveSkillDirectory(step, plan.archiveRoot)
209
+ if (outcome.code !== 'ok') {
210
+ journalDoc.results.push({ id: step.id, status: 'blocked', blocked: outcome.code })
211
+ await journal.write(journalDoc)
212
+ continue
213
+ }
214
+ journalDoc.steps.push({
215
+ id: step.id,
216
+ kind: step.kind,
217
+ before: outcome.before,
218
+ after: outcome.after
219
+ })
220
+ journalDoc.results.push({ id: step.id, status: 'applied', from: step.preview.from, to: step.preview.to })
221
+ // Rewrite after each step: a partial run is still recoverable.
222
+ await journal.write(journalDoc)
223
+ }
224
+
225
+ journalDoc.phase = 'done'
226
+ await journal.write(journalDoc)
227
+ const failures = journalDoc.results.filter(r => r.status === 'blocked')
228
+ return {
229
+ ok: failures.length === 0,
230
+ code: failures.length === 0 ? 0 : 1,
231
+ journal: journalDoc,
232
+ results: journalDoc.results
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Undo an applied journal in reverse order.
238
+ * Refuses to overwrite a target that was re-created after apply, verifies the
239
+ * archived tree before restoring, and treats an already-restored step as done.
240
+ */
241
+ export async function undoJournal(journalDoc, options = {}) {
242
+ const { journal } = options
243
+ const results = []
244
+ const steps = [...(journalDoc.steps ?? [])].reverse()
245
+ for (const step of steps) {
246
+ const outcome = await restoreSkillDirectory(step)
247
+ results.push({ id: step.id, status: outcome.code === 'ok' ? 'restored' : (outcome.code === 'already-restored' ? 'already-restored' : 'conflict'), code: outcome.code, detail: outcome.detail })
248
+ if (journal) {
249
+ journalDoc.results = [...(journalDoc.results ?? []), ...results]
250
+ journalDoc.phase = 'undoing'
251
+ await journal.write(journalDoc)
252
+ journalDoc.results = journalDoc.results.slice(0, -(results.length))
253
+ }
254
+ }
255
+ if (journal) {
256
+ journalDoc.results = [...(journalDoc.results ?? []), ...results]
257
+ journalDoc.phase = 'undone'
258
+ await journal.write(journalDoc)
259
+ }
260
+ const conflicts = results.filter(r => r.status === 'conflict')
261
+ return { ok: conflicts.length === 0, code: conflicts.length === 0 ? 0 : 3, results }
262
+ }
263
+
264
+ export { RISK_ORDER }
@@ -0,0 +1,130 @@
1
+ // Archive + restore primitives. Shared by the CLI, the DSH plugin and tests so a
2
+ // fix lands in every surface at once.
3
+ import { cp, lstat, mkdir, rename, rm, readdir } from 'node:fs/promises'
4
+ import { dirname, join } from 'node:path'
5
+ import { buildManifest, verifyManifest, manifestDigest } from './manifest.mjs'
6
+
7
+ export async function pathState(p) {
8
+ try {
9
+ const info = await lstat(p)
10
+ return { exists: true, symlink: info.isSymbolicLink(), directory: info.isDirectory() }
11
+ } catch (error) {
12
+ if (error?.code === 'ENOENT') return { exists: false, symlink: false, directory: false }
13
+ throw error
14
+ }
15
+ }
16
+
17
+ // Move a skill directory into the archive root, with exclusive creation and a
18
+ // verified copy before the original is touched.
19
+ //
20
+ // Contract:
21
+ // - the archive target must not already exist (never merge into a previous archive)
22
+ // - the source must not be a symlink (we do not follow links out of the root)
23
+ // - the copy is verified against a full manifest before the source is removed
24
+ // - if verification fails, the partial archive is removed and the source is kept
25
+ export async function archiveSkillDirectory(step, archiveRoot) {
26
+ const from = step.preview.from
27
+ const to = step.preview.to
28
+ if (!to.startsWith(archiveRoot)) return { code: 'outside-archive-root' }
29
+
30
+ const state = await pathState(from)
31
+ if (!state.exists) return { code: 'source-missing' }
32
+ if (state.symlink) return { code: 'source-is-symlink' }
33
+ if (!state.directory) return { code: 'source-not-directory' }
34
+ if ((await pathState(to)).exists) return { code: 'archive-target-exists' }
35
+
36
+ const sourceManifest = await buildManifest(from)
37
+ await mkdir(dirname(to), { recursive: true })
38
+ const staging = `${to}.partial-${process.pid}-${Date.now()}`
39
+ await mkdir(staging, { recursive: true })
40
+ try {
41
+ await cp(from, staging, { recursive: true, force: false, errorOnExist: true })
42
+ const copied = await buildManifest(staging)
43
+ if (!sameEntries(sourceManifest, copied)) {
44
+ await rm(staging, { recursive: true, force: true })
45
+ return { code: 'copy-verification-failed' }
46
+ }
47
+ await rename(staging, to)
48
+ // Re-check the source right before removal: this narrows (but cannot fully
49
+ // eliminate) a concurrent-write window. See SECURITY.md "concurrent writes".
50
+ const current = await buildManifest(from)
51
+ if (!sameEntries(sourceManifest, current)) {
52
+ await rm(to, { recursive: true, force: true })
53
+ return { code: 'source-changed-during-copy' }
54
+ }
55
+ await rm(from, { recursive: true, force: true })
56
+ const archived = await buildManifest(to)
57
+ return {
58
+ code: 'ok',
59
+ before: { path: from, manifest: sourceManifest, digest: manifestDigest(sourceManifest) },
60
+ after: { path: to, manifest: archived, digest: manifestDigest(archived) }
61
+ }
62
+ } catch (error) {
63
+ await rm(staging, { recursive: true, force: true }).catch(() => {})
64
+ throw error
65
+ }
66
+ }
67
+
68
+ // Restore one journaled step. Refuses to overwrite anything that appeared at the
69
+ // original path after apply, and verifies the archived tree before restoring.
70
+ export async function restoreSkillDirectory(step) {
71
+ const target = step.before.path
72
+ const archivePath = step.after.path
73
+
74
+ const archiveState = await pathState(archivePath)
75
+ if (!archiveState.exists) {
76
+ // Idempotency: if the original is back with the expected content, this step
77
+ // was already undone; otherwise the archive is gone and we cannot restore.
78
+ const targetState = await pathState(target)
79
+ if (targetState.exists && targetState.directory) {
80
+ const check = await verifyManifest(target, step.before.manifest)
81
+ if (check.ok) return { code: 'already-restored' }
82
+ }
83
+ return { code: 'archive-missing' }
84
+ }
85
+
86
+ const archiveCheck = await verifyManifest(archivePath, step.after.manifest)
87
+ if (!archiveCheck.ok) return { code: 'archive-integrity-failed', detail: archiveCheck }
88
+
89
+ const targetState = await pathState(target)
90
+ if (targetState.exists) return { code: 'target-occupied', detail: targetState }
91
+
92
+ await mkdir(dirname(target), { recursive: true })
93
+ const staging = `${target}.restore-${process.pid}-${Date.now()}`
94
+ try {
95
+ await cp(archivePath, staging, { recursive: true, force: false })
96
+ const check = await verifyManifest(staging, step.before.manifest)
97
+ if (!check.ok) {
98
+ await rm(staging, { recursive: true, force: true })
99
+ return { code: 'restore-verification-failed', detail: check }
100
+ }
101
+ await rename(staging, target)
102
+ } catch (error) {
103
+ await rm(staging, { recursive: true, force: true }).catch(() => {})
104
+ throw error
105
+ }
106
+ const finalCheck = await verifyManifest(target, step.before.manifest)
107
+ if (!finalCheck.ok) return { code: 'restore-verification-failed', detail: finalCheck }
108
+ await removeEmptyParents(archivePath)
109
+ await rm(archivePath, { recursive: true, force: true })
110
+ return { code: 'ok' }
111
+ }
112
+
113
+ async function removeEmptyParents(path) {
114
+ let dir = dirname(path)
115
+ for (let i = 0; i < 4; i++) {
116
+ try {
117
+ const items = await readdir(dir)
118
+ if (items.length) return
119
+ await rm(dir, { recursive: false, force: true })
120
+ dir = dirname(dir)
121
+ } catch {
122
+ return
123
+ }
124
+ }
125
+ }
126
+
127
+ function sameEntries(a, b) {
128
+ return JSON.stringify(a.entries) === JSON.stringify(b.entries)
129
+ }
130
+
package/lib/evolve.mjs ADDED
@@ -0,0 +1,128 @@
1
+ // Evolve = read-only improvement proposals + change detection (v0.3).
2
+ //
3
+ // What this module will NOT do (std review, 2026-11-06):
4
+ // - delete a dangling reference automatically (a missing file may be generated later)
5
+ // - rewrite SKILL.md with a model (behaviour gain is unverifiable, privacy/prompt-injection risk)
6
+ // - convert identical copies into symlinks (changes path resolution and installer behaviour)
7
+ //
8
+ // It produces evidence + a diff, and leaves execution to the existing hash-bound
9
+ // plan/apply path, which the caller must still approve.
10
+ import { readTextIfExists } from './util.mjs'
11
+
12
+ const REF_LINE = /^.*`((?:scripts|references|examples|assets|templates|docs)\/[^`\s)]+)`.*$/gm
13
+ const WIN_PATH = /([A-Za-z]:\\[^\s`'"))]+)/g
14
+
15
+ function lineOf(text, index) {
16
+ return text.slice(0, index).split('\n').length
17
+ }
18
+
19
+ /**
20
+ * Build proposals from a scan result.
21
+ * Every proposal carries: skill, root, kind, evidence, suggestion, diff, and
22
+ * `apply: 'manual'` — there is no auto-apply path for these in v0.3.
23
+ */
24
+ export async function propose(scanResult) {
25
+ const proposals = []
26
+ for (const root of scanResult.roots ?? []) {
27
+ for (const skill of root.skills ?? []) {
28
+ if (!skill.hasSkillMd) continue
29
+ const file = skill.dir.endsWith('SKILL.md') ? skill.dir : `${skill.dir}/SKILL.md`
30
+ const text = await readTextIfExists(file)
31
+ if (text === null) continue
32
+
33
+ // 1) Dangling local references: report, never delete.
34
+ for (const broken of skill.brokenReferences ?? []) {
35
+ const re = new RegExp(`^.*\`${escapeRe(broken)}\`.*$`, 'm')
36
+ const match = re.exec(text)
37
+ proposals.push({
38
+ id: `proposal:broken-reference:${root.id}:${skill.name}:${broken}`,
39
+ skill: skill.name,
40
+ root: root.id,
41
+ kind: 'broken-reference',
42
+ risk: 'medium',
43
+ apply: 'manual',
44
+ evidence: { missing: broken, line: match ? lineOf(text, match.index) : null, file },
45
+ suggestion: 'Either restore the referenced file or remove the instruction that points at it. Confirm which one is true for this skill before editing.',
46
+ diff: match ? `- ${match[0].trim()}\n+ (remove only if the referenced file is not generated at runtime)` : null,
47
+ note: 'A missing file only proves it cannot be resolved now; it does not prove the instruction is wrong.'
48
+ })
49
+ }
50
+
51
+ // 2) Windows-only absolute paths on a POSIX host: propose portable form.
52
+ if (process.platform !== 'win32') {
53
+ const seen = new Set()
54
+ for (const match of text.matchAll(WIN_PATH)) {
55
+ const raw = match[1]
56
+ if (seen.has(raw)) continue
57
+ seen.add(raw)
58
+ proposals.push({
59
+ id: `proposal:platform-path:${root.id}:${skill.name}:${hashish(raw)}`,
60
+ skill: skill.name,
61
+ root: root.id,
62
+ kind: 'platform-path',
63
+ risk: 'low',
64
+ apply: 'manual',
65
+ evidence: { path: raw, line: lineOf(text, match.index) },
66
+ suggestion: 'Replace the hard-coded Windows path with an environment-driven path. Note that `$HOME`/`~` are not expanded identically by every agent, so verify the target interpreter before editing.',
67
+ diff: `- ${raw}\n+ <portable path or environment variable, verified against the consuming agent>`
68
+ })
69
+ }
70
+ }
71
+ }
72
+ }
73
+
74
+ // 3) Duplicate groups: report only. Content equality does not authorise merging.
75
+ for (const duplicate of scanResult.duplicates ?? []) {
76
+ proposals.push({
77
+ id: `proposal:duplicate:${duplicate.name}`,
78
+ skill: duplicate.name,
79
+ root: null,
80
+ kind: 'duplicate',
81
+ risk: 'medium',
82
+ apply: 'manual',
83
+ evidence: { classification: duplicate.classification, copies: duplicate.copies.map(c => ({ root: c.root, dir: c.dir })) },
84
+ suggestion: duplicate.classification === 'identical-copies'
85
+ ? 'Pick one copy to own future edits, or move to a canonical source with links. Byte equality today does not promise equal upgrades later.'
86
+ : 'The copies already diverge. Diff them and decide which one is authoritative before touching either.',
87
+ diff: null
88
+ })
89
+ }
90
+
91
+ return {
92
+ schema: 'ai-optimize-master/evolve/v0.3',
93
+ platform: scanResult.platform,
94
+ generatedAt: new Date().toISOString(),
95
+ readOnly: true,
96
+ counts: proposals.reduce((acc, p) => { acc[p.kind] = (acc[p.kind] ?? 0) + 1; return acc }, {}),
97
+ proposals
98
+ }
99
+ }
100
+
101
+ /** Compare two scan results and report what appeared / disappeared. */
102
+ export function compareScans(baseline, current) {
103
+ const key = finding => finding.id
104
+ const before = new Map((baseline.findings ?? []).map(f => [key(f), f]))
105
+ const after = new Map((current.findings ?? []).map(f => [key(f), f]))
106
+ const added = [...after.keys()].filter(id => !before.has(id))
107
+ const resolved = [...before.keys()].filter(id => !after.has(id))
108
+ const skillCount = result => (result.roots ?? []).reduce((n, r) => n + (r.skillCount ?? 0), 0)
109
+ return {
110
+ schema: 'ai-optimize-master/change/v0.3',
111
+ baselineAt: baseline.scannedAt ?? null,
112
+ currentAt: current.scannedAt ?? null,
113
+ skillCountBefore: skillCount(baseline),
114
+ skillCountAfter: skillCount(current),
115
+ added: added.map(id => after.get(id)).map(f => ({ id: f.id, title: f.title, severity: f.severity })),
116
+ resolved: resolved.map(id => before.get(id)).map(f => ({ id: f.id, title: f.title, severity: f.severity }))
117
+ }
118
+ }
119
+
120
+ function escapeRe(value) {
121
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
122
+ }
123
+
124
+ function hashish(value) {
125
+ let hash = 0
126
+ for (const char of value) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
127
+ return hash.toString(16)
128
+ }
@@ -0,0 +1,63 @@
1
+ // Directory-level integrity: a single SKILL.md hash cannot prove an archive is
2
+ // complete. A manifest covers every entry: relative path, type, file hash, and
3
+ // symlink target.
4
+ import { lstat, readdir, readFile, readlink } from 'node:fs/promises'
5
+ import { join, relative } from 'node:path'
6
+ import { createHash } from 'node:crypto'
7
+
8
+ export async function buildManifest(root) {
9
+ const entries = []
10
+ async function walk(dir) {
11
+ let items
12
+ try {
13
+ items = await readdir(dir, { withFileTypes: true })
14
+ } catch (error) {
15
+ if (error?.code === 'ENOENT') return
16
+ throw error
17
+ }
18
+ for (const item of items.sort((a, b) => a.name.localeCompare(b.name))) {
19
+ const absolute = join(dir, item.name)
20
+ const rel = relative(root, absolute).replaceAll('\\', '/')
21
+ const info = await lstat(absolute)
22
+ if (info.isSymbolicLink()) {
23
+ entries.push({ path: rel, type: 'symlink', target: await readlink(absolute) })
24
+ } else if (info.isDirectory()) {
25
+ entries.push({ path: rel, type: 'dir' })
26
+ await walk(absolute)
27
+ } else if (info.isFile()) {
28
+ const bytes = await readFile(absolute)
29
+ entries.push({ path: rel, type: 'file', sha256: createHash('sha256').update(bytes).digest('hex'), size: bytes.length })
30
+ } else {
31
+ entries.push({ path: rel, type: 'other' })
32
+ }
33
+ }
34
+ }
35
+ await walk(root)
36
+ return { root, entries }
37
+ }
38
+
39
+ export function manifestDigest(manifest) {
40
+ return createHash('sha256').update(JSON.stringify(manifest.entries)).digest('hex')
41
+ }
42
+
43
+ export function manifestsEqual(a, b) {
44
+ return manifestDigest(a) === manifestDigest(b)
45
+ }
46
+
47
+ // Verify that a tree still matches a manifest. Any added, removed, or modified
48
+ // entry makes the verification fail; "looks fine" is not a verification.
49
+ export async function verifyManifest(root, manifest) {
50
+ const current = await buildManifest(root)
51
+ if (manifestsEqual(current, manifest)) return { ok: true, digest: manifestDigest(current) }
52
+ const expectedPaths = new Set(manifest.entries.map(e => e.path))
53
+ const actualPaths = new Set(current.entries.map(e => e.path))
54
+ const missing = [...expectedPaths].filter(p => !actualPaths.has(p))
55
+ const extra = [...actualPaths].filter(p => !expectedPaths.has(p))
56
+ const changed = []
57
+ for (const entry of manifest.entries) {
58
+ const other = current.entries.find(e => e.path === entry.path)
59
+ if (!other) continue
60
+ if (other.type !== entry.type || other.sha256 !== entry.sha256 || other.target !== entry.target) changed.push(entry.path)
61
+ }
62
+ return { ok: false, digest: manifestDigest(current), missing, extra, changed }
63
+ }