feat(shared): 新增14個共用spec、models/todo工具與樣板產生器,收斂跨repo重複規範

依 todo.md 執行的規範治理專案:新增 spec-preflight 等 14 個共用規範(含
conventional-commit/pull-request/git-push/issue-read/todo-list/ask-user/
subagent/no-scratch-files/skill-invocation/script-path/action-scaffold/
node-src-layout/plugin-cli/model),擴充 spec-git-safety 與 spec-gitea(token
優先序、機密遮蔽、Wiki 頁名轉義規則);新增可執行 skill `models`(模型能力
查詢與標籤)與 `todo`(依指定模型產生/附加 todo.md);新增 plugin.meta.json
單一事實來源與 gen-plugin-files.mjs 樣板產生器,統一四個 repo 的 manifest/
README/AGENTS.md 並移除寫死的本機使用者路徑;新增 shared/scripts/lib 的
log/機密遮蔽三語言參考實作。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 06:02:30 +00:00
co-authored by Claude Sonnet 5
parent d49ae1085d
commit 1030f9d403
38 changed files with 2329 additions and 139 deletions
+544
View File
@@ -0,0 +1,544 @@
#!/usr/bin/env node
'use strict';
/**
* gen-plugin-files.mjs — 依 <repo>/plugin.meta.json 產生五份 manifest、AGENTS.md(shared/doc/code)、
* 以及 README.md 五個共通章節(目錄結構/用 CLI 直接執行 skill/新增一個 skill 三節目前為
* 原樣保留的 passthrough,只有「前綴與呼叫方式」與「安裝 / 更新 / 移除」兩節會真正改寫內容)。
*
* 用法:
* node gen-plugin-files.mjs --repo <repo目錄> --dry-run # 只印比對報告,不寫檔(本階段唯一支援的用法)
* node gen-plugin-files.mjs --repo <repo目錄> --write # 實際寫入檔案(下一階段才使用)
*
* 只用 Node.js 內建模組,不依賴任何套件。
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const TEMPLATES_DIR = path.join(__dirname, '..', 'templates');
// ---------------------------------------------------------------------------
// 小工具
// ---------------------------------------------------------------------------
function readJSON(p) {
return JSON.parse(fs.readFileSync(p, 'utf8'));
}
function readTextOrNull(p) {
return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null;
}
function stripTrailingPeriod(s) {
return s.replace(/。$/, '');
}
/** 在字串第一個「,」或「:」之前插入 insertText。找不到就直接接在字串尾端。 */
function insertBeforeFirstPunct(s, insertText) {
const idx = s.search(/[,:]/);
if (idx === -1) return s + insertText;
return s.slice(0, idx) + insertText + s.slice(idx);
}
function stableStringify(obj) {
return JSON.stringify(obj, null, 2) + '\n';
}
// ---------------------------------------------------------------------------
// Manifest 產生(規則對照見 shared/templates/plugin-manifests.md)
// ---------------------------------------------------------------------------
function buildRootDescription(meta) {
const base = stripTrailingPeriod(meta.descriptionCore);
const rootAssistant = meta.callPrefixAssistants.root;
return `${base};於 ${rootAssistant} 以 ${meta.cliPrefix} 前綴呼叫。`;
}
function buildClaudeDescription(meta) {
const assistantsList = `(${meta.assistants.join(' / ')})`;
const withAssistants = insertBeforeFirstPunct(meta.descriptionCore, assistantsList);
const base = stripTrailingPeriod(withAssistants);
const claudeAssistant = meta.callPrefixAssistants.claudePlugin;
return `${base};於 ${claudeAssistant} 以 ${meta.cliPrefix} 前綴呼叫。`;
}
function buildCodexDescription(meta) {
const note = meta.codexNote || '';
if (!note) return meta.descriptionCore;
if (meta.codexNoteAnchor) {
const idx = meta.descriptionCore.indexOf(meta.codexNoteAnchor);
if (idx === -1) {
throw new Error(
`codexNoteAnchor "${meta.codexNoteAnchor}" 在 descriptionCore 中找不到(repo: ${meta.shortName})`
);
}
const insertAt = idx + meta.codexNoteAnchor.length;
return meta.descriptionCore.slice(0, insertAt) + note + meta.descriptionCore.slice(insertAt);
}
return meta.descriptionCore + note;
}
function buildRootPluginJson(meta) {
return {
name: meta.name,
version: meta.version,
description: buildRootDescription(meta),
skills: meta.skillsPath,
};
}
function buildClaudePluginJson(meta) {
return {
name: meta.name,
version: meta.version,
description: buildClaudeDescription(meta),
skills: meta.skillsPath,
author: meta.author,
homepage: meta.homepage,
repository: meta.repository,
keywords: meta.keywords,
};
}
function buildCodexPluginJson(meta) {
return {
name: meta.name,
version: meta.version,
description: buildCodexDescription(meta),
skills: meta.skillsPath,
};
}
function buildClaudeMarketplaceJson(meta) {
return {
name: meta.shortName,
description: meta.marketplace.repoDescription,
owner: { name: meta.author.name },
plugins: [
{
name: meta.name,
source: './',
description: meta.marketplace.pluginSummary,
},
],
};
}
function buildCodexMarketplaceJson(meta) {
return {
name: meta.shortName,
plugins: [
{
name: meta.name,
source: { source: 'url', url: meta.repository },
},
],
};
}
// ---------------------------------------------------------------------------
// AGENTS.md(只給 shared / doc / code;persona 不套用)
// ---------------------------------------------------------------------------
const AGENTS_APPLIES_TO = new Set(['shared', 'doc', 'code']);
function buildAgentsMd(meta) {
const tmpl = fs.readFileSync(path.join(TEMPLATES_DIR, 'AGENTS.md.tmpl'), 'utf8');
const bullets = meta.agentsExtraBullets || [];
const extraBlock = bullets.length ? bullets.map((b) => `- ${b}`).join('\n') + '\n' : '';
return tmpl
.replaceAll('{{PLUGIN_NAME}}', meta.name)
.replaceAll('{{CLI_PREFIX}}', meta.cliPrefix)
.replace('{{EXTRA_BULLETS}}', extraBlock);
}
// ---------------------------------------------------------------------------
// README.md 五個共通章節
// ---------------------------------------------------------------------------
const DEFAULT_HEADINGS = {
prefixTable: '前綴與呼叫方式',
dirTree: '目錄結構',
install: '安裝 / 更新 / 移除(各助理)',
headless: '用 CLI 直接執行 skill(headless / 一次性)',
addSkill: '新增一個 skill',
};
function getHeadings(meta) {
return { ...DEFAULT_HEADINGS, ...(meta.readmeHeadings || {}) };
}
/**
* 找出「## <heading>」章節在整份文件中的字元範圍:[sectionStart, sectionEnd)。
* sectionStart 指向標題行開頭;sectionEnd 指向下一個獨立一行 `---` 或下一個 `## ` 標題(不含),或檔尾。
* 回傳 null 表示找不到這個標題(不應該發生在四個既有 repo 上,但保守處理)。
*/
function findSection(text, heading) {
const lines = text.split('\n');
const headingLine = `## ${heading}`;
let startLine = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim() === headingLine) {
startLine = i;
break;
}
}
if (startLine === -1) return null;
let endLine = lines.length;
for (let i = startLine + 1; i < lines.length; i++) {
const t = lines[i].trim();
if (t === '---' || t.startsWith('## ')) {
endLine = i;
break;
}
}
const prefix = lines.slice(0, startLine).join('\n') + (startLine > 0 ? '\n' : '');
const suffix = lines.slice(endLine).join('\n');
const sectionText = lines.slice(startLine, endLine).join('\n');
return { startLine, endLine, prefix, suffix, sectionText };
}
/**
* 用新的 bodyLines(不含標題、不含結尾空行)取代整節內容,回傳整份新文件文字。
* 新章節格式固定為:`## <heading>` + 空行 + bodyLines + 空行(與既有四份 README 的排版慣例一致)。
*/
function replaceSection(text, heading, bodyLines) {
const sec = findSection(text, heading);
if (!sec) {
throw new Error(`找不到章節「## ${heading}」`);
}
const newSectionLines = [`## ${heading}`, '', ...bodyLines];
const newText = sec.prefix + newSectionLines.join('\n') + '\n\n' + sec.suffix;
return { newText, oldSectionText: sec.sectionText };
}
/**
* 章節文字的「可比較表示」:既有章節(sec.sectionText)永遠包含結尾那一行空行(緊接在 `---` 之前),
* 所以這裡也補上同一個結尾空行,才能跟 oldSectionText 公平比較,避免只因為少了那一行空行就誤報「有變動」。
*/
function renderSectionForCompare(heading, bodyLines) {
return [`## ${heading}`, '', ...bodyLines, ''].join('\n');
}
// ---- 章節 1/5:前綴與呼叫方式(完全模板化) ----
function buildPrefixTableBodyLines(meta) {
const tmpl = fs.readFileSync(path.join(TEMPLATES_DIR, 'readme-prefix-table.md.tmpl'), 'utf8');
const openCodeMethod = meta.readmeOpenCodeInstallMethod || 'skills 目錄(複製/clone)';
const rendered = tmpl
.replaceAll('{{CLI_PREFIX}}', meta.cliPrefix)
.replace('{{OPENCODE_INSTALL_METHOD}}', openCodeMethod);
// tmpl 檔本身已含 "## 前綴與呼叫方式\n\n" 開頭,這裡只取標題與空行之後的部分當 bodyLines。
const lines = rendered.split('\n');
// 找到第一個非空行之後(跳過標題行與其後的空行)
const headingIdx = lines.findIndex((l) => l.trim().startsWith('## '));
let bodyStart = headingIdx + 1;
while (bodyStart < lines.length && lines[bodyStart].trim() === '') bodyStart++;
let bodyEnd = lines.length;
while (bodyEnd > bodyStart && lines[bodyEnd - 1].trim() === '') bodyEnd--;
return lines.slice(bodyStart, bodyEnd);
}
// ---- 章節 3:安裝 / 更新 / 移除(改為引用 spec-plugin-cli) ----
function buildInstallBodyLines(meta) {
const host = 'gitea.jsc.idv.tw';
const pluginName = meta.name; // jsc-<shortName>
const marketplaceName = meta.shortName;
const token = `${pluginName}@${marketplaceName}`;
const url = meta.repository;
const notes = meta.readmeInstallNotes || [];
const lines = [];
if (notes.length) {
notes.forEach((note, i) => {
if (i > 0) lines.push('>');
lines.push(`> ${note}`);
});
lines.push('>');
}
lines.push(
'> 完整的安裝/更新/移除指令(Claude Code、Codex、Antigravity、OpenCode、GitHub Copilot CLI 五種助理),一律以 [`/jsc-shared:spec-plugin-cli`](https://gitea.jsc.idv.tw/plugins/shared/src/branch/master/skills/spec-plugin-cli/SKILL.md) 為唯一權威版本,套用時代入下列佔位符:',
'>',
'> | 佔位符 | 值 |',
'> | --- | --- |',
`> | \`<host>\` | \`${host}\` |`,
`> | \`<name>\` | \`${meta.shortName}\` |`,
`> | \`<plugin>\` | \`${pluginName}\` |`,
`> | \`<marketplace>\` | \`${marketplaceName}\` |`,
`> | \`<token>\`(= \`<plugin>@<marketplace>\`) | \`${token}\` |`,
`> | \`<url>\` | \`${url}\` |`
);
return lines;
}
// ---- 章節 2/4/5:目錄結構、用 CLI 直接執行 skill、新增一個 skill(passthrough) ----
// 這三節內容是各 repo 特有事實(skills 清單/目錄樹/範例指令),目前原樣保留、只重新包裝標記。
// 詳見 shared/templates/readme-passthrough-sections.md。
function buildPassthroughBodyLines(currentReadmeText, heading) {
const sec = findSection(currentReadmeText, heading);
if (!sec) return null;
const lines = sec.sectionText.split('\n');
// 去掉標題行與其後緊接的空行,其餘原樣回傳
let bodyStart = 1;
while (bodyStart < lines.length && lines[bodyStart].trim() === '') bodyStart++;
let bodyEnd = lines.length;
while (bodyEnd > bodyStart && lines[bodyEnd - 1].trim() === '') bodyEnd--;
return lines.slice(bodyStart, bodyEnd);
}
/**
* 產生整份新 README.md 文字,並回報五個章節各自「是否變動」與「舊/新內容」供 dry-run 顯示。
*/
function buildReadme(meta, currentReadmeText) {
const headings = getHeadings(meta);
const changes = [];
let text = currentReadmeText;
// 章節 1:前綴與呼叫方式 — 完全模板化
{
const bodyLines = buildPrefixTableBodyLines(meta);
const { newText, oldSectionText } = replaceSection(text, headings.prefixTable, bodyLines);
const newSectionText = renderSectionForCompare(headings.prefixTable, bodyLines);
changes.push({
key: 'prefixTable',
heading: headings.prefixTable,
changed: oldSectionText !== newSectionText,
oldText: oldSectionText,
newText: newSectionText,
});
text = newText;
}
// 章節 2:目錄結構 — passthrough(原樣保留)
{
const bodyLines = buildPassthroughBodyLines(currentReadmeText, headings.dirTree);
if (bodyLines === null) {
changes.push({ key: 'dirTree', heading: headings.dirTree, changed: false, note: '找不到此章節(略過)' });
} else {
const { newText, oldSectionText } = replaceSection(text, headings.dirTree, bodyLines);
const newSectionText = renderSectionForCompare(headings.dirTree, bodyLines);
changes.push({
key: 'dirTree',
heading: headings.dirTree,
changed: oldSectionText !== newSectionText,
oldText: oldSectionText,
newText: newSectionText,
});
text = newText;
}
}
// 章節 3:安裝 / 更新 / 移除 — 改為引用 spec-plugin-cli
{
const bodyLines = buildInstallBodyLines(meta);
const { newText, oldSectionText } = replaceSection(text, headings.install, bodyLines);
const newSectionText = renderSectionForCompare(headings.install, bodyLines);
changes.push({
key: 'install',
heading: headings.install,
changed: oldSectionText !== newSectionText,
oldText: oldSectionText,
newText: newSectionText,
});
text = newText;
}
// 章節 4:用 CLI 直接執行 skill — passthrough(原樣保留)
{
const bodyLines = buildPassthroughBodyLines(currentReadmeText, headings.headless);
if (bodyLines === null) {
changes.push({ key: 'headless', heading: headings.headless, changed: false, note: '找不到此章節(略過)' });
} else {
const { newText, oldSectionText } = replaceSection(text, headings.headless, bodyLines);
const newSectionText = renderSectionForCompare(headings.headless, bodyLines);
changes.push({
key: 'headless',
heading: headings.headless,
changed: oldSectionText !== newSectionText,
oldText: oldSectionText,
newText: newSectionText,
});
text = newText;
}
}
// 章節 5:新增一個 skill / 新增/修改 skill — passthrough(原樣保留)
{
const bodyLines = buildPassthroughBodyLines(currentReadmeText, headings.addSkill);
if (bodyLines === null) {
changes.push({ key: 'addSkill', heading: headings.addSkill, changed: false, note: '找不到此章節(略過)' });
} else {
const { newText, oldSectionText } = replaceSection(text, headings.addSkill, bodyLines);
const newSectionText = renderSectionForCompare(headings.addSkill, bodyLines);
changes.push({
key: 'addSkill',
heading: headings.addSkill,
changed: oldSectionText !== newSectionText,
oldText: oldSectionText,
newText: newSectionText,
});
text = newText;
}
}
return { text, changes };
}
// ---------------------------------------------------------------------------
// 比對與報告
// ---------------------------------------------------------------------------
function fileTargets(repoDir, meta) {
const targets = [
{ label: 'plugin.json(root / Antigravity)', file: path.join(repoDir, 'plugin.json'), kind: 'json', build: () => buildRootPluginJson(meta) },
{ label: '.claude-plugin/plugin.json', file: path.join(repoDir, '.claude-plugin', 'plugin.json'), kind: 'json', build: () => buildClaudePluginJson(meta) },
{ label: '.codex-plugin/plugin.json', file: path.join(repoDir, '.codex-plugin', 'plugin.json'), kind: 'json', build: () => buildCodexPluginJson(meta) },
{ label: '.claude-plugin/marketplace.json', file: path.join(repoDir, '.claude-plugin', 'marketplace.json'), kind: 'json', build: () => buildClaudeMarketplaceJson(meta) },
{ label: '.agents/plugins/marketplace.json', file: path.join(repoDir, '.agents', 'plugins', 'marketplace.json'), kind: 'json', build: () => buildCodexMarketplaceJson(meta) },
];
if (AGENTS_APPLIES_TO.has(meta.shortName)) {
targets.push({ label: 'AGENTS.md', file: path.join(repoDir, 'AGENTS.md'), kind: 'text', build: () => buildAgentsMd(meta) });
}
return targets;
}
function diffLines(oldStr, newStr) {
if (oldStr === newStr) return null;
return { old: oldStr == null ? '(不存在)' : oldStr, new: newStr };
}
function printSeparator(char = '-', len = 78) {
console.log(char.repeat(len));
}
function reportRepo(repoDir, { dryRun }) {
const shortName = path.basename(repoDir);
const metaPath = path.join(repoDir, 'plugin.meta.json');
if (!fs.existsSync(metaPath)) {
console.log(`[SKIP] ${repoDir} 沒有 plugin.meta.json`);
return { repo: shortName, ok: false };
}
const meta = readJSON(metaPath);
printSeparator('=');
console.log(`Repo: ${shortName} (${repoDir})`);
printSeparator('=');
let anyChange = false;
const fileResults = [];
for (const target of fileTargets(repoDir, meta)) {
const current = readTextOrNull(target.file);
const builtRaw = target.build();
const newContent = target.kind === 'json' ? stableStringify(builtRaw) : builtRaw;
const currentNormalized = current; // 保留原始內容比對(含尾端換行差異視為變動,提醒人工留意)
const changed = currentNormalized !== newContent;
if (changed) anyChange = true;
fileResults.push({ label: target.label, file: target.file, changed, current: currentNormalized, next: newContent });
console.log(`\n--- ${target.label} ---`);
console.log(`檔案:${target.file}`);
if (!current) {
console.log('現況:檔案不存在(將會新建)');
}
console.log(changed ? '狀態:會變動' : '狀態:無變動');
if (changed) {
console.log('[舊內容]');
console.log(current == null ? '(不存在)' : current);
console.log('[新內容]');
console.log(newContent);
}
}
// README.md 五個共通章節
const readmePath = path.join(repoDir, 'README.md');
const currentReadme = readTextOrNull(readmePath);
if (currentReadme == null) {
console.log(`\n--- README.md ---\n檔案不存在,略過五個共通章節的產生。`);
} else {
const { text: newReadme, changes } = buildReadme(meta, currentReadme);
console.log(`\n--- README.md(五個共通章節)---`);
console.log(`檔案:${readmePath}`);
for (const c of changes) {
if (c.note) {
console.log(`\n[${c.key}] ## ${c.heading} — ${c.note}`);
continue;
}
console.log(`\n[${c.key}] ## ${c.heading} — ${c.changed ? '會變動' : '無變動'}`);
if (c.changed) {
console.log(' [舊章節內容]');
console.log(indent(c.oldText, ' '));
console.log(' [新章節內容]');
console.log(indent(c.newText, ' '));
}
if (c.changed) anyChange = true;
}
fileResults.push({ label: 'README.md(五節)', file: readmePath, changed: newReadme !== currentReadme, current: currentReadme, next: newReadme });
}
console.log(`\nRepo「${shortName}」摘要:${anyChange ? '有變動待審核' : '完全無變動'}`);
if (!dryRun) {
for (const fr of fileResults) {
if (fr.changed) {
fs.mkdirSync(path.dirname(fr.file), { recursive: true });
fs.writeFileSync(fr.file, fr.next, 'utf8');
console.log(`[WRITE] ${fr.file}`);
}
}
}
return { repo: shortName, anyChange };
}
function indent(text, pad) {
return text
.split('\n')
.map((l) => pad + l)
.join('\n');
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
function parseArgs(argv) {
const args = { repo: null, dryRun: false, write: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--repo') args.repo = argv[++i];
else if (a === '--dry-run') args.dryRun = true;
else if (a === '--write') args.write = true;
else {
console.error(`未知參數:${a}`);
process.exit(1);
}
}
return args;
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (!args.repo) {
console.error('用法:node gen-plugin-files.mjs --repo <repo目錄> (--dry-run | --write)');
process.exit(1);
}
if (!args.dryRun && !args.write) {
console.error('請明確指定 --dry-run(只印比對報告)或 --write(實際寫入檔案)。');
process.exit(1);
}
const repoDir = path.resolve(args.repo);
if (!fs.existsSync(repoDir)) {
console.error(`repo 目錄不存在:${repoDir}`);
process.exit(1);
}
reportRepo(repoDir, { dryRun: args.dryRun && !args.write });
}
main();
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env node
// ==============================================================================
// 用途:時間戳與 log 格式的參考實作(Node/mjs 版)。對應 /jsc-shared:spec-time-log
// 規範:更新時間一律 Asia/Taipei、固定 yyyy/MM/dd HH:mm:ss;輸出訊息一律
// [時間][階段][等級]: 訊息、一行一則、走 stderr。本檔案不會被其他 repo
// 跨 repo 直接 import,純供其他 repo 的腳本對照複製或標註對應關係
// (等價邏輯抽自 persona/scripts/persona-lib.mjs 的 nowDisplay(),只取
// 時間與 log 兩個函式,不耦合該檔其他情緒/記憶邏輯)。
// 更新時間:2026/08/11 00:00:00
// 相依:Node.js 標準內建功能,無外部套件。
// ==============================================================================
const TAIPEI_OFFSET_MS = 8 * 60 * 60 * 1000;
/**
* 回傳 Asia/Taipei 時區的 yyyy/MM/dd HH:mm:ss 時間字串。
* @param {Date} date 基準時間,預設為呼叫當下。
* @returns {string}
*/
export function nowTaipei(date = new Date()) {
const shifted = new Date(Math.floor(date.getTime() / 1000) * 1000 + TAIPEI_OFFSET_MS);
const pad = (n) => String(n).padStart(2, "0");
return `${shifted.getUTCFullYear()}/${pad(shifted.getUTCMonth() + 1)}/${pad(shifted.getUTCDate())} ${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}`;
}
/**
* 輸出統一格式訊息([時間][階段][等級]: 訊息,一行一則),一律走 stderr。
* @param {string} level INF/WRN/ERR/TRC/DBG。
* @param {string} message 訊息內容。
* @param {string} [stage] 階段名稱,選填;空字串則不輸出 [階段] 區塊。
*/
export function log(level, message, stage = "") {
const stamp = nowTaipei();
if (stage) {
console.error(`[${stamp}][${stage}][${level}]: ${message}`);
} else {
console.error(`[${stamp}][${level}]: ${message}`);
}
}
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
# ==============================================================================
# 用途:時間戳與 log 格式的參考實作(Python 版)。對應 /jsc-shared:spec-time-log
# 規範:更新時間一律 Asia/Taipei、固定 yyyy/MM/dd HH:mm:ss(以
# zoneinfo.ZoneInfo 取得,不硬編 +8 offset,正確處理 DST/歷史時區變更);
# 輸出訊息一律 [時間][階段][等級]: 訊息、一行一則、走 stderr。本檔案不會
# 被其他 repo 跨 repo 直接 import,純供其他 repo 的腳本對照複製或標註
# 對應關係(原始寫法見 doc/scripts/worklog/wiki_api.py 的 now_str()/log())。
# 更新時間:2026/08/11 00:00:00
# 相依:Python 3 標準庫(zoneinfo、datetime)。
# ==============================================================================
import sys
from datetime import datetime
from zoneinfo import ZoneInfo
TAIPEI = ZoneInfo("Asia/Taipei")
def now_taipei():
"""取得台灣時區的 yyyy/MM/dd HH:mm:ss 時間字串。"""
return datetime.now(TAIPEI).strftime("%Y/%m/%d %H:%M:%S")
def log(level, message, stage=""):
"""輸出統一格式訊息([時間][階段][等級]: 訊息,一行一則),一律走 stderr 不污染 stdout。"""
if stage:
print(f"[{now_taipei()}][{stage}][{level}]: {message}", file=sys.stderr)
else:
print(f"[{now_taipei()}][{level}]: {message}", file=sys.stderr)
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# ==============================================================================
# 用途:時間戳與 log 格式的參考實作(bash 版)。對應 /jsc-shared:spec-time-log
# 規範:更新時間一律 Asia/Taipei、固定 yyyy/MM/dd HH:mm:ss;輸出訊息一律
# [時間][階段][等級]: 訊息、一行一則、走 stderr。本檔案不會被其他 repo
# 跨 repo 直接 source/呼叫,純供其他 repo 的腳本對照複製或標註對應關係
# (原始寫法見 doc/scripts/worklog/worklog.sh 第23-33行附近)。
# 更新時間:2026/08/11 00:00:00
# 相依:bash、coreutils date(需支援 TZ 環境變數)。
# ==============================================================================
# ------------------------------------------------------------------------------
# now_taipei:回傳 Asia/Taipei 時區的 yyyy/MM/dd HH:mm:ss 時間字串
# ------------------------------------------------------------------------------
now_taipei() {
TZ='Asia/Taipei' date +'%Y/%m/%d %H:%M:%S'
}
# ------------------------------------------------------------------------------
# log:輸出統一格式訊息([時間][階段][等級]: 訊息,一行一則),一律走 stderr
# $1 = level(INF/WRN/ERR/TRC/DBG)
# $2 = message
# $3 = stage(選填;空則不輸出 [階段] 區塊)
# ------------------------------------------------------------------------------
log() {
local level="$1" message="$2" stage="${3:-}" stamp
stamp="$(now_taipei)"
if [ -n "$stage" ]; then
printf '[%s][%s][%s]: %s\n' "$stamp" "$stage" "$level" "$message" >&2
else
printf '[%s][%s]: %s\n' "$stamp" "$level" "$message" >&2
fi
}
+56
View File
@@ -0,0 +1,56 @@
{
"_meta": {
"用途": "機密遮蔽規則(9 條)的唯一權威資料來源,供 Python/JavaScript/Bash 三語言各自載入套用。任何實作異動一律先改本檔,再同步對應語言的實作。",
"來源": "抽自 doc/scripts/worklog/transcript.py 的 REDACT_PATTERNS,並比對 persona/scripts/persona-lib.mjs 的 redactSecrets() 確認語意一致。",
"語法規則": "pattern 一律使用三語言可直接使用的正則語法:不使用 Python 專屬語法特性(例如 (?i) inline flag、\\d 的 Unicode 語意),大小寫不分的規則改用顯式字元類別(如 [Tt][Oo][Kk][Ee][Nn])展開;數字一律用 [0-9] 而非 \\d。\\b/\\s/\\S 為 Python re、JavaScript RegExp 皆直接支援的語法,Bash 端以 GNU grep -E/sed -E(glibc regex,支援 \\b、\\s 等 GNU 擴充)套用。",
"反向參照規則": "replacement 內的反向參照統一用 $1 風格(JavaScript String.replace/Bash sed 皆可直接使用)。Python 使用本檔時,套用前需先把 replacement 內的 $1、$2…轉成 \\1、\\2… 再交給 re.sub();transcript.py 的 REDACT_PATTERNS 保留原有 \\1 寫法不受本檔影響。",
"更新時間": "2026/08/11 00:00:00"
},
"rules": [
{
"pattern": "[A-Za-z0-9_-]*:[A-Za-z0-9_-]{16,}@",
"replacement": "***@",
"purpose": "URL 內嵌憑證(user:token@ 形式)"
},
{
"pattern": "\\b[0-9a-f]{40}\\b",
"replacement": "***",
"purpose": "Gitea 40 字元 hex token"
},
{
"pattern": "\\bgh[pousr]_[A-Za-z0-9_]{16,}\\b",
"replacement": "***",
"purpose": "GitHub token(ghp_/gho_/ghu_/ghs_/ghr_ 開頭)"
},
{
"pattern": "\\bsk-[A-Za-z0-9_-]{16,}\\b",
"replacement": "***",
"purpose": "API key(sk- 開頭)"
},
{
"pattern": "\\b([Tt][Oo][Kk][Ee][Nn]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Pp][Aa][Ss][Ss][Ww][Dd]|[Pp][Ww][Dd]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Aa][Pp][Ii][_-]?[Kk][Ee][Yy])\\b\\s*[:=]\\s*\\S+",
"replacement": "$1=***",
"purpose": "key=value 形式機密(token/password/passwd/pwd/secret/api_key,不分大小寫)"
},
{
"pattern": "[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn]:\\s*([Tt][Oo][Kk][Ee][Nn]|[Bb][Ee][Aa][Rr][Ee][Rr])\\s+\\S+",
"replacement": "Authorization: $1 ***",
"purpose": "HTTP Authorization 標頭(token/bearer,不分大小寫)"
},
{
"pattern": "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}",
"replacement": "***",
"purpose": "Email 位址"
},
{
"pattern": "\\b09[0-9]{2}[-\\s]?[0-9]{3}[-\\s]?[0-9]{3}\\b",
"replacement": "***",
"purpose": "台灣手機號碼"
},
{
"pattern": "\\b[A-Z][12][0-9]{8}\\b",
"replacement": "***",
"purpose": "台灣身分證字號"
}
]
}