@quolu/lattice 0.55.1 → 0.57.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.
@@ -100,6 +100,7 @@ import {
100
100
  acceptPullTask,
101
101
  attachPullWorker,
102
102
  closePullRun,
103
+ detachPullWorker,
103
104
  inspectRunMode as inspectPullRunMode,
104
105
  intakePullTask,
105
106
  interventionPullTask,
@@ -4411,6 +4412,15 @@ export async function runRuntimeCli({ argv, cwd, stdout, stderr }) {
4411
4412
  const output = await releasePullTask({ runDir, taskId: argv[6] });
4412
4413
  stdout.write(`${JSON.stringify(output)}\n`); return 0;
4413
4414
  };
4415
+ } else if (argv.length === 7
4416
+ && argv[0] === 'run' && argv[1] === 'intake' && argv[2] === 'detach'
4417
+ && argv[3] === '--run' && typeof argv[4] === 'string' && argv[4].length > 0
4418
+ && argv[5] === '--task' && typeof argv[6] === 'string' && argv[6].length > 0) {
4419
+ action = async () => {
4420
+ const { runDir } = await resolveRunStore(cwd, argv[4]);
4421
+ const output = await detachPullWorker({ runDir, taskId: argv[6] });
4422
+ stdout.write(`${JSON.stringify(output)}\n`); return 0;
4423
+ };
4414
4424
  } else if (argv.length === 9
4415
4425
  && argv[0] === 'run' && argv[1] === 'intake' && argv[2] === 'attach'
4416
4426
  && argv[3] === '--run' && typeof argv[4] === 'string' && argv[4].length > 0
@@ -216,6 +216,7 @@ function project(events, meta) {
216
216
  sequence: event.sequence,
217
217
  ...structuredClone(event.payload),
218
218
  worker: null,
219
+ worker_detached: false,
219
220
  accepted: null,
220
221
  });
