feat(persona): 時區改為+08:00等值轉換、補機密遮蔽,並接上shared共用規範
依 todo.md 執行的規範治理專案: - persona-lib.mjs 的 iso()/nowIso() 改為輸出 +08:00 offset(等值瞬間轉換, 非加8小時),新增 nowDisplay()(人讀顯示格式)與 todayTaipei()(台北曆日 日期鍵),修正 11 處原本用 UTC 字串切片產生日期鍵的錯誤用法;新增 redactSecrets() 並套用到 persona-gitea.mjs 的 Gitea 錯誤路徑,避免機密 外洩。 - 新增一次性遷移腳本 scripts/migrate-tz.mjs(備份/dry-run/冪等/衝突偵測 中止),供之後有真實人格資料需要轉換時使用。 - persona 13 個 skill 與 AGENTS.md 接上 /jsc-shared 共用規範(spec-output、 spec-script-path、spec-subagent、spec-gitea、spec-git-safety、 spec-skill-invocation 等),不再各自重抄,且完全未變動人格行為本身的規範 (情緒模型、記憶衰減、劇場模式、鎖與隔離機制等逐字保留)。 - selftest 全數通過(788/788,含本次新增的時區邊界/冪等性測試)。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+19
-11
@@ -77,7 +77,7 @@ export async function nextCodeAcrossMachines(romaji, { owner = null } = {}) {
|
||||
const taken = remote.personas.map((p) => p.code);
|
||||
return { code: nextCode(romaji, { taken }), checked_remote: true, taken };
|
||||
} catch (err) {
|
||||
return { code: local, checked_remote: false, reason: String(err.message || err) };
|
||||
return { code: local, checked_remote: false, reason: pl.redactSecrets(String(err.message || err)) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,8 @@ export function updateSyncState(slug, mutate) {
|
||||
// 環境與 API
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
// giteaEnv()/giteaProblem() 實作 /jsc-shared:spec-gitea 的『token 解析優先序』章節:
|
||||
// 專用變數(PERSONA_GITEA_TOKEN)插在最前面,其次才是 GITEA_TOKEN,順序不可打亂。
|
||||
export function giteaEnv() {
|
||||
const host = String(process.env.PERSONA_GITEA_HOST || process.env.GITEA_HOST || "").replace(/\/+$/, "");
|
||||
const token = String(process.env.PERSONA_GITEA_TOKEN || process.env.GITEA_TOKEN || "");
|
||||
@@ -194,6 +196,8 @@ export function giteaProblem() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// api() 實作 /jsc-shared:spec-gitea 的『API 呼叫慣例』章節:
|
||||
// base 為 `${host}/api/v1`,標頭帶 `Authorization: token <token>`,body 一律 UTF-8 JSON。
|
||||
async function api(method, route, body = null) {
|
||||
const { host, token } = giteaEnv();
|
||||
const res = await fetch(`${host}/api/v1${route}`, {
|
||||
@@ -223,7 +227,7 @@ export async function giteaLogin() {
|
||||
const cached = pl.readJson(ownerCachePath(), {}) ?? {};
|
||||
if (cached.host === env.host && cached.login) return cached.login;
|
||||
const res = await api("GET", "/user");
|
||||
if (!res.ok || !res.json?.login) throw new Error(`取不到 Gitea 帳號(HTTP ${res.status}):${res.text.slice(0, 120)}`);
|
||||
if (!res.ok || !res.json?.login) throw new Error(`取不到 Gitea 帳號(HTTP ${res.status}):${pl.redactSecrets(res.text).slice(0, 120)}`);
|
||||
pl.writeJson(ownerCachePath(), { host: env.host, login: res.json.login, cached_at: pl.nowIso() });
|
||||
return res.json.login;
|
||||
}
|
||||
@@ -241,7 +245,7 @@ async function listRepos(owner, me) {
|
||||
const out = [];
|
||||
for (let page = 1; page <= 40; page += 1) {
|
||||
const res = await api("GET", `${route}?page=${page}&limit=50`);
|
||||
if (!res.ok) throw new Error(`列出 ${owner} 的存取庫失敗(HTTP ${res.status}):${res.text.slice(0, 160)}`);
|
||||
if (!res.ok) throw new Error(`列出 ${owner} 的存取庫失敗(HTTP ${res.status}):${pl.redactSecrets(res.text).slice(0, 160)}`);
|
||||
const batch = Array.isArray(res.json) ? res.json : [];
|
||||
out.push(...batch);
|
||||
if (batch.length < 50) break;
|
||||
@@ -304,7 +308,7 @@ export async function ensureRepo(owner, code, { description = "", private_ = tru
|
||||
description: description || `jsc-persona 人格 ${code}`,
|
||||
auto_init: false,
|
||||
});
|
||||
if (!res.ok) throw new Error(`建立存取庫 ${owner}/${code} 失敗(HTTP ${res.status}):${res.text.slice(0, 160)}`);
|
||||
if (!res.ok) throw new Error(`建立存取庫 ${owner}/${code} 失敗(HTTP ${res.status}):${pl.redactSecrets(res.text).slice(0, 160)}`);
|
||||
void env;
|
||||
return { repo: res.json, created: true };
|
||||
}
|
||||
@@ -318,7 +322,7 @@ export async function ensureWikiHome(owner, code, content) {
|
||||
content_base64: Buffer.from(content, "utf8").toString("base64"),
|
||||
message: "init: 人格設定百科",
|
||||
});
|
||||
if (!res.ok) throw new Error(`建立 Wiki 首頁失敗(HTTP ${res.status}):${res.text.slice(0, 160)}`);
|
||||
if (!res.ok) throw new Error(`建立 Wiki 首頁失敗(HTTP ${res.status}):${pl.redactSecrets(res.text).slice(0, 160)}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -367,7 +371,7 @@ export function git(args, cwd = null) {
|
||||
|
||||
function gitOrThrow(args, cwd, what) {
|
||||
const res = git(args, cwd);
|
||||
if (!res.ok) throw new Error(`${what} 失敗:git ${args.join(" ")}\n ${res.stderr || res.stdout}`);
|
||||
if (!res.ok) throw new Error(`${what} 失敗:git ${args.join(" ")}\n ${pl.redactSecrets(res.stderr || res.stdout)}`);
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -478,6 +482,10 @@ const WIKI_PREFIX = { memory: "Memory", mindmap: "Mindmap", relations: "Relation
|
||||
* Gitea 的 wiki **只有根目錄的 .md 會變成頁面**(1.27 實測:子目錄頁面連結 404),
|
||||
* 所以低頻區的檔案要攤平成根層檔名(`memory/long-term/x.md` → `Memory-x.md`,
|
||||
* 網頁上顯示為「Memory x」),再用 `_paths.json` 記住原本的路徑,pull 時才還原得回去。
|
||||
*
|
||||
* 對應 /jsc-shared:spec-gitea 的『Wiki 頁名轉義規則』第 3 點:本檔走 git clone/push
|
||||
* 直接寫檔名,不反推 Gitea 的 title↔sub_url 內部轉義,改用 `_paths.json` manifest
|
||||
* 自己記住「工作路徑 → 儲存檔名」的對應。
|
||||
*/
|
||||
export function wikiName(rel) {
|
||||
if (!rel.includes("/")) return rel;
|
||||
@@ -718,7 +726,7 @@ export async function pushArea(slug, area, { message = "", code = null, owner =
|
||||
});
|
||||
return { ok: true, changed: false, files: staged.length, area, code: theCode };
|
||||
}
|
||||
gitOrThrow(["commit", "-q", "-m", message || `sync(${area}): ${pl.nowIso()}`], dir, "git commit");
|
||||
gitOrThrow(["commit", "-q", "-m", message || `sync(${area}): ${pl.nowDisplay()}`], dir, "git commit");
|
||||
let pushed = git(["push", "-q", "-u", "origin", "HEAD"], dir);
|
||||
let overwrote = null;
|
||||
if (!pushed.ok) {
|
||||
@@ -754,7 +762,7 @@ export async function pushArea(slug, area, { message = "", code = null, owner =
|
||||
}
|
||||
pushed = git(["push", "-q", "-u", "origin", "HEAD"], dir);
|
||||
}
|
||||
if (!pushed.ok) return { ok: false, area, code: theCode, reason: pushed.stderr || pushed.stdout };
|
||||
if (!pushed.ok) return { ok: false, area, code: theCode, reason: pl.redactSecrets(pushed.stderr || pushed.stdout) };
|
||||
updateSyncState(slug, (state) => {
|
||||
state.code = theCode;
|
||||
state.owner = theOwner;
|
||||
@@ -802,7 +810,7 @@ export async function pullArea(slug, area, { code = null, owner = null, force =
|
||||
clearStaleIndexLock(dir);
|
||||
const localChanged = new Set(statusPaths(dir));
|
||||
const fetched = git(["fetch", "--quiet", "origin"], dir);
|
||||
if (!fetched.ok) return { ok: false, area, reason: fetched.stderr || "fetch 失敗" };
|
||||
if (!fetched.ok) return { ok: false, area, reason: pl.redactSecrets(fetched.stderr) || "fetch 失敗" };
|
||||
const head = git(["rev-parse", "--abbrev-ref", "HEAD"], dir).stdout || "main";
|
||||
const remoteRef = `origin/${head}`;
|
||||
const exists = git(["rev-parse", "--verify", "--quiet", remoteRef], dir);
|
||||
@@ -815,7 +823,7 @@ export async function pullArea(slug, area, { code = null, owner = null, force =
|
||||
}
|
||||
git(["checkout", "--", "."], dir);
|
||||
const reset = git(["reset", "--hard", "--quiet", remoteRef], dir);
|
||||
if (!reset.ok) return { ok: false, area, reason: reset.stderr };
|
||||
if (!reset.ok) return { ok: false, area, reason: pl.redactSecrets(reset.stderr) };
|
||||
// 只寫回「遠端真的改過的」與「本機缺少的」。
|
||||
// 不能無差別覆蓋:本機有較新但還沒 push 的內容時,那會把它蓋掉。
|
||||
const root = pl.personaDir(slug);
|
||||
@@ -920,7 +928,7 @@ export async function importFromRemote(code, { owner = null, slug = null, force
|
||||
// 本機是空的,衝突判定沒有意義;覆蓋既有人格時使用者已經明講了 --force
|
||||
results[area] = await pullArea(target, area, { code, owner: theOwner, force: true });
|
||||
} catch (err) {
|
||||
results[area] = { ok: false, area, reason: String(err.message || err) };
|
||||
results[area] = { ok: false, area, reason: pl.redactSecrets(String(err.message || err)) };
|
||||
}
|
||||
}
|
||||
if (!pl.personaExists(target)) {
|
||||
|
||||
Reference in New Issue
Block a user