@deftai/directive-core 0.95.0 → 0.97.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/dist/cache/archive.d.ts +134 -0
- package/dist/cache/archive.js +636 -0
- package/dist/cache/index.d.ts +1 -0
- package/dist/cache/index.js +1 -0
- package/dist/cache/main.js +298 -1
- package/dist/check/gate-lists.js +8 -0
- package/dist/consumer-check-contract/evaluate.d.ts +124 -0
- package/dist/consumer-check-contract/evaluate.js +699 -0
- package/dist/consumer-check-contract/index.d.ts +5 -0
- package/dist/consumer-check-contract/index.js +5 -0
- package/dist/content-contracts/skills/helpers.d.ts +1 -1
- package/dist/content-contracts/skills/helpers.js +1 -0
- package/dist/delivery-attempt/disk-begin.d.ts +51 -0
- package/dist/delivery-attempt/disk-begin.js +68 -0
- package/dist/delivery-attempt/evaluate.d.ts +26 -0
- package/dist/delivery-attempt/evaluate.js +443 -0
- package/dist/delivery-attempt/fingerprint.d.ts +32 -0
- package/dist/delivery-attempt/fingerprint.js +100 -0
- package/dist/delivery-attempt/handoff.d.ts +25 -0
- package/dist/delivery-attempt/handoff.js +102 -0
- package/dist/delivery-attempt/index.d.ts +17 -0
- package/dist/delivery-attempt/index.js +17 -0
- package/dist/delivery-attempt/ledger.d.ts +169 -0
- package/dist/delivery-attempt/ledger.js +758 -0
- package/dist/delivery-attempt/material-delta.d.ts +38 -0
- package/dist/delivery-attempt/material-delta.js +126 -0
- package/dist/delivery-attempt/types.d.ts +210 -0
- package/dist/delivery-attempt/types.js +77 -0
- package/dist/doctor/index.d.ts +1 -0
- package/dist/doctor/index.js +1 -0
- package/dist/doctor/main.js +12 -0
- package/dist/doctor/openclaw-soft-rebind.d.ts +26 -0
- package/dist/doctor/openclaw-soft-rebind.js +164 -0
- package/dist/hooks/dispatcher.d.ts +2 -1
- package/dist/hooks/dispatcher.js +63 -9
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/init-deposit/gitignore.js +7 -0
- package/dist/init-deposit/hygiene.d.ts +1 -1
- package/dist/init-deposit/hygiene.js +16 -4
- package/dist/init-deposit/init-deposit.js +5 -0
- package/dist/init-deposit/refresh.js +3 -0
- package/dist/init-deposit/scaffold.js +6 -5
- package/dist/parent-turn-shape/evaluate.d.ts +84 -0
- package/dist/parent-turn-shape/evaluate.js +353 -0
- package/dist/parent-turn-shape/index.d.ts +8 -0
- package/dist/parent-turn-shape/index.js +8 -0
- package/dist/pr-merge-readiness/ci-gate.d.ts +29 -1
- package/dist/pr-merge-readiness/ci-gate.js +191 -24
- package/dist/pr-merge-readiness/compute.js +10 -1
- package/dist/pr-merge-readiness/index.d.ts +2 -1
- package/dist/pr-merge-readiness/index.js +2 -1
- package/dist/pr-merge-readiness/output.js +14 -0
- package/dist/pr-merge-readiness/platform-status.d.ts +29 -0
- package/dist/pr-merge-readiness/platform-status.js +49 -0
- package/dist/pr-watch/constants.d.ts +10 -0
- package/dist/pr-watch/constants.js +12 -1
- package/dist/pr-watch/main.js +16 -1
- package/dist/pr-watch/probe.js +13 -1
- package/dist/pr-watch/types.d.ts +3 -2
- package/dist/pr-watch/watch.js +14 -7
- package/dist/review-monitor/constants.js +3 -2
- package/dist/review-monitor/tier-detection.d.ts +7 -3
- package/dist/review-monitor/tier-detection.js +18 -1
- package/dist/review-monitor/verify.js +18 -0
- package/dist/scope/index.d.ts +2 -0
- package/dist/scope/index.js +2 -0
- package/dist/scope/main.d.ts +10 -0
- package/dist/scope/main.js +109 -24
- package/dist/scope/promote-from-issue.d.ts +49 -0
- package/dist/scope/promote-from-issue.js +367 -0
- package/dist/scope/promote-path.d.ts +39 -0
- package/dist/scope/promote-path.js +105 -0
- package/dist/scope-provenance/digest.d.ts +67 -0
- package/dist/scope-provenance/digest.js +188 -0
- package/dist/scope-provenance/evaluate.d.ts +82 -0
- package/dist/scope-provenance/evaluate.js +528 -0
- package/dist/scope-provenance/index.d.ts +6 -0
- package/dist/scope-provenance/index.js +6 -0
- package/dist/session/compact-ritual.d.ts +96 -0
- package/dist/session/compact-ritual.js +237 -0
- package/dist/session/compact-ritual.spec.d.ts +2 -0
- package/dist/session/compact-ritual.spec.js +21 -0
- package/dist/session/index.d.ts +2 -0
- package/dist/session/index.js +2 -0
- package/dist/session/openclaw-soft-rebind-deposit.d.ts +46 -0
- package/dist/session/openclaw-soft-rebind-deposit.js +165 -0
- package/dist/swarm/routing.d.ts +4 -2
- package/dist/swarm/routing.js +26 -4
- package/dist/test-boundary/evaluate.d.ts +54 -0
- package/dist/test-boundary/evaluate.js +368 -0
- package/dist/test-boundary/index.d.ts +6 -0
- package/dist/test-boundary/index.js +6 -0
- package/dist/test-boundary/policy.d.ts +52 -0
- package/dist/test-boundary/policy.js +182 -0
- package/dist/triage/actions/index.js +62 -2
- package/dist/triage/actions/types.d.ts +8 -1
- package/dist/triage/author-filter.d.ts +51 -0
- package/dist/triage/author-filter.js +152 -0
- package/dist/triage/bootstrap/gitignore.d.ts +1 -1
- package/dist/triage/bootstrap/gitignore.js +15 -1
- package/dist/triage/classify/index.d.ts +2 -2
- package/dist/triage/classify/index.js +2 -2
- package/dist/triage/classify/label-mirror.d.ts +68 -5
- package/dist/triage/classify/label-mirror.js +261 -31
- package/dist/triage/help/registry-data.d.ts +49 -38
- package/dist/triage/help/registry-data.js +115 -40
- package/dist/triage/index.d.ts +1 -0
- package/dist/triage/index.js +1 -0
- package/dist/triage/queue/index.d.ts +1 -0
- package/dist/triage/queue/index.js +1 -0
- package/dist/triage/queue/render.d.ts +2 -0
- package/dist/triage/queue/render.js +6 -0
- package/dist/vbrief-activate/activate.js +22 -6
- package/dist/xbrief/styles.js +33 -17
- package/package.json +11 -3
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reversible archive for closed github-issue cache entries (#1137).
|
|
3
|
+
*
|
|
4
|
+
* Distinct from `cachePrune` (TTL / expires_at hard-delete). This module moves
|
|
5
|
+
* closed-and-aged live entries under `.deft-cache/archived/github-issue/...`
|
|
6
|
+
* with `archive-meta.json`, and can list/restore them. Operator-invoked only;
|
|
7
|
+
* never wired into check / session-start / sync.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, } from "node:fs";
|
|
10
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
11
|
+
import { assertProjectionContained, ProjectionContainmentError, } from "../fs/projection-containment.js";
|
|
12
|
+
import { hasArtifactSuffix, resolveLifecycleRoot } from "../layout/resolve.js";
|
|
13
|
+
import { issueNumbersFromPlan } from "../triage/queue/scope-walk.js";
|
|
14
|
+
import { DEFAULT_PRUNE_OLDER_THAN_DAYS, GH_KEY_RE, REPO_RE } from "./constants.js";
|
|
15
|
+
import { CacheError } from "./errors.js";
|
|
16
|
+
import { appendAudit, atomicWriteText } from "./io.js";
|
|
17
|
+
import { entryDir, validateKey } from "./paths.js";
|
|
18
|
+
import { parseIso, systemClock, utcIso } from "./time.js";
|
|
19
|
+
export { ProjectionContainmentError as CacheArchiveContainmentError };
|
|
20
|
+
/** Archive age default matches TTL prune window but is a separate concept (#1137). */
|
|
21
|
+
export const DEFAULT_ARCHIVE_OLDER_THAN_DAYS = DEFAULT_PRUNE_OLDER_THAN_DAYS;
|
|
22
|
+
export const ARCHIVE_SOURCE_GITHUB_ISSUE = "github-issue";
|
|
23
|
+
export const ARCHIVED_ROOT_SEGMENT = "archived";
|
|
24
|
+
export const ARCHIVE_META_FILENAME = "archive-meta.json";
|
|
25
|
+
const OPEN_LIFECYCLE_FOLDERS = ["proposed", "pending", "active"];
|
|
26
|
+
const TERMINAL_DECISIONS = new Set(["reject", "mark-duplicate"]);
|
|
27
|
+
function assertWritableCachePath(cacheRoot, ...segments) {
|
|
28
|
+
const cacheAbs = resolve(cacheRoot);
|
|
29
|
+
const projectDir = dirname(cacheAbs);
|
|
30
|
+
const target = segments.length > 0 ? join(cacheAbs, ...segments) : cacheAbs;
|
|
31
|
+
assertProjectionContained(projectDir, target);
|
|
32
|
+
return target;
|
|
33
|
+
}
|
|
34
|
+
/** Live entry dir for a github-issue key. */
|
|
35
|
+
export function liveEntryDir(source, key, cacheRoot) {
|
|
36
|
+
return entryDir(source, key, cacheRoot);
|
|
37
|
+
}
|
|
38
|
+
/** Archived entry dir: `.deft-cache/archived/<source>/<owner>/<repo>/<N>/`. */
|
|
39
|
+
export function archivedEntryDir(source, key, cacheRoot) {
|
|
40
|
+
if (source !== ARCHIVE_SOURCE_GITHUB_ISSUE) {
|
|
41
|
+
throw new CacheError(`archive source '${source}' not supported in v1 (supports: ${ARCHIVE_SOURCE_GITHUB_ISSUE} only)`);
|
|
42
|
+
}
|
|
43
|
+
validateKey(source, key);
|
|
44
|
+
return join(cacheRoot, ARCHIVED_ROOT_SEGMENT, source, ...key.split("/"));
|
|
45
|
+
}
|
|
46
|
+
function loadPlan(path) {
|
|
47
|
+
try {
|
|
48
|
+
const data = JSON.parse(readFileSync(path, { encoding: "utf8" }));
|
|
49
|
+
if (typeof data !== "object" || data === null)
|
|
50
|
+
return null;
|
|
51
|
+
const plan = data.plan;
|
|
52
|
+
return typeof plan === "object" && plan !== null ? plan : null;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Parse github issue URI → `{repo: owner/name, number}` when possible. */
|
|
59
|
+
function issueRefFromUri(uri) {
|
|
60
|
+
const cleaned = uri.replace(/\/+$/, "");
|
|
61
|
+
// Accept github.com and www.github.com (case-insensitive host).
|
|
62
|
+
// Digits may include leading zeros (rare URL forms); Number() normalizes.
|
|
63
|
+
const m = /(?:https?:\/\/)?(?:www\.)?github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)$/i.exec(cleaned);
|
|
64
|
+
if (m) {
|
|
65
|
+
// Lowercase owner/repo so protection matches case-folded cache keys (#1137 review).
|
|
66
|
+
// Leading zeros normalize via Number(); reject 0 / non-positive.
|
|
67
|
+
const num = Number(m[3]);
|
|
68
|
+
if (!(num >= 1))
|
|
69
|
+
return null;
|
|
70
|
+
return {
|
|
71
|
+
repo: `${(m[1] ?? "").toLowerCase()}/${(m[2] ?? "").toLowerCase()}`,
|
|
72
|
+
number: num,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const slash = cleaned.lastIndexOf("/");
|
|
76
|
+
const tail = slash >= 0 ? cleaned.slice(slash + 1) : cleaned;
|
|
77
|
+
if (/^[1-9]\d*$/.test(tail)) {
|
|
78
|
+
return { repo: "", number: Number(tail) };
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Open-lifecycle protection set: preferred keys are `owner/repo#N`.
|
|
84
|
+
* Bare `#N` keys are also recorded when the plan URI has no repo (conservative).
|
|
85
|
+
*/
|
|
86
|
+
export function openLifecycleReferencedKeys(projectRoot) {
|
|
87
|
+
const out = new Set();
|
|
88
|
+
let base;
|
|
89
|
+
try {
|
|
90
|
+
base = resolveLifecycleRoot(projectRoot);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
for (const folder of OPEN_LIFECYCLE_FOLDERS) {
|
|
96
|
+
const folderDir = join(base, folder);
|
|
97
|
+
if (!existsSync(folderDir))
|
|
98
|
+
continue;
|
|
99
|
+
for (const name of readdirSync(folderDir)
|
|
100
|
+
.filter((entry) => hasArtifactSuffix(entry))
|
|
101
|
+
.sort()) {
|
|
102
|
+
const plan = loadPlan(join(folderDir, name));
|
|
103
|
+
if (plan === null)
|
|
104
|
+
continue;
|
|
105
|
+
const refs = plan.references;
|
|
106
|
+
if (!Array.isArray(refs))
|
|
107
|
+
continue;
|
|
108
|
+
for (const ref of refs) {
|
|
109
|
+
if (typeof ref !== "object" || ref === null)
|
|
110
|
+
continue;
|
|
111
|
+
const typed = ref;
|
|
112
|
+
const uri = typed.uri;
|
|
113
|
+
if (typeof uri !== "string")
|
|
114
|
+
continue;
|
|
115
|
+
const parsed = issueRefFromUri(uri);
|
|
116
|
+
if (parsed) {
|
|
117
|
+
if (parsed.repo) {
|
|
118
|
+
// Repo-scoped protection only — multi-repo caches stay independent.
|
|
119
|
+
out.add(`${parsed.repo}#${parsed.number}`);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
out.add(`#${parsed.number}`);
|
|
123
|
+
}
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
// Non-github URI with numeric tail: bare-number protect (conservative).
|
|
127
|
+
for (const n of issueNumbersFromPlan({ references: [ref] })) {
|
|
128
|
+
out.add(`#${n}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
/** True when cache key `owner/repo/N` is protected by open lifecycle refs. */
|
|
136
|
+
export function isLifecycleProtected(cacheKey, protectedKeys) {
|
|
137
|
+
const m = GH_KEY_RE.exec(cacheKey);
|
|
138
|
+
if (!m)
|
|
139
|
+
return false;
|
|
140
|
+
const repo = `${(m[1] ?? "").toLowerCase()}/${(m[2] ?? "").toLowerCase()}`;
|
|
141
|
+
const n = m[3] ?? "";
|
|
142
|
+
// Prefer exact (case-folded) repo match; bare #N only when URI had no repo.
|
|
143
|
+
return protectedKeys.has(`${repo}#${n}`) || protectedKeys.has(`#${n}`);
|
|
144
|
+
}
|
|
145
|
+
function collectLiveMetaPaths(srcRoot) {
|
|
146
|
+
const found = [];
|
|
147
|
+
function walk(dir) {
|
|
148
|
+
if (!existsSync(dir))
|
|
149
|
+
return;
|
|
150
|
+
for (const name of readdirSync(dir, { withFileTypes: true })) {
|
|
151
|
+
const full = join(dir, name.name);
|
|
152
|
+
if (name.isDirectory())
|
|
153
|
+
walk(full);
|
|
154
|
+
else if (name.name === "meta.json")
|
|
155
|
+
found.push(full);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
walk(srcRoot);
|
|
159
|
+
return found;
|
|
160
|
+
}
|
|
161
|
+
function keyFromLiveMetaPath(metaPath, srcRoot) {
|
|
162
|
+
const parent = dirname(metaPath);
|
|
163
|
+
const rel = relative(srcRoot, parent);
|
|
164
|
+
return rel.split(/[/\\]/).join("/");
|
|
165
|
+
}
|
|
166
|
+
function readJsonObject(path) {
|
|
167
|
+
try {
|
|
168
|
+
const data = JSON.parse(readFileSync(path, "utf8"));
|
|
169
|
+
if (typeof data !== "object" || data === null || Array.isArray(data))
|
|
170
|
+
return null;
|
|
171
|
+
return data;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function issueNumberFromKey(key) {
|
|
178
|
+
const m = GH_KEY_RE.exec(key);
|
|
179
|
+
if (!m)
|
|
180
|
+
return null;
|
|
181
|
+
const raw = m[3] ?? "";
|
|
182
|
+
// GH_KEY_RE already requires digits; re-check full-string positive int for SLizard.
|
|
183
|
+
if (!/^[1-9]\d*$/.test(raw))
|
|
184
|
+
return null;
|
|
185
|
+
return Number(raw);
|
|
186
|
+
}
|
|
187
|
+
function repoFromKey(key) {
|
|
188
|
+
const m = GH_KEY_RE.exec(key);
|
|
189
|
+
if (!m)
|
|
190
|
+
return null;
|
|
191
|
+
return `${m[1]}/${m[2]}`;
|
|
192
|
+
}
|
|
193
|
+
function fileMtimeMs(path) {
|
|
194
|
+
try {
|
|
195
|
+
return statSync(path).mtimeMs;
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Resolve closed age basis: `closed_at` from raw, else `meta.fetched_at`, else
|
|
203
|
+
* meta.json mtime (#1137).
|
|
204
|
+
*/
|
|
205
|
+
export function resolveClosedAge(raw, meta, metaPath, now) {
|
|
206
|
+
const closedRaw = raw.closed_at;
|
|
207
|
+
if (typeof closedRaw === "string" && closedRaw.trim().length > 0) {
|
|
208
|
+
try {
|
|
209
|
+
const dt = parseIso(closedRaw);
|
|
210
|
+
if (!Number.isNaN(dt.getTime())) {
|
|
211
|
+
return {
|
|
212
|
+
closedAt: closedRaw,
|
|
213
|
+
ageBasis: "closed_at",
|
|
214
|
+
ageMs: Math.max(0, now.getTime() - dt.getTime()),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
/* fall through */
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const fetched = meta && typeof meta.fetched_at === "string" ? meta.fetched_at : null;
|
|
223
|
+
if (fetched) {
|
|
224
|
+
try {
|
|
225
|
+
const dt = parseIso(fetched);
|
|
226
|
+
if (!Number.isNaN(dt.getTime())) {
|
|
227
|
+
return {
|
|
228
|
+
closedAt: null,
|
|
229
|
+
ageBasis: "fetched_at",
|
|
230
|
+
ageMs: Math.max(0, now.getTime() - dt.getTime()),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
/* fall through */
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const mtime = fileMtimeMs(metaPath);
|
|
239
|
+
return {
|
|
240
|
+
closedAt: null,
|
|
241
|
+
ageBasis: "mtime",
|
|
242
|
+
ageMs: mtime > 0 ? Math.max(0, now.getTime() - mtime) : 0,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
/** Align with candidates-log latestDecisions key: `${repo}\0${n}`. */
|
|
246
|
+
function decisionKey(repo, issueNumber) {
|
|
247
|
+
return `${repo}\0${issueNumber}`;
|
|
248
|
+
}
|
|
249
|
+
function decisionKeyAlt(repo, issueNumber) {
|
|
250
|
+
return `${repo}#${issueNumber}`;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Archive closed github-issue cache entries older than the threshold.
|
|
254
|
+
* Skips (does not half-move) entries still referenced by open lifecycle scopes.
|
|
255
|
+
*/
|
|
256
|
+
export function archiveClosedEntries(options = {}) {
|
|
257
|
+
const clock = options.clock ?? systemClock;
|
|
258
|
+
const olderThanDays = options.olderThanDays ?? DEFAULT_ARCHIVE_OLDER_THAN_DAYS;
|
|
259
|
+
if (olderThanDays < 0 || !Number.isFinite(olderThanDays)) {
|
|
260
|
+
throw new CacheError(`--older-than-days must be >= 0 (got ${JSON.stringify(olderThanDays)})`);
|
|
261
|
+
}
|
|
262
|
+
const source = options.source ?? ARCHIVE_SOURCE_GITHUB_ISSUE;
|
|
263
|
+
if (source !== ARCHIVE_SOURCE_GITHUB_ISSUE) {
|
|
264
|
+
throw new CacheError(`archive source '${source}' not supported in v1 (supports: ${ARCHIVE_SOURCE_GITHUB_ISSUE} only)`);
|
|
265
|
+
}
|
|
266
|
+
if (options.repo != null && options.repo !== "" && !REPO_RE.test(options.repo)) {
|
|
267
|
+
throw new CacheError(`invalid --repo '${options.repo}': expected 'owner/repo' (alphanumerics, '.', '_', '-' only)`);
|
|
268
|
+
}
|
|
269
|
+
const cacheRoot = options.cacheRoot ?? ".deft-cache";
|
|
270
|
+
assertWritableCachePath(cacheRoot);
|
|
271
|
+
const projectRoot = options.projectRoot ?? dirname(resolve(cacheRoot));
|
|
272
|
+
const dryRun = options.dryRun === true;
|
|
273
|
+
const thresholdMs = olderThanDays * 24 * 60 * 60 * 1000;
|
|
274
|
+
const now = clock.now();
|
|
275
|
+
let protectedKeys;
|
|
276
|
+
if (options.protectedKeys) {
|
|
277
|
+
protectedKeys = options.protectedKeys;
|
|
278
|
+
}
|
|
279
|
+
else if (options.protectedIssueNumbers) {
|
|
280
|
+
const synthetic = new Set();
|
|
281
|
+
for (const n of options.protectedIssueNumbers)
|
|
282
|
+
synthetic.add(`#${n}`);
|
|
283
|
+
protectedKeys = synthetic;
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
protectedKeys = openLifecycleReferencedKeys(projectRoot);
|
|
287
|
+
}
|
|
288
|
+
const archived = [];
|
|
289
|
+
const skipped = [];
|
|
290
|
+
const srcRoot = join(cacheRoot, source);
|
|
291
|
+
if (!existsSync(srcRoot)) {
|
|
292
|
+
return {
|
|
293
|
+
dryRun,
|
|
294
|
+
olderThanDays,
|
|
295
|
+
source,
|
|
296
|
+
repo: options.repo ?? null,
|
|
297
|
+
archived,
|
|
298
|
+
skipped,
|
|
299
|
+
archivedCount: 0,
|
|
300
|
+
skippedCount: 0,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
for (const metaPath of collectLiveMetaPaths(srcRoot)) {
|
|
304
|
+
const key = keyFromLiveMetaPath(metaPath, srcRoot);
|
|
305
|
+
if (!GH_KEY_RE.test(key)) {
|
|
306
|
+
skipped.push({ source, key, reason: "invalid-key" });
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (options.repo) {
|
|
310
|
+
const keyRepo = repoFromKey(key);
|
|
311
|
+
if (keyRepo !== options.repo)
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
const liveDir = dirname(metaPath);
|
|
315
|
+
const archiveDir = archivedEntryDir(source, key, cacheRoot);
|
|
316
|
+
if (existsSync(archiveDir) && !existsSync(liveDir)) {
|
|
317
|
+
skipped.push({ source, key, reason: "already-archived" });
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const rawPath = join(liveDir, "raw.json");
|
|
321
|
+
if (!existsSync(rawPath)) {
|
|
322
|
+
skipped.push({ source, key, reason: "missing-raw" });
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const raw = readJsonObject(rawPath);
|
|
326
|
+
if (raw === null) {
|
|
327
|
+
skipped.push({ source, key, reason: "missing-raw", detail: "unreadable raw.json" });
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (raw.state !== "closed") {
|
|
331
|
+
skipped.push({ source, key, reason: "not-closed" });
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const meta = readJsonObject(metaPath);
|
|
335
|
+
const age = resolveClosedAge(raw, meta, metaPath, now);
|
|
336
|
+
if (age.ageMs < thresholdMs) {
|
|
337
|
+
skipped.push({
|
|
338
|
+
source,
|
|
339
|
+
key,
|
|
340
|
+
reason: "too-recent",
|
|
341
|
+
detail: `age_days=${(age.ageMs / 86400000).toFixed(2)} basis=${age.ageBasis}`,
|
|
342
|
+
});
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
const issueNum = issueNumberFromKey(key);
|
|
346
|
+
if (isLifecycleProtected(key, protectedKeys)) {
|
|
347
|
+
skipped.push({
|
|
348
|
+
source,
|
|
349
|
+
key,
|
|
350
|
+
reason: "open-lifecycle-scope",
|
|
351
|
+
detail: `issue ${key} referenced in proposed/pending/active`,
|
|
352
|
+
});
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
let preArchiveDecision = null;
|
|
356
|
+
if (options.latestDecisions && issueNum !== null) {
|
|
357
|
+
const keyRepo = repoFromKey(key) ?? "";
|
|
358
|
+
preArchiveDecision =
|
|
359
|
+
options.latestDecisions.get(decisionKey(keyRepo, issueNum)) ??
|
|
360
|
+
options.latestDecisions.get(decisionKeyAlt(keyRepo, issueNum)) ??
|
|
361
|
+
options.latestDecisions.get(String(issueNum)) ??
|
|
362
|
+
null;
|
|
363
|
+
}
|
|
364
|
+
if (options.terminalDecisionOnly) {
|
|
365
|
+
if (preArchiveDecision === null || !TERMINAL_DECISIONS.has(preArchiveDecision)) {
|
|
366
|
+
skipped.push({
|
|
367
|
+
source,
|
|
368
|
+
key,
|
|
369
|
+
reason: "non-terminal-decision",
|
|
370
|
+
detail: preArchiveDecision ?? "none",
|
|
371
|
+
});
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
const candidate = {
|
|
376
|
+
source,
|
|
377
|
+
key,
|
|
378
|
+
liveDir,
|
|
379
|
+
archiveDir,
|
|
380
|
+
closedAt: age.closedAt,
|
|
381
|
+
ageBasis: age.ageBasis,
|
|
382
|
+
ageMs: age.ageMs,
|
|
383
|
+
preArchiveDecision,
|
|
384
|
+
};
|
|
385
|
+
if (!dryRun) {
|
|
386
|
+
assertWritableCachePath(cacheRoot, ARCHIVED_ROOT_SEGMENT, source, ...key.split("/"));
|
|
387
|
+
assertWritableCachePath(cacheRoot, source, ...key.split("/"));
|
|
388
|
+
mkdirSync(dirname(archiveDir), { recursive: true });
|
|
389
|
+
if (existsSync(archiveDir)) {
|
|
390
|
+
// Prefer live overwrite of stale archive only when operator re-archives;
|
|
391
|
+
// refuse silently by skipping to avoid data loss of archived tree.
|
|
392
|
+
skipped.push({
|
|
393
|
+
source,
|
|
394
|
+
key,
|
|
395
|
+
reason: "already-archived",
|
|
396
|
+
detail: "archive path exists; remove or restore first",
|
|
397
|
+
});
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
renameSync(liveDir, archiveDir);
|
|
401
|
+
const archiveMeta = {
|
|
402
|
+
archived_at: utcIso(clock),
|
|
403
|
+
reason: options.reason ?? "closed-age-archive",
|
|
404
|
+
source_path: liveDir.split(/[/\\]/).join("/"),
|
|
405
|
+
key,
|
|
406
|
+
source,
|
|
407
|
+
pre_archive_decision: preArchiveDecision,
|
|
408
|
+
closed_at: age.closedAt,
|
|
409
|
+
age_basis: age.ageBasis,
|
|
410
|
+
};
|
|
411
|
+
const projectDir = dirname(resolve(cacheRoot));
|
|
412
|
+
atomicWriteText(join(archiveDir, ARCHIVE_META_FILENAME), `${JSON.stringify(archiveMeta, null, 2)}\n`, { projectRoot: projectDir });
|
|
413
|
+
appendAudit({
|
|
414
|
+
event: "cache:archive",
|
|
415
|
+
source,
|
|
416
|
+
key,
|
|
417
|
+
timestamp: utcIso(clock),
|
|
418
|
+
reason: archiveMeta.reason,
|
|
419
|
+
archive_dir: archiveDir.split(/[/\\]/).join("/"),
|
|
420
|
+
closed_at: age.closedAt,
|
|
421
|
+
age_basis: age.ageBasis,
|
|
422
|
+
pre_archive_decision: preArchiveDecision,
|
|
423
|
+
}, cacheRoot);
|
|
424
|
+
}
|
|
425
|
+
archived.push(candidate);
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
dryRun,
|
|
429
|
+
olderThanDays,
|
|
430
|
+
source,
|
|
431
|
+
repo: options.repo ?? null,
|
|
432
|
+
archived,
|
|
433
|
+
skipped,
|
|
434
|
+
archivedCount: archived.length,
|
|
435
|
+
skippedCount: skipped.length,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
/** List archived entries newest `archived_at` first. */
|
|
439
|
+
export function listArchivedEntries(options = {}) {
|
|
440
|
+
const source = options.source ?? ARCHIVE_SOURCE_GITHUB_ISSUE;
|
|
441
|
+
if (source !== ARCHIVE_SOURCE_GITHUB_ISSUE) {
|
|
442
|
+
throw new CacheError(`archive source '${source}' not supported in v1 (supports: ${ARCHIVE_SOURCE_GITHUB_ISSUE} only)`);
|
|
443
|
+
}
|
|
444
|
+
if (options.repo != null && options.repo !== "" && !REPO_RE.test(options.repo)) {
|
|
445
|
+
throw new CacheError(`invalid --repo '${options.repo}': expected 'owner/repo' (alphanumerics, '.', '_', '-' only)`);
|
|
446
|
+
}
|
|
447
|
+
const cacheRoot = options.cacheRoot ?? ".deft-cache";
|
|
448
|
+
assertWritableCachePath(cacheRoot);
|
|
449
|
+
const archRoot = join(cacheRoot, ARCHIVED_ROOT_SEGMENT, source);
|
|
450
|
+
const entries = [];
|
|
451
|
+
if (existsSync(archRoot)) {
|
|
452
|
+
for (const metaPath of collectLiveMetaPaths(archRoot)) {
|
|
453
|
+
// meta.json under archived tree; archive-meta is sibling
|
|
454
|
+
const entryDirPath = dirname(metaPath);
|
|
455
|
+
const key = keyFromLiveMetaPath(metaPath, archRoot);
|
|
456
|
+
if (!GH_KEY_RE.test(key))
|
|
457
|
+
continue;
|
|
458
|
+
if (options.repo) {
|
|
459
|
+
const keyRepo = repoFromKey(key);
|
|
460
|
+
if (keyRepo !== options.repo)
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
const archiveMetaPath = join(entryDirPath, ARCHIVE_META_FILENAME);
|
|
464
|
+
let meta;
|
|
465
|
+
if (existsSync(archiveMetaPath)) {
|
|
466
|
+
const parsed = readJsonObject(archiveMetaPath);
|
|
467
|
+
meta = {
|
|
468
|
+
archived_at: typeof parsed?.archived_at === "string"
|
|
469
|
+
? parsed.archived_at
|
|
470
|
+
: utcIso(systemClock, new Date(fileMtimeMs(archiveMetaPath))),
|
|
471
|
+
reason: typeof parsed?.reason === "string" ? parsed.reason : "unknown",
|
|
472
|
+
source_path: typeof parsed?.source_path === "string" ? parsed.source_path : "",
|
|
473
|
+
key: typeof parsed?.key === "string" ? parsed.key : key,
|
|
474
|
+
source: typeof parsed?.source === "string" ? parsed.source : source,
|
|
475
|
+
pre_archive_decision: typeof parsed?.pre_archive_decision === "string" ? parsed.pre_archive_decision : null,
|
|
476
|
+
closed_at: typeof parsed?.closed_at === "string" ? parsed.closed_at : null,
|
|
477
|
+
age_basis: typeof parsed?.age_basis === "string" ? parsed.age_basis : undefined,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
meta = {
|
|
482
|
+
archived_at: utcIso(systemClock, new Date(fileMtimeMs(entryDirPath))),
|
|
483
|
+
reason: "unknown",
|
|
484
|
+
source_path: "",
|
|
485
|
+
key,
|
|
486
|
+
source,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
if (options.since) {
|
|
490
|
+
try {
|
|
491
|
+
const sinceDt = parseIso(options.since);
|
|
492
|
+
const archivedDt = parseIso(meta.archived_at);
|
|
493
|
+
if (archivedDt < sinceDt)
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
/* ignore bad since filter for this entry */
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
entries.push({
|
|
501
|
+
source,
|
|
502
|
+
key,
|
|
503
|
+
archiveDir: entryDirPath,
|
|
504
|
+
archivedAt: meta.archived_at,
|
|
505
|
+
meta,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
entries.sort((a, b) => b.archivedAt.localeCompare(a.archivedAt));
|
|
510
|
+
const limit = options.limit !== undefined && options.limit !== null && options.limit > 0
|
|
511
|
+
? options.limit
|
|
512
|
+
: null;
|
|
513
|
+
const sliced = limit !== null ? entries.slice(0, limit) : entries;
|
|
514
|
+
return {
|
|
515
|
+
entries: sliced,
|
|
516
|
+
count: sliced.length,
|
|
517
|
+
source,
|
|
518
|
+
repo: options.repo ?? null,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
function filesEqual(a, b) {
|
|
522
|
+
try {
|
|
523
|
+
return readFileSync(a).equals(readFileSync(b));
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Move an archived entry back to the live cache path.
|
|
531
|
+
* Idempotent no-op when already live with identical raw.json.
|
|
532
|
+
* Refuse when live exists with different content unless force.
|
|
533
|
+
*/
|
|
534
|
+
export function restoreFromArchive(options = {}) {
|
|
535
|
+
const clock = options.clock ?? systemClock;
|
|
536
|
+
const source = options.source ?? ARCHIVE_SOURCE_GITHUB_ISSUE;
|
|
537
|
+
if (source !== ARCHIVE_SOURCE_GITHUB_ISSUE) {
|
|
538
|
+
throw new CacheError(`archive source '${source}' not supported in v1 (supports: ${ARCHIVE_SOURCE_GITHUB_ISSUE} only)`);
|
|
539
|
+
}
|
|
540
|
+
const cacheRoot = options.cacheRoot ?? ".deft-cache";
|
|
541
|
+
assertWritableCachePath(cacheRoot);
|
|
542
|
+
let key = options.key ?? "";
|
|
543
|
+
if (!key) {
|
|
544
|
+
if (options.issue === undefined || options.issue === null) {
|
|
545
|
+
throw new CacheError("restore requires --issue N or --key owner/repo/N");
|
|
546
|
+
}
|
|
547
|
+
if (!Number.isInteger(options.issue) || options.issue < 1) {
|
|
548
|
+
throw new CacheError(`--issue must be a positive integer (got ${JSON.stringify(options.issue)})`);
|
|
549
|
+
}
|
|
550
|
+
if (!options.repo || !REPO_RE.test(options.repo)) {
|
|
551
|
+
throw new CacheError(`restore with --issue requires --repo owner/name (got ${JSON.stringify(options.repo)})`);
|
|
552
|
+
}
|
|
553
|
+
key = `${options.repo}/${options.issue}`;
|
|
554
|
+
}
|
|
555
|
+
validateKey(source, key);
|
|
556
|
+
const liveDir = liveEntryDir(source, key, cacheRoot);
|
|
557
|
+
const archiveDir = archivedEntryDir(source, key, cacheRoot);
|
|
558
|
+
if (!existsSync(archiveDir)) {
|
|
559
|
+
if (existsSync(liveDir)) {
|
|
560
|
+
return {
|
|
561
|
+
source,
|
|
562
|
+
key,
|
|
563
|
+
status: "already-live",
|
|
564
|
+
liveDir,
|
|
565
|
+
archiveDir,
|
|
566
|
+
detail: "archive missing; live entry present (idempotent)",
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
return {
|
|
570
|
+
source,
|
|
571
|
+
key,
|
|
572
|
+
status: "missing",
|
|
573
|
+
liveDir,
|
|
574
|
+
archiveDir,
|
|
575
|
+
detail: "no archived entry and no live entry",
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
if (existsSync(liveDir)) {
|
|
579
|
+
const liveRaw = join(liveDir, "raw.json");
|
|
580
|
+
const archRaw = join(archiveDir, "raw.json");
|
|
581
|
+
if (existsSync(liveRaw) && existsSync(archRaw) && filesEqual(liveRaw, archRaw)) {
|
|
582
|
+
// Identical content — remove archive tree as completed restore cleanup? keep both for safety:
|
|
583
|
+
// idempotent: report already-live without deleting archive unless force wants clean.
|
|
584
|
+
return {
|
|
585
|
+
source,
|
|
586
|
+
key,
|
|
587
|
+
status: "already-live",
|
|
588
|
+
liveDir,
|
|
589
|
+
archiveDir,
|
|
590
|
+
detail: "live entry matches archived raw.json",
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
if (!options.force) {
|
|
594
|
+
return {
|
|
595
|
+
source,
|
|
596
|
+
key,
|
|
597
|
+
status: "conflict",
|
|
598
|
+
liveDir,
|
|
599
|
+
archiveDir,
|
|
600
|
+
detail: "live path exists with different content; pass --force to replace",
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
assertWritableCachePath(cacheRoot, source, ...key.split("/"));
|
|
604
|
+
rmSync(liveDir, { recursive: true, force: true });
|
|
605
|
+
}
|
|
606
|
+
assertWritableCachePath(cacheRoot, source, ...key.split("/"));
|
|
607
|
+
assertWritableCachePath(cacheRoot, ARCHIVED_ROOT_SEGMENT, source, ...key.split("/"));
|
|
608
|
+
mkdirSync(dirname(liveDir), { recursive: true });
|
|
609
|
+
renameSync(archiveDir, liveDir);
|
|
610
|
+
// Drop archive-meta from live tree so walkers see a normal entry.
|
|
611
|
+
const liveArchiveMeta = join(liveDir, ARCHIVE_META_FILENAME);
|
|
612
|
+
if (existsSync(liveArchiveMeta)) {
|
|
613
|
+
try {
|
|
614
|
+
rmSync(liveArchiveMeta, { force: true });
|
|
615
|
+
}
|
|
616
|
+
catch {
|
|
617
|
+
/* best-effort */
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
appendAudit({
|
|
621
|
+
event: "cache:restore-from-archive",
|
|
622
|
+
source,
|
|
623
|
+
key,
|
|
624
|
+
timestamp: utcIso(clock),
|
|
625
|
+
live_dir: liveDir.split(/[/\\]/).join("/"),
|
|
626
|
+
force: options.force === true,
|
|
627
|
+
}, cacheRoot);
|
|
628
|
+
return {
|
|
629
|
+
source,
|
|
630
|
+
key,
|
|
631
|
+
status: "restored",
|
|
632
|
+
liveDir,
|
|
633
|
+
archiveDir,
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
//# sourceMappingURL=archive.js.map
|
package/dist/cache/index.d.ts
CHANGED
package/dist/cache/index.js
CHANGED