221
222
  } else if (event.kind === 'intake_refreshed') {
@@ -235,6 +236,13 @@ function project(events, meta) {
235
236
  } else if (event.kind === 'worker_stopped' || event.kind === 'worker_resumed') {
236
237
  const intake = intakes.get(event.task_id);
237
238
  if (intake?.worker) intake.worker.stopped = event.kind === 'worker_stopped';
239
+ } else if (event.kind === 'worker_detached') {
240
+ const intake = intakes.get(event.task_id);
241
+ // `worker_detached` is remembered separately from `worker === null`: an
242
+ // intake that never had a worker and one whose worker was deliberately
243
+ // unbound look identical otherwise, and only the latter may be released
244
+ // by an operator who is not the original seat.
245
+ if (intake) { intake.worker = null; intake.worker_detached = true; }
238
246
  } else if (event.kind === 'task_accepted') {
239
247
  const intake = intakes.get(event.task_id);
240
248
  if (intake) intake.accepted = structuredClone(event.payload);
@@ -287,6 +295,20 @@ function sameActor(left, right) {
287
295
  return left?.host === right.host && left?.session === right.session && left?.agent === right.agent;
288
296
  }
289
297
 
298
+ /**
299
+ * The acting actor when the caller has one, `null` when it does not.
300
+ *
301
+ * Recovery is performed by an operator who is by definition NOT the seat being
302
+ * recovered — the seat may be SIGSTOP'd, dead, or from a session that no longer
303
+ * exists. Demanding the seat's own env-derived identity on a recovery command
304
+ * would mean only the frozen seat could unfreeze itself, which is the trap this
305
+ * whole path exists to remove. The actor is recorded when present so the trail
306
+ * survives, but it is not an authorization gate here.
307
+ */
308
+ function optionalActorFromEnvironment(environment = process.env) {
309
+ try { return actorFromEnvironment(environment); } catch { return null; }
310
+ }
311
+
290
312
  function activeMember(store, planKey) {
291
313
  const member = store.members?.find((entry) => entry.plan?.plan_key === planKey);
292
314
  if (!member) fail('PLAN_NOT_ACTIVE', `active planが見つからない: ${planKey}`);
@@ -805,10 +827,15 @@ export async function releasePullTask({ runDir, taskId, environment = process.en
805
827
  if (intake.worker !== null) {
806
828
  fail('INTAKE_WORKER_ATTACHED', 'workerがattach済みのintakeはreleaseできない', {
807
829
  task_id: taskId,
808
- next_action: 'workerを安全に停止・detachできる正規経路を先に使う',
830
+ next_action: 'lattice run intake detach --run <run> --task <task> --json',
809
831
  });
810
832
  }
811
- if (!sameActor(intake.actor, actor)) {
833
+ // The actor gate protects a seat's own in-progress work from another seat.
834
+ // Once the worker is detached there is no seat left to protect, and demanding
835
+ // the original session's identity would block the operator performing the
836
+ // recovery — the seat that owned this intake is precisely the one that is
837
+ // gone. The releasing actor is recorded either way.
838
+ if (intake.worker_detached !== true && !sameActor(intake.actor, actor)) {
812
839
  fail('INTAKE_BINDING_CONFLICT', 'intakeを作成したactorだけがreleaseできる', {
813
840
  task_id: taskId,
814
841
  });
@@ -930,6 +957,66 @@ export async function attachPullWorker({ runDir, taskId, input, environment = pr
930
957
  } finally { await lock.release(); }
931
958
  }
932
959
 
960
+ /**
961
+ * Unbind a worker from its intake, releasing it first if the hold stopped it.
962
+ *
963
+ * This is the door `releasePullTask`'s own `next_action` already names. Without
964
+ * it a hold that can never clear leaves the seat SIGSTOP'd with no legitimate
965
+ * exit: `run close` refuses while the intake is unaccepted, `run intake release`
966
+ * refuses while a worker is attached, and `run abandon` is legacy-only. A shared
967
+ * pull run over a repo with no indexable code reached exactly that state
968
+ * (2026-08-10) — the sensor could not stamp an empty index, so the boundary was
969
+ * never verifiable and the hold was permanent.
970
+ *
971
+ * Authorization is process identity, not the actor: `signalAttachedWorker`
972
+ * re-verifies lstart/argv/pgid against the recorded binding before signalling,
973
+ * which is strictly stronger than an env-derived actor claim (env vars are
974
+ * trivially settable; a process's start identity is not). See
975
+ * `optionalActorFromEnvironment` for why the seat's own identity cannot be the
976
+ * gate on a recovery command.
977
+ */
978
+ export async function detachPullWorker({ runDir, taskId, environment = process.env }) {
979
+ if (!identifier(taskId)) fail('INVALID_TASK_ID', 'task idが不正');
980
+ const actor = optionalActorFromEnvironment(environment);
981
+ const lock = await acquirePullLock(runDir, 'detach', `${taskId}-${randomUUID()}`);
982
+ try {
983
+ let current = await readPullStore(runDir);
984
+ const state = project(current.events, current.meta);
985
+ if (state.closed) fail('RUN_CLOSED', 'closed pull runのworkerをdetachできない');
986
+ const intake = state.intakes.find((entry) => entry.task_id === taskId);
987
+ if (intake === undefined) fail('INTAKE_NOT_FOUND', 'active intakeが存在しない', { task_id: taskId });
988
+ if (intake.accepted !== null) {
989
+ fail('INTAKE_ALREADY_ACCEPTED', 'accepted intakeのworkerはdetachできない', { task_id: taskId });
990
+ }
991
+ if (intake.worker === null) {
992
+ fail('INTAKE_WORKER_ABSENT', 'attach済みworkerが無い', { task_id: taskId });
993
+ }
994
+ // Resume before unbinding. Once the binding is gone nothing in this store can
995
+ // name that pid again, so a worker left stopped here would be unreachable by
996
+ // any future command — the same one-way door, one step further along.
997
+ const resumed = intake.worker.stopped;
998
+ if (resumed) {
999
+ await signalAttachedWorker(intake, 'SIGCONT');
1000
+ current = await appendEvent(runDir, current, buildEvent({
1001
+ events: current.events, meta: current.meta, kind: 'worker_resumed', taskId,
1002
+ payload: { released_by: 'worker_detach' },
1003
+ }));
1004
+ }
1005
+ current = await appendEvent(runDir, current, buildEvent({
1006
+ events: current.events, meta: current.meta, kind: 'worker_detached', taskId,
1007
+ payload: { detached_by: actor, pid: intake.worker.pid, resumed },
1008
+ }));
1009
+ const output = {
1010
+ schema: 'lattice.pull_worker_detach_result.v1', outcome: 'detached',
1011
+ run_id: state.run_id, task_id: taskId, pid: intake.worker.pid, resumed,
1012
+ next_action: 'lattice run intake release --run <run> --task <task> --json',
1013
+ result_digest: '',
1014
+ };
1015
+ output.result_digest = digestArtifact(output);
1016
+ return output;
1017
+ } finally { await lock.release(); }
1018
+ }
1019
+
933
1020
  function observationModel(state) {
934
1021
  const intakes = state.intakes.filter((entry) => (
935
1022
  entry.accepted === null && entry.intervention.state !== 'hold'
@@ -1105,7 +1192,13 @@ export async function interventionPullTask(runDir, taskId) {
1105
1192
  if (!intake) fail('TASK_NOT_INTAKED', `taskがintakeされていない: ${taskId}`);
1106
1193
  const output = { schema: 'lattice.pull_intervention.v1', run_id: stored.meta.run_id,
1107
1194
  task_id: taskId, ...structuredClone(intake.intervention),
1108
- worker_attached: intake.worker !== null, worker_stopped: intake.worker?.stopped ?? false };
1195
+ worker_attached: intake.worker !== null, worker_stopped: intake.worker?.stopped ?? false,
1196
+ // `next_action` says how to clear the hold. When the seat is already frozen
1197
+ // the operator also needs to know how to get out if it never clears — the
1198
+ // absence of that answer is what turned one unverifiable boundary into an
1199
+ // unrecoverable run (2026-08-10).
1200
+ recovery: intake.worker?.stopped === true
1201
+ ? 'lattice run intake detach --run <run> --task <task> --json' : null };
1109
1202
  output.result_digest = digestArtifact(output); return output;
1110
1203
  }
1111
1204
 
package/src/todo-cli.mjs CHANGED
@@ -1205,8 +1205,11 @@ async function migrate({ repoRoot, inputRef, serializationReviewed = false }) {
1205
1205
  function extractionFreshnessViolations(repoRoot, extraction) {
1206
1206
  let reachable;
1207
1207
  try {
1208
+ // rev-listの出力はobject数に比例して伸びる(本repo実測で既定maxBuffer 1MiB超過済み)。
1209
+ // 隣のgit showと同様にmaxBufferを明示しないと、ENOBUFSが握られて
1210
+ // source_reachability_unreadableへ誤変換される。
1208
1211
  reachable = new Set(gitSync(['rev-list', '--objects', '--all'], {
1209
- cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
1212
+ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 134_217_728,
1210
1213
  }).split('\n').filter(Boolean).map((line) => line.split(' ')[0]));
1211
1214
  } catch {
1212
1215
  return [{ code: 'source_reachability_unreadable', path: '/tasks', task_ids: [],
@@ -2673,6 +2676,50 @@ async function serveGantt({ repoRoot, port, stdout, env, scope = DEFAULT_GANTT_S
2673
2676
  return null;
2674
2677
  }
2675
2678
 
2679
+ /**
2680
+ * Whether this command should mark the repo as an actively-worked project.
2681
+ *
2682
+ * Being "active" is not a local convenience: the bridge heartbeat sends every
2683
+ * active project to the hub, and the hub routes the published dashboard from
2684
+ * that set. So a command that flips this flag is claiming, on the operator's
2685
+ * behalf, that this terminal serves this project — for the next two hours.
2686
+ *
2687
+ * A read must never make that claim. Running `lattice todo status` inside a
2688
+ * clone to look at it once used to register the project here, contest it with
2689
+ * whichever terminal actually serves it, and (before partial acceptance landed)
2690
+ * take that terminal's whole project set down with it. The diagnosing operator
2691
+ * extended the very outage they were investigating (2026-08-10).
2692
+ *
2693
+ * The rule is an allowlist of store writes, not a denylist of known-bad reads.
2694
+ * The denylist it replaces had grown one incident at a time — `verify` was
2695
+ * added to it the day before, after `todo verify` turned out to be unreachable
2696
+ * for exactly the store inconsistency it exists to diagnose — and every read
2697
+ * added later would have defaulted back to claiming ownership.
2698
+ *
2699
+ * `dashboard` commands are excluded: they own the registry themselves, and
2700
+ * pre-registering the current repo ahead of `dashboard remove` would re-add
2701
+ * what the operator is asking to drop.
2702
+ */
2703
+ function writesTodoStore(argv) {
2704
+ const [command, second, third] = argv;
2705
+ if (argv.includes('--schema')) return false;
2706
+ switch (command) {
2707
+ case 'note': return second !== 'list';
2708
+ case 'migrate': return !argv.includes('--dry-run');
2709
+ case 'snapshot': return argv.includes('--rebuild');
2710
+ case 'independence': return second === 'compile'
2711
+ || (second === 'witness' && ['migrate', 'scaffold'].includes(third));
2712
+ case 'seam-proposal': return ['compile', 'apply', 'land'].includes(second);
2713
+ case 'evidence': return second === 'promote';
2714
+ case 'dependency': return second === 'connect';
2715
+ case 'phase': return second !== 'status';
2716
+ case 'start': case 'retract': case 'block': case 'unblock': case 'done':
2717
+ case 'reopen': case 'split': case 'revise': case 'revise-phase': case 'revise-set':
2718
+ return true;
2719
+ default: return false;
2720
+ }
2721
+ }
2722
+
2676
2723
  async function ensureActiveProjectDashboard({ repoRoot, env }) {
2677
2724
  if (env.LATTICE_DASHBOARD_AUTOSTART === '0') return null;
2678
2725
  const actorIdentity = ACTOR_ENV_KEYS.map((key) => env[key]);
@@ -3134,21 +3181,12 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
3134
3181
 
3135
3182
  try {
3136
3183
  const repoRoot = resolveRepoRoot(cwd);
3137
- const ganttCommand = argv[0] === 'gantt';
3138
- const dashboardAdopt = argv[0] === 'dashboard' && argv[1] === 'adopt';
3139
- const migrationDryRun = argv[0] === 'migrate' && argv.includes('--dry-run');
3140
- // `verify` is the store's own read-only recovery diagnostic the command
3141
- // an operator reaches for precisely when the store might be inconsistent.
3142
- // ensureActiveProjectDashboard calls readTodoStoreStable for an unrelated
3143
- // side effect (registering this session as an active dashboard project),
3144
- // and readTodoStoreStable retries a persistent STORE_INCONSISTENT as if it
3145
- // were a transient in-flight write before giving up and reporting a
3146
- // content-free STORE_BUSY. Running that pre-hook ahead of `verify` meant
3147
- // the one command meant to surface the real inconsistency never got to —
3148
- // it died in the same generic way every other command did (2026-08-10 P0:
3149
- // `todo verify` was unreachable for the exact case it exists to diagnose).
3150
- const verifyCommand = argv[0] === 'verify';
3151
- if (!ganttCommand && !dashboardAdopt && !migrationDryRun && !verifyCommand) {
3184
+ // Only a store write claims this terminal as the project's live source; see
3185
+ // `writesTodoStore`. This also keeps the pre-hook away from `verify`, whose
3186
+ // whole job is to diagnose a store too inconsistent for the pre-hook's own
3187
+ // `readTodoStoreStable` to read (2026-08-10 P0: `todo verify` was
3188
+ // unreachable for exactly the case it exists to diagnose).
3189
+ if (writesTodoStore(argv)) {
3152
3190
  await ensureActiveProjectDashboard({ repoRoot, env });
3153
3191
  }
3154
3192
  const result = atomicCommit
@@ -62,7 +62,7 @@ function withExternalPane(html, pane) {
62
62
 
63
63
  function liveHtml(html, headDigest, eventsPath, externalPane = null) {
64
64
  const controller = `<script>(()=>{const badge=document.createElement('div');badge.setAttribute('role','status');badge.style.cssText='position:fixed;right:12px;bottom:12px;z-index:99;padding:6px 10px;border:1px solid #d9d8d4;border-radius:4px;background:#fcfcfb;font:600 12px system-ui';badge.textContent='進捗: 接続中';document.body.append(badge);const head=${JSON.stringify(headDigest)};let lastReceipt=Date.now();let stream=null;const receive=event=>{const next=JSON.parse(event.data);if(typeof next.head_digest!=='string')return;lastReceipt=Date.now();badge.textContent='進捗: 最新';if(next.head_digest!==head){badge.textContent='進捗: 更新を反映中';location.reload();}};const connect=()=>{if(stream)stream.close();badge.textContent='進捗: 接続中';stream=new EventSource(${JSON.stringify(eventsPath)});stream.onopen=()=>{lastReceipt=Date.now();};stream.addEventListener('state',receive);stream.addEventListener('ping',receive);stream.addEventListener('lattice-error',event=>{lastReceipt=Date.now();const detail=JSON.parse(event.data);badge.textContent='進捗: エラー '+detail.code;badge.style.borderColor='#d03b3b';});stream.onerror=()=>{badge.textContent='進捗: 再接続中';};};connect();setInterval(()=>{if(Date.now()-lastReceipt>${SSE_STALE_MS})connect();},${SSE_WATCHDOG_MS});})();</script>`;
65
- const publicMetadata = '<meta name="description" content="Latticeで管理しているプロジェクトの依存工程と進捗を確認できます。"><meta name="robots" content="noindex, nofollow"><meta name="theme-color" content="#f7f3ea">';
65
+ const publicMetadata = '<meta name="description" content="Latticeで管理しているプロジェクトの依存工程と進捗を確認できます。"><meta name="theme-color" content="#f7f3ea">';
66
66
  const publicStyle = '<style>body[data-gantt-root]{grid-template-rows:auto minmax(0,1fr)}.lattice-live-brand{z-index:10;display:flex;align-items:center;gap:8px;min-width:0;padding:10px 16px;border-bottom:1px solid #d8d0c5;background:#f7f3ea;color:#6c655d;font:600 12px/1.5 system-ui,-apple-system,"Hiragino Sans","Yu Gothic UI",sans-serif}.lattice-live-brand a{color:#201d19;text-decoration:none}.lattice-live-brand a:hover{color:#315cbe}.lattice-live-brand strong{color:#201d19}.lattice-live-brand-note{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.lattice-live-back{margin-left:auto!important;color:#315cbe!important;font-weight:750}@media(max-width:560px){.lattice-live-brand{padding:9px 12px}.lattice-live-brand-note{display:none}}</style>';
67
67
  const publicHeader = '<header class="lattice-live-brand"><a href="https://kitepon.dev/">kitepon.dev</a><span aria-hidden="true">/</span><strong>Lattice</strong><span class="lattice-live-brand-note">公開工程表</span><a class="lattice-live-back" href="/projects/">一覧へ戻る</a></header>';
68
68
  const live = html
@@ -87,14 +87,14 @@ function dashboardHtml(projects) {
87
87
  }).join('');
88
88
  const content = rows.length === 0 ? '<p>アクティブなプロジェクトはありません。</p>'
89
89
  : `<ul>${rows}</ul>`;
90
- return `<!doctype html><html lang="ja"><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="description" content="Latticeが管理している公開中の工程と現在地を確認できます。"><meta name="robots" content="noindex, nofollow"><meta property="og:title" content="公開中の工程表 — Lattice"><meta property="og:description" content="Latticeが管理している公開中の工程と現在地を確認できます。"><meta name="theme-color" content="#f7f3ea"><title>公開中の工程表 — Lattice</title><style>:root{color-scheme:light;--paper:#f7f3ea;--panel:#fffdf8;--ink:#201d19;--soft:#6c655d;--line:#d8d0c5;--cobalt:#315cbe;--orange:#e85f2a}*{box-sizing:border-box}body{min-height:100vh;margin:0;color:var(--ink);background:var(--paper);font:16px/1.7 system-ui,-apple-system,sans-serif}.shell{max-width:880px;margin:0 auto;padding:28px 22px 40px}.brand{display:flex;align-items:center;gap:9px;padding-bottom:24px;border-bottom:1px solid var(--line);font-size:.88rem}.brand a,.footer a{color:var(--ink);font-weight:800;text-decoration:none}.brand a:hover,.footer a:hover{color:var(--cobalt)}.brand span{color:var(--soft)}main{padding:64px 0 72px}.eyebrow{margin:0 0 8px;color:var(--orange);font-size:.76rem;font-weight:800;letter-spacing:.14em}.lead{max-width:620px;margin:0 0 34px;color:var(--soft)}h1{margin:0 0 14px;font-size:clamp(2rem,6vw,3.4rem);line-height:1.12;letter-spacing:-.04em}ul{display:grid;gap:12px;margin:0;padding:0;list-style:none}li a{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:20px;padding:18px 20px;border:1px solid var(--line);border-radius:12px;color:inherit;background:var(--panel);text-decoration:none;box-shadow:0 8px 28px rgba(48,39,27,.04)}li a:hover{border-color:var(--cobalt);transform:translateY(-1px)}li strong{font-size:1.04rem}li code{color:var(--soft);font-size:.78rem}li span{color:var(--cobalt);font-weight:800}.note{margin:28px 0 0;padding:16px 18px;border-left:3px solid var(--orange);color:var(--soft);background:rgba(255,253,248,.72);font-size:.88rem}.footer{display:flex;flex-wrap:wrap;justify-content:space-between;gap:16px;padding-top:20px;border-top:1px solid var(--line);color:var(--soft);font-size:.82rem}.footer nav{display:flex;gap:18px}@media(max-width:560px){.shell{padding:20px 16px 32px}main{padding:44px 0 56px}li a{grid-template-columns:minmax(0,1fr) auto;padding:16px}li code{grid-column:1/-1;grid-row:2}.footer{display:block}.footer nav{margin-top:10px}}</style></head><body><div class="shell"><header class="brand"><a href="https://kitepon.dev/">kitepon.dev</a><span aria-hidden="true">/</span><strong>Lattice</strong></header><main><p class="eyebrow">LIVE DEVELOPMENT</p><h1>公開中の工程表</h1><p class="lead">Latticeが管理しているプロジェクトの工程と、いまどこまで進んでいるかを公開データから確認できます。</p>${content}<p class="note">表示内容はLatticeの記録から自動生成されます。製品の紹介や使い方はGitHubをご覧ください。</p></main><footer class="footer"><span>kitepon.dev の開発工程を、Latticeで可視化しています。</span><nav aria-label="関連リンク"><a href="https://kitepon.dev/">kitepon.dev</a><a href="https://github.com/kitepon-rgb/Lattice">GitHub</a></nav></footer></div></body></html>`;
90
+ return `<!doctype html><html lang="ja"><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="description" content="Latticeが管理している公開中の工程と現在地を確認できます。"><meta property="og:title" content="公開中の工程表 — Lattice"><meta property="og:description" content="Latticeが管理している公開中の工程と現在地を確認できます。"><meta name="theme-color" content="#f7f3ea"><title>公開中の工程表 — Lattice</title><style>:root{color-scheme:light;--paper:#f7f3ea;--panel:#fffdf8;--ink:#201d19;--soft:#6c655d;--line:#d8d0c5;--cobalt:#315cbe;--orange:#e85f2a}*{box-sizing:border-box}body{min-height:100vh;margin:0;color:var(--ink);background:var(--paper);font:16px/1.7 system-ui,-apple-system,sans-serif}.shell{max-width:880px;margin:0 auto;padding:28px 22px 40px}.brand{display:flex;align-items:center;gap:9px;padding-bottom:24px;border-bottom:1px solid var(--line);font-size:.88rem}.brand a,.footer a{color:var(--ink);font-weight:800;text-decoration:none}.brand a:hover,.footer a:hover{color:var(--cobalt)}.brand span{color:var(--soft)}main{padding:64px 0 72px}.eyebrow{margin:0 0 8px;color:var(--orange);font-size:.76rem;font-weight:800;letter-spacing:.14em}.lead{max-width:620px;margin:0 0 34px;color:var(--soft)}h1{margin:0 0 14px;font-size:clamp(2rem,6vw,3.4rem);line-height:1.12;letter-spacing:-.04em}ul{display:grid;gap:12px;margin:0;padding:0;list-style:none}li a{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:20px;padding:18px 20px;border:1px solid var(--line);border-radius:12px;color:inherit;background:var(--panel);text-decoration:none;box-shadow:0 8px 28px rgba(48,39,27,.04)}li a:hover{border-color:var(--cobalt);transform:translateY(-1px)}li strong{font-size:1.04rem}li code{color:var(--soft);font-size:.78rem}li span{color:var(--cobalt);font-weight:800}.note{margin:28px 0 0;padding:16px 18px;border-left:3px solid var(--orange);color:var(--soft);background:rgba(255,253,248,.72);font-size:.88rem}.footer{display:flex;flex-wrap:wrap;justify-content:space-between;gap:16px;padding-top:20px;border-top:1px solid var(--line);color:var(--soft);font-size:.82rem}.footer nav{display:flex;gap:18px}@media(max-width:560px){.shell{padding:20px 16px 32px}main{padding:44px 0 56px}li a{grid-template-columns:minmax(0,1fr) auto;padding:16px}li code{grid-column:1/-1;grid-row:2}.footer{display:block}.footer nav{margin-top:10px}}</style></head><body><div class="shell"><header class="brand"><a href="https://kitepon.dev/">kitepon.dev</a><span aria-hidden="true">/</span><strong>Lattice</strong></header><main><p class="eyebrow">LIVE DEVELOPMENT</p><h1>公開中の工程表</h1><p class="lead">Latticeが管理しているプロジェクトの工程と、いまどこまで進んでいるかを公開データから確認できます。</p>${content}<p class="note">表示内容はLatticeの記録から自動生成されます。製品の紹介や使い方はGitHubをご覧ください。</p></main><footer class="footer"><span>kitepon.dev の開発工程を、Latticeで可視化しています。</span><nav aria-label="関連リンク"><a href="https://kitepon.dev/">kitepon.dev</a><a href="https://github.com/kitepon-rgb/Lattice">GitHub</a></nav></footer></div></body></html>`;
91
91
  }
92
92
 
93
93
  function notFoundHtml(code, path) {
94
94
  const reason = code === 'PROJECT_NOT_FOUND'
95
95
  ? '指定された工程表は、公開を終了したかURLが変わった可能性があります。'
96
96
  : '指定されたページは、この公開工程表にはありません。';
97
- return `<!doctype html><html lang="ja"><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex, nofollow"><meta name="theme-color" content="#f7f3ea"><title>ページが見つかりません — Lattice</title><style>:root{color-scheme:light;--paper:#f7f3ea;--panel:#fffdf8;--ink:#201d19;--soft:#6c655d;--line:#d8d0c5;--cobalt:#315cbe;--orange:#e85f2a}*{box-sizing:border-box}body{min-height:100vh;margin:0;color:var(--ink);background:var(--paper);font:16px/1.7 system-ui,-apple-system,"Hiragino Sans","Yu Gothic UI",sans-serif}.shell{width:min(720px,calc(100% - 32px));margin:0 auto;padding:28px 0 40px}.brand{display:flex;align-items:center;gap:9px;padding-bottom:24px;border-bottom:1px solid var(--line);font-size:.88rem}.brand a{color:var(--ink);font-weight:800;text-decoration:none}.brand a:hover{color:var(--cobalt)}.brand span{color:var(--soft)}main{padding:clamp(64px,12vw,112px) 0}.eyebrow{margin:0 0 10px;color:var(--orange);font-size:.76rem;font-weight:800;letter-spacing:.14em}h1{margin:0 0 16px;font-size:clamp(2.1rem,7vw,4rem);line-height:1.1;letter-spacing:-.045em}p{max-width:620px;margin:0;color:var(--soft)}code{display:block;margin-top:22px;padding:12px 14px;overflow-wrap:anywhere;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--soft);font-size:.78rem}.actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:28px}.actions a{display:inline-flex;align-items:center;min-height:44px;padding:0 16px;border:1px solid var(--line);border-radius:8px;color:var(--ink);background:var(--panel);font-weight:750;text-decoration:none}.actions a:first-child{border-color:var(--cobalt);color:#fff;background:var(--cobalt)}.actions a:hover{transform:translateY(-1px)}</style></head><body><div class="shell"><header class="brand"><a href="https://kitepon.dev/">kitepon.dev</a><span aria-hidden="true">/</span><strong>Lattice</strong></header><main><p class="eyebrow">404 · ${escapeHtml(code)}</p><h1>ページが見つかりません</h1><p>${reason}</p><code>${escapeHtml(path)}</code><nav class="actions" aria-label="戻り先"><a href="/projects/">公開工程表の一覧へ</a><a href="https://kitepon.dev/">kitepon.devへ</a></nav></main></div></body></html>`;
97
+ return `<!doctype html><html lang="ja"><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#f7f3ea"><title>ページが見つかりません — Lattice</title><style>:root{color-scheme:light;--paper:#f7f3ea;--panel:#fffdf8;--ink:#201d19;--soft:#6c655d;--line:#d8d0c5;--cobalt:#315cbe;--orange:#e85f2a}*{box-sizing:border-box}body{min-height:100vh;margin:0;color:var(--ink);background:var(--paper);font:16px/1.7 system-ui,-apple-system,"Hiragino Sans","Yu Gothic UI",sans-serif}.shell{width:min(720px,calc(100% - 32px));margin:0 auto;padding:28px 0 40px}.brand{display:flex;align-items:center;gap:9px;padding-bottom:24px;border-bottom:1px solid var(--line);font-size:.88rem}.brand a{color:var(--ink);font-weight:800;text-decoration:none}.brand a:hover{color:var(--cobalt)}.brand span{color:var(--soft)}main{padding:clamp(64px,12vw,112px) 0}.eyebrow{margin:0 0 10px;color:var(--orange);font-size:.76rem;font-weight:800;letter-spacing:.14em}h1{margin:0 0 16px;font-size:clamp(2.1rem,7vw,4rem);line-height:1.1;letter-spacing:-.045em}p{max-width:620px;margin:0;color:var(--soft)}code{display:block;margin-top:22px;padding:12px 14px;overflow-wrap:anywhere;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--soft);font-size:.78rem}.actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:28px}.actions a{display:inline-flex;align-items:center;min-height:44px;padding:0 16px;border:1px solid var(--line);border-radius:8px;color:var(--ink);background:var(--panel);font-weight:750;text-decoration:none}.actions a:first-child{border-color:var(--cobalt);color:#fff;background:var(--cobalt)}.actions a:hover{transform:translateY(-1px)}</style></head><body><div class="shell"><header class="brand"><a href="https://kitepon.dev/">kitepon.dev</a><span aria-hidden="true">/</span><strong>Lattice</strong></header><main><p class="eyebrow">404 · ${escapeHtml(code)}</p><h1>ページが見つかりません</h1><p>${reason}</p><code>${escapeHtml(path)}</code><nav class="actions" aria-label="戻り先"><a href="/projects/">公開工程表の一覧へ</a><a href="https://kitepon.dev/">kitepon.devへ</a></nav></main></div></body></html>`;
98
98
  }
99
99
 
100
100
  function validateProject(project) {