@ngockhoale/ukit 3.0.10 → 3.0.11
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 +10 -0
- package/bin/ukit +5 -9
- package/package.json +1 -1
- package/scripts/bench/memory-baseline.mjs +1 -1
- package/scripts/bench/memory-bench.mjs +1 -1
- package/scripts/bench/memory-canary.mjs +12 -2
- package/scripts/bench/recorder-overhead.mjs +1 -1
- package/scripts/bench/sqlite-spike.mjs +1 -1
- package/scripts/measure-decision-gateway.mjs +1 -1
- package/src/cli/commands/memory.js +3 -0
- package/src/cli/deadline.js +19 -0
- package/src/core/agentRuntime/eventStore.js +38 -10
- package/src/core/agentRuntime/supervisor.js +41 -9
- package/src/core/agentRuntime/vmEngine.js +33 -2
- package/src/core/codeintel/freshness.js +13 -9
- package/src/core/codeintel/retriever.js +25 -20
- package/src/core/fileOps.js +19 -4
- package/src/core/memory/memoryFlags.js +14 -6
- package/src/core/metadata.js +111 -81
- package/src/core/token/index.js +69 -28
- package/src/index/buildIndex.js +7 -0
- package/src/index/impactContext.js +14 -5
- package/src/render/instructionRenderer.js +71 -19
- package/template_project/.claude/hooks/auto-prune-bash.sh +60 -83
- package/template_project/.claude/hooks/block-dangerous.mjs +42 -11
- package/template_project/.claude/skills/docs-manager/init-project-docs.sh +59 -16
- package/template_project/.claude/skills/docx/scripts/document.py +31 -8
- package/template_project/.claude/skills/frontend-vue/composables/indexDBStore.js +26 -5
- package/template_project/.claude/skills/frontend-vue/composables/useRequest.js +24 -12
- package/template_project/.claude/skills/frontend-vue/composables/useSession.js +16 -11
- package/template_project/.claude/skills/frontend-vue/composables/useWebSocket.js +25 -2
- package/template_project/.claude/skills/pptx/scripts/rearrange.py +11 -2
- package/template_project/.claude/skills/root-cause-tracing/find-polluter.sh +20 -5
- package/template_project/.claude/skills/webapp-testing/scripts/with_server.py +37 -6
- package/template_project/.claude/ukit/index/cache-utils.mjs +25 -19
- package/template_project/.claude/ukit/index/impact-context.mjs +21 -13
- package/template_project/.claude/ukit/index/lib/index-core.mjs +96 -14
- package/template_project/.claude/ukit/index/query-index.mjs +13 -11
- package/template_project/.claude/ukit/index/resolve-context.mjs +12 -11
- package/template_project/.claude/ukit/index/route-task.mjs +44 -25
- package/template_project/.claude/ukit/index/triage.mjs +12 -11
- package/template_project/.claude/ukit/index/verify-context.mjs +12 -11
- package/template_project/.claude/ukit/runtime/async-lock.mjs +33 -0
- package/template_project/.claude/ukit/runtime/output-compression.mjs +9 -2
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +18 -2
- package/template_project/.claude/ukit/runtime/token-utils.mjs +60 -18
|
@@ -32,10 +32,24 @@ fi
|
|
|
32
32
|
|
|
33
33
|
NOW_UTC=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
# Single locked read-modify-write for settings.local.json + permission-usage.json —
|
|
36
|
+
# the same serialization point auto-allow-bash.sh uses (<usage>.lock). See the
|
|
37
|
+
# auto-allow header for the full rationale: a stale-snapshot rewrite can silently
|
|
38
|
+
# drop allow rules, and Claude Code re-reads settings.local.json so a torn write
|
|
39
|
+
# would drop the ENTIRE allow list.
|
|
40
|
+
#
|
|
41
|
+
# TASK-008 fail-closed policy (same as auto-allow-bash, TASK-028): the acquisition
|
|
42
|
+
# budget derives from the remaining hook deadline minus a cleanup reserve, capped
|
|
43
|
+
# at a short slice. On busy/abort — or a missing runtime module on a degraded
|
|
44
|
+
# install — the mutation is SKIPPED (fail closed): sensitive permission state is
|
|
45
|
+
# never written unlocked. The old inline lock ran fn() UNLOCKED on non-EEXIST
|
|
46
|
+
# errors and after maxWaitMs (C79-10).
|
|
47
|
+
UKIT_RUNTIME_DIR="$SCRIPT_DIR/../ukit/runtime" node -e '
|
|
48
|
+
// Deadline must exceed the derived lock budget in this block (remaining deadline
|
|
49
|
+
// minus reserve, capped at a short slice) so the watchdog can never fire while
|
|
50
|
+
// the mutation is still legally waiting for the lock.
|
|
38
51
|
const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 8000;
|
|
52
|
+
const LOCK_STARTED_AT = Date.now();
|
|
39
53
|
|
|
40
54
|
// SPEC §8: a failed prune abandons configured work — announce it (success stays silent).
|
|
41
55
|
// Unpruned rules are harmless (stale entries simply never match again), so degrade is
|
|
@@ -52,9 +66,9 @@ setTimeout(() => {
|
|
|
52
66
|
emitDegrade("prune exceeded its deadline; stale Bash auto-allow rules were not pruned this session — the next session retries.");
|
|
53
67
|
process.exit(0);
|
|
54
68
|
}, HOOK_DEADLINE_MS).unref();
|
|
55
|
-
const crypto = require("crypto");
|
|
56
69
|
const fsp = require("fs").promises;
|
|
57
70
|
const path = require("path");
|
|
71
|
+
const { pathToFileURL } = require("url");
|
|
58
72
|
|
|
59
73
|
const settingsPath = process.argv[1];
|
|
60
74
|
const usagePath = process.argv[2];
|
|
@@ -63,6 +77,15 @@ const ttlDays = Math.max(1, Number(process.argv[4]) || 30);
|
|
|
63
77
|
const nowUtc = process.argv[5];
|
|
64
78
|
const cutoff = Date.now() - ttlDays * 24 * 60 * 60 * 1000;
|
|
65
79
|
|
|
80
|
+
async function pathExists(filePath) {
|
|
81
|
+
try {
|
|
82
|
+
await fsp.access(filePath);
|
|
83
|
+
return true;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
66
89
|
async function readJson(filePath) {
|
|
67
90
|
try {
|
|
68
91
|
return JSON.parse(await fsp.readFile(filePath, "utf8"));
|
|
@@ -118,84 +141,7 @@ async function pruneAuditLog() {
|
|
|
118
141
|
await fsp.rename(tempPath, auditPath);
|
|
119
142
|
}
|
|
120
143
|
|
|
121
|
-
|
|
122
|
-
// settings/usage rewrites against each other and against parallel invocations. Atomic
|
|
123
|
-
// writes because Claude Code re-reads settings.local.json — a torn write would drop the
|
|
124
|
-
// ENTIRE allow list and every command would prompt again. Fail-open after 5s: a hook
|
|
125
|
-
// must never hang the chain.
|
|
126
|
-
function isPidAlive(pid) {
|
|
127
|
-
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
128
|
-
try {
|
|
129
|
-
process.kill(pid, 0);
|
|
130
|
-
return true;
|
|
131
|
-
} catch (error) {
|
|
132
|
-
return error?.code === "EPERM";
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
async function readLockOwner(lockPath) {
|
|
137
|
-
const owner = await readJson(path.join(lockPath, "owner"));
|
|
138
|
-
return owner && Number.isInteger(owner.pid) && typeof owner.token === "string"
|
|
139
|
-
? owner
|
|
140
|
-
: null;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
async function withLock(lockPath, fn) {
|
|
144
|
-
const staleMs = 10000;
|
|
145
|
-
const maxWaitMs = 5000;
|
|
146
|
-
const startedAt = Date.now();
|
|
147
|
-
const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString("hex")}`;
|
|
148
|
-
let held = false;
|
|
149
|
-
while (!held) {
|
|
150
|
-
try {
|
|
151
|
-
await fsp.mkdir(path.dirname(lockPath), { recursive: true });
|
|
152
|
-
await fsp.mkdir(lockPath);
|
|
153
|
-
try {
|
|
154
|
-
await fsp.writeFile(path.join(lockPath, "owner"), JSON.stringify({
|
|
155
|
-
pid: process.pid,
|
|
156
|
-
token: ownerToken,
|
|
157
|
-
ts: Date.now(),
|
|
158
|
-
}));
|
|
159
|
-
} catch (error) {
|
|
160
|
-
try {
|
|
161
|
-
await fsp.rm(lockPath, { recursive: true, force: true });
|
|
162
|
-
} catch {}
|
|
163
|
-
throw error;
|
|
164
|
-
}
|
|
165
|
-
held = true;
|
|
166
|
-
} catch (error) {
|
|
167
|
-
if (!error || error.code !== "EEXIST") return fn();
|
|
168
|
-
try {
|
|
169
|
-
const stat = await fsp.stat(lockPath);
|
|
170
|
-
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
171
|
-
const owner = await readLockOwner(lockPath);
|
|
172
|
-
if (!owner || !isPidAlive(owner.pid)) {
|
|
173
|
-
try {
|
|
174
|
-
await fsp.rm(lockPath, { recursive: true, force: true });
|
|
175
|
-
continue;
|
|
176
|
-
} catch {}
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
} catch {}
|
|
180
|
-
if (Date.now() - startedAt > maxWaitMs) break;
|
|
181
|
-
await new Promise((resolve) => setTimeout(resolve, 3 + Math.floor(Math.random() * 9)));
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
try {
|
|
185
|
-
return await fn();
|
|
186
|
-
} finally {
|
|
187
|
-
if (held) {
|
|
188
|
-
const owner = await readLockOwner(lockPath);
|
|
189
|
-
if (owner?.token === ownerToken) {
|
|
190
|
-
try {
|
|
191
|
-
await fsp.rm(lockPath, { recursive: true, force: true });
|
|
192
|
-
} catch {}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
withLock(`${usagePath}.lock`, async () => {
|
|
144
|
+
async function pruneMutation() {
|
|
199
145
|
// Rotate the audit log inside the same lock as the rule prune: both are
|
|
200
146
|
// read-modify-write passes on files these two hooks share.
|
|
201
147
|
await pruneAuditLog();
|
|
@@ -235,7 +181,38 @@ withLock(`${usagePath}.lock`, async () => {
|
|
|
235
181
|
if (prunedCount > 0) {
|
|
236
182
|
await appendAudit("auto_prune_summary", { prunedCount, ttlDays });
|
|
237
183
|
}
|
|
238
|
-
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
(async () => {
|
|
187
|
+
// Fail-closed shared lock (TASK-008, same contract as auto-allow-bash.sh):
|
|
188
|
+
// the lock lives beside the usage file, and a degraded install without the
|
|
189
|
+
// runtime module must SKIP the prune rather than write permission state
|
|
190
|
+
// unlocked — unpruned rules are harmless, torn/lost writes are not.
|
|
191
|
+
const runtimeDir = process.env.UKIT_RUNTIME_DIR || "";
|
|
192
|
+
const lockModulePath = runtimeDir ? path.join(runtimeDir, "async-lock.mjs") : "";
|
|
193
|
+
let lockModule = null;
|
|
194
|
+
if (lockModulePath && (await pathExists(lockModulePath))) {
|
|
195
|
+
try {
|
|
196
|
+
lockModule = await import(pathToFileURL(lockModulePath).href);
|
|
197
|
+
} catch {
|
|
198
|
+
lockModule = null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (!lockModule || typeof lockModule.withAsyncLock !== "function") {
|
|
202
|
+
// Degraded install (runtime module missing): fail closed — skip the write,
|
|
203
|
+
// exit 0, retry next session.
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const deadlineMs = lockModule.lockBudgetMs({
|
|
207
|
+
hookDeadlineMs: HOOK_DEADLINE_MS,
|
|
208
|
+
startedAt: LOCK_STARTED_AT,
|
|
209
|
+
});
|
|
210
|
+
const outcome = await lockModule.withAsyncLock(usagePath, { deadlineMs }, pruneMutation);
|
|
211
|
+
if (!outcome.ok) {
|
|
212
|
+
// busy or aborted: fail closed — write nothing unlocked, exit 0.
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
})().catch((error) => {
|
|
239
216
|
emitDegrade(`prune failed (${error?.message ?? error}); stale Bash auto-allow rules were not pruned this session — the next session retries.`);
|
|
240
217
|
process.exit(0);
|
|
241
218
|
});
|
|
@@ -133,23 +133,45 @@ const basename = (token) => token.split('/').pop();
|
|
|
133
133
|
// caller fails closed, never silently allows). UKIT_REALPATH_MISSING=1 (set by
|
|
134
134
|
// the wrapper when no system realpath exists) forces the same fail-closed —
|
|
135
135
|
// the pre-port gate could not prove containment without realpath.
|
|
136
|
-
|
|
136
|
+
//
|
|
137
|
+
// TASK-007 (C79-09): every fs probe below is ASYNC (libuv) and raced against
|
|
138
|
+
// the remaining scan budget — never realpathSync/lstatSync. A sync probe on a
|
|
139
|
+
// stalled mount pins the event loop, so runHook's Promise.race deadline timer
|
|
140
|
+
// can never fire and the gate freezes instead of refusing. A timed-out or
|
|
141
|
+
// erroring probe degrades to the lexical ancestor walk; a timed-out final
|
|
142
|
+
// realpath returns '' => downstream fail-closed.
|
|
143
|
+
const FS_OP_BUDGET_MS = 3000;
|
|
144
|
+
|
|
145
|
+
function timedFs(op, deadlineMs, signal) {
|
|
146
|
+
if (signal && signal.aborted) return Promise.reject(new Error('aborted'));
|
|
147
|
+
const budget = Number.isFinite(deadlineMs) ? deadlineMs : FS_OP_BUDGET_MS;
|
|
148
|
+
let timer;
|
|
149
|
+
const timeout = new Promise((_, reject) => {
|
|
150
|
+
timer = setTimeout(() => reject(new Error('fs probe timed out')), Math.max(0, budget));
|
|
151
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
152
|
+
});
|
|
153
|
+
return Promise.race([op(), timeout]).finally(() => clearTimeout(timer));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function canonicalPath(p, env, deadlineMs, signal) {
|
|
137
157
|
if (env && env.UKIT_REALPATH_MISSING === '1') return '';
|
|
158
|
+
const deadlineAt = Date.now() + (Number.isFinite(deadlineMs) ? deadlineMs : FS_OP_BUDGET_MS);
|
|
159
|
+
const remaining = () => deadlineAt - Date.now();
|
|
138
160
|
try {
|
|
139
|
-
const out =
|
|
161
|
+
const out = await timedFs(() => fsp.realpath(p), remaining(), signal);
|
|
140
162
|
if (out) return out;
|
|
141
163
|
} catch {}
|
|
142
164
|
let rest = '';
|
|
143
165
|
let cur = p;
|
|
144
|
-
const existsOrLink = (q) => {
|
|
166
|
+
const existsOrLink = async (q) => {
|
|
145
167
|
try {
|
|
146
|
-
|
|
168
|
+
await timedFs(() => fsp.lstat(q), remaining(), signal);
|
|
147
169
|
return true;
|
|
148
170
|
} catch {
|
|
149
171
|
return false;
|
|
150
172
|
}
|
|
151
173
|
};
|
|
152
|
-
while (cur && cur !== '/' && !existsOrLink(cur)) {
|
|
174
|
+
while (cur && cur !== '/' && !(await existsOrLink(cur))) {
|
|
153
175
|
rest = `/${path.basename(cur)}${rest}`;
|
|
154
176
|
const parent = cur.slice(0, cur.lastIndexOf('/')) || (cur.startsWith('/') ? '/' : cur);
|
|
155
177
|
if (parent === cur) break;
|
|
@@ -157,15 +179,18 @@ function canonicalPath(p, env) {
|
|
|
157
179
|
}
|
|
158
180
|
if (!cur) cur = '/';
|
|
159
181
|
try {
|
|
160
|
-
const out =
|
|
182
|
+
const out = await timedFs(() => fsp.realpath(cur), remaining(), signal);
|
|
161
183
|
return `${out}${rest}`;
|
|
162
184
|
} catch {
|
|
163
185
|
return '';
|
|
164
186
|
}
|
|
165
187
|
}
|
|
166
188
|
|
|
167
|
-
function evaluate({ command, projectRoot, env }) {
|
|
189
|
+
async function evaluate({ command, projectRoot, env, deadlineMs, signal }) {
|
|
168
190
|
if (!command) return { code: 0, stdout: '', stderr: '' };
|
|
191
|
+
if (signal && signal.aborted) return TIMEOUT_REFUSAL;
|
|
192
|
+
const deadlineAt = Date.now() + (Number.isFinite(deadlineMs) ? deadlineMs : FS_OP_BUDGET_MS);
|
|
193
|
+
const remaining = () => deadlineAt - Date.now();
|
|
169
194
|
|
|
170
195
|
// Quoted-span scrub: only when the invoked command is a pure text tool AND
|
|
171
196
|
// its output cannot reach a shell — `echo "rm -rf /" | bash` executes the
|
|
@@ -185,7 +210,7 @@ function evaluate({ command, projectRoot, env }) {
|
|
|
185
210
|
}
|
|
186
211
|
|
|
187
212
|
// rm handling: allow safe cleanup targets only; everything else defers.
|
|
188
|
-
const projectRootCanon = canonicalPath(projectRoot, env);
|
|
213
|
+
const projectRootCanon = await canonicalPath(projectRoot, env, remaining(), signal);
|
|
189
214
|
|
|
190
215
|
let verdictUnsafe = false;
|
|
191
216
|
let verdictGeneric = false;
|
|
@@ -251,7 +276,7 @@ function evaluate({ command, projectRoot, env }) {
|
|
|
251
276
|
allSafe = false;
|
|
252
277
|
} else {
|
|
253
278
|
const abs = target.startsWith('/') ? target : `${projectRootCanon}/${target}`;
|
|
254
|
-
const canon = canonicalPath(abs, env);
|
|
279
|
+
const canon = await canonicalPath(abs, env, remaining(), signal);
|
|
255
280
|
if (!projectRootCanon || !canon || !canon.startsWith(`${projectRootCanon}/`)) {
|
|
256
281
|
allSafe = false;
|
|
257
282
|
}
|
|
@@ -321,7 +346,13 @@ export async function runHook(ctx) {
|
|
|
321
346
|
try {
|
|
322
347
|
const run = (async () => {
|
|
323
348
|
if (ctx && ctx.signal && ctx.signal.aborted) return TIMEOUT_REFUSAL;
|
|
324
|
-
return evaluate({
|
|
349
|
+
return evaluate({
|
|
350
|
+
command: extracted.command,
|
|
351
|
+
projectRoot,
|
|
352
|
+
env,
|
|
353
|
+
deadlineMs,
|
|
354
|
+
signal: ctx && ctx.signal,
|
|
355
|
+
});
|
|
325
356
|
})();
|
|
326
357
|
return await Promise.race([run, timeout]);
|
|
327
358
|
} catch {
|
|
@@ -366,7 +397,7 @@ async function main() {
|
|
|
366
397
|
try {
|
|
367
398
|
const extracted = extractCommand({ rawInput, env });
|
|
368
399
|
const command = extracted.unparseable ? '' : extracted.command;
|
|
369
|
-
const verdict = evaluate({ command, projectRoot, env });
|
|
400
|
+
const verdict = await evaluate({ command, projectRoot, env, deadlineMs: HOOK_DEADLINE_MS });
|
|
370
401
|
if (verdict.stdout) process.stdout.write(verdict.stdout);
|
|
371
402
|
if (verdict.stderr) process.stderr.write(verdict.stderr);
|
|
372
403
|
process.exit(verdict.code);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
+
set -euo pipefail
|
|
2
3
|
|
|
3
4
|
# Script khởi tạo docs cho project mới
|
|
4
|
-
# Usage: ./init-project-docs.sh "Project Name"
|
|
5
|
+
# Usage: ./init-project-docs.sh "Project Name" [author]
|
|
5
6
|
|
|
6
7
|
PROJECT_NAME=${1:-"New Project"}
|
|
7
8
|
AUTHOR=${2:-$(git config user.name 2>/dev/null || echo "Developer")}
|
|
@@ -12,40 +13,82 @@ echo "📅 Date: $DATE"
|
|
|
12
13
|
echo "👤 Author: $AUTHOR"
|
|
13
14
|
echo ""
|
|
14
15
|
|
|
16
|
+
# C80-19: verify every template exists BEFORE touching ./docs — previously a
|
|
17
|
+
# missing template + redirect produced empty files while `rm -rf ./docs` had
|
|
18
|
+
# already destroyed the old docs (data loss).
|
|
19
|
+
SKILL_DIR="$HOME/.claude/skills/docs-manager/templates"
|
|
20
|
+
TEMPLATES=(
|
|
21
|
+
"README.md.template:docs/README.md"
|
|
22
|
+
"project.md.template:docs/project.md"
|
|
23
|
+
"memory.md.template:docs/memory.md"
|
|
24
|
+
"agent-roles.md.template:docs/agents/agent-roles.md"
|
|
25
|
+
"coding-conventions.md.template:docs/standards/coding-conventions.md"
|
|
26
|
+
"onboarding.md.template:docs/guides/onboarding.md"
|
|
27
|
+
)
|
|
28
|
+
missing=0
|
|
29
|
+
for pair in "${TEMPLATES[@]}"; do
|
|
30
|
+
tpl="${pair%%:*}"
|
|
31
|
+
if [ ! -f "$SKILL_DIR/$tpl" ]; then
|
|
32
|
+
echo "❌ Missing template: $SKILL_DIR/$tpl" >&2
|
|
33
|
+
missing=1
|
|
34
|
+
fi
|
|
35
|
+
done
|
|
36
|
+
if [ "$missing" -ne 0 ]; then
|
|
37
|
+
echo "❌ Templates incomplete — aborting without touching docs/" >&2
|
|
38
|
+
exit 1
|
|
39
|
+
fi
|
|
40
|
+
|
|
15
41
|
# Kiểm tra đã có docs/ chưa
|
|
16
42
|
if [ -d "./docs" ]; then
|
|
17
43
|
echo "⚠️ Warning: docs/ folder already exists!"
|
|
18
|
-
read -p "Overwrite? (y/N): " -n 1 -r
|
|
44
|
+
read -p "Overwrite? (y/N): " -n 1 -r || true
|
|
19
45
|
echo
|
|
20
|
-
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
|
46
|
+
if [[ ! ${REPLY:-} =~ ^[Yy]$ ]]; then
|
|
21
47
|
echo "❌ Cancelled"
|
|
22
48
|
exit 1
|
|
23
49
|
fi
|
|
24
|
-
|
|
50
|
+
# C80-19: preserve the old tree by renaming it BEFORE any write — the
|
|
51
|
+
# previous script ran `rm -rf ./docs` up front, so a failure destroyed docs.
|
|
52
|
+
backup_dir="./docs.bak-$(date +%Y%m%d-%H%M%S)"
|
|
53
|
+
mv "./docs" "$backup_dir"
|
|
54
|
+
echo "🗂️ Previous docs preserved at $backup_dir (remove manually when satisfied)"
|
|
25
55
|
fi
|
|
26
56
|
|
|
27
57
|
# Tạo cấu trúc
|
|
28
58
|
echo "📁 Creating folder structure..."
|
|
29
59
|
mkdir -p docs/{architecture,agents/prompts,standards,workflows,context,guides,models,integrations}
|
|
30
60
|
|
|
31
|
-
# Copy templates
|
|
32
|
-
SKILL_DIR="$HOME/.claude/skills/docs-manager/templates"
|
|
33
|
-
|
|
34
61
|
echo "📄 Creating files from templates..."
|
|
35
62
|
|
|
36
|
-
#
|
|
63
|
+
# Escape replacement text for sed (&, \, |, /, newlines) — C80-19: an
|
|
64
|
+
# unescaped metachar in PROJECT_NAME/AUTHOR corrupted output or broke sed.
|
|
65
|
+
sed_escape() {
|
|
66
|
+
printf '%s' "$1" | sed -e 's/[&|\\\/]/\\&/g'
|
|
67
|
+
}
|
|
68
|
+
PROJECT_NAME_E=$(sed_escape "$PROJECT_NAME")
|
|
69
|
+
AUTHOR_E=$(sed_escape "$AUTHOR")
|
|
70
|
+
DATE_E=$(sed_escape "$DATE")
|
|
71
|
+
|
|
37
72
|
replace_placeholders() {
|
|
38
73
|
local file=$1
|
|
39
|
-
sed "s/{{PROJECT_NAME}}/$
|
|
74
|
+
sed "s/{{PROJECT_NAME}}/$PROJECT_NAME_E/g; s/{{AUTHOR}}/$AUTHOR_E/g; s/{{DATE}}/$DATE_E/g" "$file"
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Write to a temp file first; only install on success so a sed failure never
|
|
78
|
+
# leaves an empty/partial doc.
|
|
79
|
+
gen_doc() {
|
|
80
|
+
local tpl=$1 dest=$2
|
|
81
|
+
local tmp
|
|
82
|
+
tmp=$(mktemp)
|
|
83
|
+
replace_placeholders "$SKILL_DIR/$tpl" > "$tmp"
|
|
84
|
+
mv "$tmp" "$dest"
|
|
40
85
|
}
|
|
41
86
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
replace_placeholders "$SKILL_DIR/coding-conventions.md.template" > docs/standards/coding-conventions.md
|
|
48
|
-
replace_placeholders "$SKILL_DIR/onboarding.md.template" > docs/guides/onboarding.md
|
|
87
|
+
for pair in "${TEMPLATES[@]}"; do
|
|
88
|
+
tpl="${pair%%:*}"
|
|
89
|
+
dest="${pair#*:}"
|
|
90
|
+
gen_doc "$tpl" "$dest"
|
|
91
|
+
done
|
|
49
92
|
|
|
50
93
|
# Tạo empty files
|
|
51
94
|
echo "📝 Creating placeholder files..."
|
|
@@ -639,11 +639,17 @@ class Document:
|
|
|
639
639
|
# Create temporary directory with subdirectories for unpacked content and baseline
|
|
640
640
|
self.temp_dir = tempfile.mkdtemp(prefix="docx_")
|
|
641
641
|
self.unpacked_path = Path(self.temp_dir) / "unpacked"
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
642
|
+
try:
|
|
643
|
+
shutil.copytree(self.original_path, self.unpacked_path)
|
|
644
|
+
|
|
645
|
+
# Pack original directory into temporary .docx for validation baseline (outside unpacked dir)
|
|
646
|
+
self.original_docx = Path(self.temp_dir) / "original.docx"
|
|
647
|
+
pack_document(self.original_path, self.original_docx, validate=False)
|
|
648
|
+
except Exception:
|
|
649
|
+
# C80-20: a failed constructor must not rely on __del__ timing —
|
|
650
|
+
# clean the temp dir right here before the exception propagates.
|
|
651
|
+
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
|
652
|
+
raise
|
|
647
653
|
|
|
648
654
|
self.word_path = self.unpacked_path / "word"
|
|
649
655
|
|
|
@@ -830,10 +836,27 @@ class Document:
|
|
|
830
836
|
self.next_comment_id += 1
|
|
831
837
|
return comment_id
|
|
832
838
|
|
|
839
|
+
def close(self):
|
|
840
|
+
"""Release the temp working tree (C80-20: deterministic, not __del__)."""
|
|
841
|
+
temp_dir = getattr(self, "temp_dir", None)
|
|
842
|
+
if temp_dir and Path(temp_dir).exists():
|
|
843
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
844
|
+
self.temp_dir = None
|
|
845
|
+
|
|
846
|
+
def __enter__(self):
|
|
847
|
+
return self
|
|
848
|
+
|
|
849
|
+
def __exit__(self, exc_type, exc, tb):
|
|
850
|
+
self.close()
|
|
851
|
+
return False
|
|
852
|
+
|
|
833
853
|
def __del__(self):
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
854
|
+
# Safety net only — callers should use `with` or close() explicitly;
|
|
855
|
+
# __del__ timing is non-deterministic (GC cycles, interpreter kill).
|
|
856
|
+
try:
|
|
857
|
+
self.close()
|
|
858
|
+
except Exception:
|
|
859
|
+
pass
|
|
837
860
|
|
|
838
861
|
def validate(self) -> None:
|
|
839
862
|
"""
|
|
@@ -73,6 +73,10 @@ export const addDataIDB = async (storage, data) => {
|
|
|
73
73
|
return new Promise((resolve, reject) => {
|
|
74
74
|
try{
|
|
75
75
|
let transaction = db.transaction([storage], 'readwrite');
|
|
76
|
+
// C80-16: transaction abort must settle the outer promise.
|
|
77
|
+
transaction.onerror = () => {
|
|
78
|
+
reject('Error writing to object store (transaction failed)');
|
|
79
|
+
};
|
|
76
80
|
let objectStore = transaction.objectStore(storage);
|
|
77
81
|
let clearRequest = objectStore.clear();
|
|
78
82
|
|
|
@@ -89,9 +93,13 @@ export const addDataIDB = async (storage, data) => {
|
|
|
89
93
|
resolve(); // Resolve immediately if no data is provided
|
|
90
94
|
}
|
|
91
95
|
}
|
|
96
|
+
clearRequest.onerror = () => {
|
|
97
|
+
reject('Error clearing object store');
|
|
98
|
+
};
|
|
92
99
|
} catch (e) {
|
|
93
100
|
window.indexedDB.deleteDatabase(db_state.DB_NAME);
|
|
94
101
|
createStoreIDB(storage);
|
|
102
|
+
reject(e); // C80-16: never leave the await hanging
|
|
95
103
|
}
|
|
96
104
|
|
|
97
105
|
});
|
|
@@ -114,23 +122,36 @@ export const getDataIDB = async (storage) => {
|
|
|
114
122
|
return;
|
|
115
123
|
}
|
|
116
124
|
let trans = db.transaction([storage],'readonly');
|
|
125
|
+
let store = trans.objectStore(storage);
|
|
126
|
+
let to_return = [];
|
|
127
|
+
|
|
117
128
|
trans.oncomplete = e => {
|
|
118
129
|
resolve(to_return[0]);
|
|
119
130
|
};
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
131
|
+
// C80-15: a failed transaction/cursor must settle the promise —
|
|
132
|
+
// previously the outer await hung forever.
|
|
133
|
+
trans.onerror = () => {
|
|
134
|
+
resolve([]);
|
|
135
|
+
};
|
|
136
|
+
trans.onabort = () => {
|
|
137
|
+
resolve([]);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const cursorRequest = store.openCursor();
|
|
141
|
+
cursorRequest.onsuccess = e => {
|
|
125
142
|
let cursor = e.target.result;
|
|
126
143
|
if (cursor) {
|
|
127
144
|
to_return.push(cursor.value)
|
|
128
145
|
cursor.continue();
|
|
129
146
|
}
|
|
130
147
|
};
|
|
148
|
+
cursorRequest.onerror = () => {
|
|
149
|
+
resolve(to_return[0] ?? []);
|
|
150
|
+
};
|
|
131
151
|
} catch (e) {
|
|
132
152
|
window.indexedDB.deleteDatabase(db_state.DB_NAME);
|
|
133
153
|
createStoreIDB(storage);
|
|
154
|
+
resolve([]); // C80-15: never leave the await hanging
|
|
134
155
|
}
|
|
135
156
|
});
|
|
136
157
|
};
|
|
@@ -70,10 +70,6 @@ export const request_origin = async (url, data = {}, method = "post") => {
|
|
|
70
70
|
state.loading = true;
|
|
71
71
|
}
|
|
72
72
|
let response = await ax[method.toLowerCase()](url, data);
|
|
73
|
-
state.loading_count -= 1;
|
|
74
|
-
if (state.loading_count === 0) {
|
|
75
|
-
state.loading = false;
|
|
76
|
-
}
|
|
77
73
|
if(typeof response?.data === 'object'){
|
|
78
74
|
return response?.data;
|
|
79
75
|
} else {
|
|
@@ -82,6 +78,14 @@ export const request_origin = async (url, data = {}, method = "post") => {
|
|
|
82
78
|
}
|
|
83
79
|
} catch {
|
|
84
80
|
return [];
|
|
81
|
+
} finally {
|
|
82
|
+
// C80-11: decrement on every path — a thrown request must not wedge the
|
|
83
|
+
// global loading spinner forever.
|
|
84
|
+
state.loading_count -= 1;
|
|
85
|
+
if (state.loading_count <= 0) {
|
|
86
|
+
state.loading_count = 0;
|
|
87
|
+
state.loading = false;
|
|
88
|
+
}
|
|
85
89
|
}
|
|
86
90
|
};
|
|
87
91
|
/**
|
|
@@ -128,15 +132,19 @@ export const request = async (url, data = {}, method = "post", with_loading=true
|
|
|
128
132
|
const gzipBuffer = decompressData(response?.data);
|
|
129
133
|
to_return = JSON.parse(gzipBuffer);
|
|
130
134
|
}
|
|
135
|
+
return to_return;
|
|
136
|
+
} catch {
|
|
137
|
+
return [];
|
|
138
|
+
} finally {
|
|
139
|
+
// C80-09: decrement on every path — a thrown request must not wedge the
|
|
140
|
+
// global loading spinner forever.
|
|
131
141
|
if(with_loading){
|
|
132
142
|
state.loading_count -= 1;
|
|
133
|
-
if (state.loading_count
|
|
143
|
+
if (state.loading_count <= 0) {
|
|
144
|
+
state.loading_count = 0;
|
|
134
145
|
state.loading = false;
|
|
135
146
|
}
|
|
136
147
|
}
|
|
137
|
-
return to_return;
|
|
138
|
-
} catch {
|
|
139
|
-
return [];
|
|
140
148
|
}
|
|
141
149
|
};
|
|
142
150
|
|
|
@@ -174,15 +182,19 @@ export const requestForm = async (url, data = {}, method = "post", with_loading=
|
|
|
174
182
|
const gzipBuffer = decompressData(response?.data);
|
|
175
183
|
to_return = JSON.parse(gzipBuffer);
|
|
176
184
|
}
|
|
185
|
+
return to_return;
|
|
186
|
+
} catch {
|
|
187
|
+
return [];
|
|
188
|
+
} finally {
|
|
189
|
+
// C80-10: decrement on every path — a thrown request must not wedge the
|
|
190
|
+
// global loading spinner forever.
|
|
177
191
|
if(with_loading){
|
|
178
192
|
state.loading_count -= 1;
|
|
179
|
-
if (state.loading_count
|
|
193
|
+
if (state.loading_count <= 0) {
|
|
194
|
+
state.loading_count = 0;
|
|
180
195
|
state.loading = false;
|
|
181
196
|
}
|
|
182
197
|
}
|
|
183
|
-
return to_return;
|
|
184
|
-
} catch {
|
|
185
|
-
return [];
|
|
186
198
|
}
|
|
187
199
|
};
|
|
188
200
|
|
|
@@ -120,19 +120,24 @@ export const getSession = (key) => {
|
|
|
120
120
|
if (process.client) {
|
|
121
121
|
const storedSession = localStorage.getItem(SESSION_KEY)
|
|
122
122
|
if (storedSession) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
session.value = data
|
|
132
|
-
return session.value[key]
|
|
123
|
+
try {
|
|
124
|
+
const { data, expiry, hash } = JSON.parse(storedSession)
|
|
125
|
+
// check hash — C80-14: non-JSON or a blob missing expiry/hash must
|
|
126
|
+
// clear + return undefined, not throw into page guards.
|
|
127
|
+
const check_hash = encrypt_string(expiry.toString());
|
|
128
|
+
if (hash !== check_hash) {
|
|
129
|
+
clearSession();
|
|
130
|
+
return undefined;
|
|
133
131
|
} else {
|
|
134
|
-
|
|
132
|
+
if (expiry > Date.now()) {
|
|
133
|
+
session.value = data
|
|
134
|
+
return session.value[key]
|
|
135
|
+
} else {
|
|
136
|
+
clearSession()
|
|
137
|
+
}
|
|
135
138
|
}
|
|
139
|
+
} catch {
|
|
140
|
+
clearSession()
|
|
136
141
|
}
|
|
137
142
|
}
|
|
138
143
|
}
|