devmethod-ai 0.1.0-rc.2 → 0.2.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.
Files changed (228) hide show
  1. package/.agents/skills/decision-architecture/SKILL.md +15 -13
  2. package/.agents/skills/decision-architecture/assets/ADR.md +18 -17
  3. package/.agents/skills/decision-architecture/references/api-contracts.md +21 -16
  4. package/.agents/skills/decision-architecture/references/backend-boundaries.md +21 -18
  5. package/.agents/skills/decision-architecture/references/product-decisions.md +14 -17
  6. package/.agents/skills/design-to-code/SKILL.md +18 -11
  7. package/.agents/skills/design-to-code/assets/UI_ACCEPTANCE.md +13 -12
  8. package/.agents/skills/design-to-code/references/ux-contract.md +20 -15
  9. package/.agents/skills/design-to-code/references/visual-creation.md +37 -0
  10. package/.agents/skills/project-foundation/SKILL.md +39 -30
  11. package/.agents/skills/project-foundation/assets/AGENTS.foundation.md +16 -15
  12. package/.agents/skills/project-foundation/assets/PROJECT_PROFILE.md +24 -23
  13. package/.agents/skills/project-foundation/assets/START_HERE.md +17 -14
  14. package/.agents/skills/project-foundation/references/mission-context.md +30 -0
  15. package/.agents/skills/project-foundation/references/operating-commands.md +49 -34
  16. package/.agents/skills/project-foundation/references/profiles/README.md +18 -0
  17. package/.agents/skills/project-foundation/references/profiles/ci.md +11 -0
  18. package/.agents/skills/project-foundation/references/profiles/cloud-delivery.md +13 -0
  19. package/.agents/skills/project-foundation/references/profiles/messaging.md +11 -0
  20. package/.agents/skills/project-foundation/references/profiles/mongodb.md +11 -0
  21. package/.agents/skills/project-foundation/references/profiles/node-nest.md +11 -0
  22. package/.agents/skills/project-foundation/references/profiles/postgres-drizzle.md +11 -0
  23. package/.agents/skills/project-foundation/references/profiles/react-next.md +11 -0
  24. package/.agents/skills/project-foundation/references/profiles/typescript.md +11 -0
  25. package/.agents/skills/project-foundation/references/work-sizing.md +31 -0
  26. package/.agents/skills/react-feature-engineering/SKILL.md +26 -22
  27. package/.agents/skills/react-feature-engineering/references/review-and-sources.md +41 -37
  28. package/.agents/skills/reliable-ai-integration/SKILL.md +13 -10
  29. package/.agents/skills/reliable-ai-integration/assets/AI_EVALUATION.md +19 -18
  30. package/.agents/skills/reliable-ai-integration/references/evidence-and-media.md +22 -18
  31. package/.agents/skills/reliable-ai-integration/references/jobs-and-costs.md +20 -15
  32. package/.agents/skills/scoped-delivery/SKILL.md +25 -18
  33. package/.agents/skills/scoped-delivery/assets/CHECKPOINT.md +22 -11
  34. package/.agents/skills/scoped-delivery/assets/MISSION.md +35 -0
  35. package/.agents/skills/scoped-delivery/assets/SLICE.md +15 -14
  36. package/.agents/skills/scoped-delivery/assets/VERIFICATION.md +19 -0
  37. package/.agents/skills/scoped-delivery/references/verification-and-cost.md +21 -18
  38. package/COMPATIBILITY.md +3 -3
  39. package/CONTRIBUTING.md +13 -0
  40. package/README.md +94 -9
  41. package/START_HERE.md +24 -0
  42. package/VALIDATION.md +37 -0
  43. package/dist/checkpoint.js +171 -0
  44. package/dist/cli.js +118 -17
  45. package/dist/doctor.js +113 -0
  46. package/dist/filesystem.js +32 -0
  47. package/dist/init.js +38 -34
  48. package/dist/mission.js +85 -0
  49. package/dist/planner.js +65 -0
  50. package/dist/records.js +76 -0
  51. package/dist/update.js +57 -0
  52. package/docs/ADR-001-distribution.md +13 -0
  53. package/docs/ADR-002-read-only-diagnostics.md +25 -0
  54. package/docs/ADR-003-installation-provenance.md +15 -0
  55. package/docs/ADR-004-checkpoint-resumption.md +23 -0
  56. package/docs/ADR-005-mission-context.md +11 -0
  57. package/docs/ADR-006-opt-in-native-pilot.md +15 -0
  58. package/docs/BMAD-COMPARISON.md +34 -0
  59. package/docs/CODEX-ADAPTER-VALIDATION.md +20 -0
  60. package/docs/COMPARISON-ITERATION.md +25 -0
  61. package/docs/DOCTOR.md +23 -0
  62. package/docs/EVALUATION.md +39 -0
  63. package/docs/EVOLUTION.md +34 -0
  64. package/docs/GREENFIELD-VALIDATION.md +36 -0
  65. package/docs/MILESTONES.md +39 -0
  66. package/docs/MISSIONS.md +47 -0
  67. package/docs/NATIVE-PILOT-RESULTS.md +22 -0
  68. package/docs/NATIVE-PILOT.md +17 -0
  69. package/docs/ORCHESTRATION.md +11 -0
  70. package/docs/RC2-VALIDATION.md +50 -0
  71. package/docs/RELEASE-0.1.0.md +60 -0
  72. package/docs/RELEASE-0.2.0.md +37 -0
  73. package/docs/RELEASE-CHECKLIST.md +13 -0
  74. package/docs/RELEASE-NOTES-0.1.0-rc.2.md +32 -0
  75. package/docs/RELEASE-NOTES-0.1.0.md +11 -0
  76. package/docs/RESUMPTION.md +87 -0
  77. package/docs/ROADMAP.md +39 -0
  78. package/docs/STACK-PROFILES.md +9 -0
  79. package/docs/TROUBLESHOOTING.md +16 -0
  80. package/docs/UPDATES.md +30 -0
  81. package/docs/VISUAL-CREATION-PROPOSAL.md +21 -0
  82. package/docs/VISUAL-WORKFLOW.md +75 -0
  83. package/docs/images/devmethod-delivery.svg +10 -0
  84. package/docs/images/devmethod-flow.svg +13 -0
  85. package/docs/media/from-zero/README.md +17 -0
  86. package/docs/media/from-zero/devmethod-demo.fr.srt +59 -0
  87. package/docs/media/from-zero/scenes.json +50 -0
  88. package/docs/media/full-chain-4k/README.md +37 -0
  89. package/docs/media/full-chain-4k/devmethod-chaine-complete.fr.srt +435 -0
  90. package/docs/media/full-chain-4k/scenes.json +173 -0
  91. package/docs/media/full-chain-4k/transcripts.fr.md +315 -0
  92. package/docs/media/visual-chain/README.md +44 -0
  93. package/docs/media/visual-chain/assets/add-book-v1.png +0 -0
  94. package/docs/media/visual-chain/assets/completed-v1.png +0 -0
  95. package/docs/media/visual-chain/devmethod-du-besoin-au-produit.fr.srt +147 -0
  96. package/docs/media/visual-chain/execution.fr.md +30 -0
  97. package/docs/media/visual-chain/image-prompts.json +9 -0
  98. package/docs/media/visual-chain/reference-hashes.json +5 -0
  99. package/docs/media/visual-chain/scenes.json +232 -0
  100. package/docs/media/visual-chain/video-preview.jpg +0 -0
  101. package/docs/missions/visual-workflow.md +46 -0
  102. package/evaluation/COMPARISONS.md +33 -0
  103. package/evaluation/README.md +35 -0
  104. package/evaluation/candidate-setup.json +33 -0
  105. package/evaluation/comparison-v2-readiness.json +18 -0
  106. package/evaluation/comparison-v2-results.json +58 -0
  107. package/evaluation/fixtures/dependency/CONTRIBUTING.md +3 -0
  108. package/evaluation/fixtures/dependency/TICKET.md +3 -0
  109. package/evaluation/fixtures/dependency/acceptance.test.mjs +4 -0
  110. package/evaluation/fixtures/dependency/api.mjs +1 -0
  111. package/evaluation/fixtures/failing-verification/CONTRIBUTING.md +3 -0
  112. package/evaluation/fixtures/failing-verification/acceptance.test.mjs +4 -0
  113. package/evaluation/fixtures/failing-verification/total.mjs +1 -0
  114. package/evaluation/fixtures/stale-handoff/CHECKPOINT.md +3 -0
  115. package/evaluation/fixtures/stale-handoff/CONTRACT.md +3 -0
  116. package/evaluation/fixtures/stale-handoff/CONTRIBUTING.md +3 -0
  117. package/evaluation/fixtures/stale-handoff/acceptance.test.mjs +4 -0
  118. package/evaluation/fixtures/stale-handoff/label.mjs +1 -0
  119. package/evaluation/fixtures/update-customization/README.md +13 -0
  120. package/evaluation/fixtures.json +85 -0
  121. package/evaluation/greenfield/acceptance.test.mjs +57 -0
  122. package/evaluation/greenfield/security.test.mjs +28 -0
  123. package/evaluation/native-pilot-results.json +155 -0
  124. package/examples/README.md +36 -0
  125. package/examples/bugfix/acceptance.test.mjs +17 -0
  126. package/examples/bugfix/page-size.mjs +4 -0
  127. package/examples/clair-from-zero/AGENT-EVALUATION.md +15 -0
  128. package/examples/clair-from-zero/MISSION.md +41 -0
  129. package/examples/clair-from-zero/README.md +24 -0
  130. package/examples/clair-from-zero/app/app.mjs +69 -0
  131. package/examples/clair-from-zero/app/domain.mjs +30 -0
  132. package/examples/clair-from-zero/app/index.html +20 -0
  133. package/examples/clair-from-zero/app/storage.mjs +4 -0
  134. package/examples/clair-from-zero/app/styles.css +128 -0
  135. package/examples/clair-from-zero/browser-check.cjs +2 -0
  136. package/examples/clair-from-zero/tests/domain.test.mjs +37 -0
  137. package/examples/fullstack/CONTRACT.md +23 -0
  138. package/examples/fullstack/DEPENDENCIES.md +18 -0
  139. package/examples/fullstack/README.md +100 -0
  140. package/examples/fullstack/api/application/tasks.ts +13 -0
  141. package/examples/fullstack/api/domain/task.ts +12 -0
  142. package/examples/fullstack/api/http.ts +26 -0
  143. package/examples/fullstack/api/infrastructure/migrate.ts +15 -0
  144. package/examples/fullstack/api/infrastructure/postgres.ts +18 -0
  145. package/examples/fullstack/api/infrastructure/schema.ts +6 -0
  146. package/examples/fullstack/api/main.ts +17 -0
  147. package/examples/fullstack/api/tsconfig.json +17 -0
  148. package/examples/fullstack/compose.yaml +14 -0
  149. package/examples/fullstack/migrations/0000_tasks.sql +5 -0
  150. package/examples/fullstack/migrations/meta/_journal.json +13 -0
  151. package/examples/fullstack/mission.json +34 -0
  152. package/examples/fullstack/package-lock.json +2661 -0
  153. package/examples/fullstack/package.json +45 -0
  154. package/examples/fullstack/tests/e2e.test.cjs +54 -0
  155. package/examples/fullstack/tests/http.test.cjs +33 -0
  156. package/examples/fullstack/tests/postgres.test.cjs +29 -0
  157. package/examples/fullstack/tests/unit.test.cjs +20 -0
  158. package/examples/fullstack/tests/web-model.test.cjs +23 -0
  159. package/examples/fullstack/web/app/layout.tsx +4 -0
  160. package/examples/fullstack/web/app/page.tsx +11 -0
  161. package/examples/fullstack/web/features/tasks/components/task-list.tsx +11 -0
  162. package/examples/fullstack/web/features/tasks/hooks/use-task-filter.ts +7 -0
  163. package/examples/fullstack/web/features/tasks/model/tasks.ts +12 -0
  164. package/examples/fullstack/web/features/tasks/server/list-tasks.ts +8 -0
  165. package/examples/fullstack/web/next.config.mjs +2 -0
  166. package/examples/fullstack/web/tsconfig.json +36 -0
  167. package/examples/mission/CONTRACT.md +3 -0
  168. package/examples/mission/mission.json +18 -0
  169. package/examples/mission/plan.json +11 -0
  170. package/examples/pocket-tasks/BRIEF.md +21 -0
  171. package/examples/pocket-tasks/PROJECT_PROFILE.md +19 -0
  172. package/examples/pocket-tasks/README.md +43 -0
  173. package/examples/pocket-tasks/docs/ADR-001.md +7 -0
  174. package/examples/pocket-tasks/docs/HANDOFF.md +16 -0
  175. package/examples/pocket-tasks/docs/MISSION.md +15 -0
  176. package/examples/pocket-tasks/docs/VERIFICATION.md +58 -0
  177. package/examples/pocket-tasks/package.json +1 -0
  178. package/examples/pocket-tasks/public/app.js +84 -0
  179. package/examples/pocket-tasks/public/index.html +12 -0
  180. package/examples/pocket-tasks/public/styles.css +1 -0
  181. package/examples/pocket-tasks/server.mjs +79 -0
  182. package/examples/pocket-tasks/src/domain.mjs +31 -0
  183. package/examples/pocket-tasks/src/store.mjs +49 -0
  184. package/examples/pocket-tasks/tests/app.test.mjs +132 -0
  185. package/examples/pocket-tasks/tests/ui-errors.test.mjs +49 -0
  186. package/examples/visual-pilot/README.md +11 -0
  187. package/examples/visual-pilot/app/app.js +10 -0
  188. package/examples/visual-pilot/app/index.html +1 -0
  189. package/examples/visual-pilot/app/reference.png +0 -0
  190. package/examples/visual-pilot/app/style.css +3 -0
  191. package/examples/visual-pilot/browser-check.cjs +3 -0
  192. package/examples/visual-pilot/desktop-actual.png +0 -0
  193. package/examples/visual-pilot/directions-prompt.txt +1 -0
  194. package/examples/visual-pilot/directions-v1.png +0 -0
  195. package/examples/visual-pilot/editorial-mockup-prompt.txt +1 -0
  196. package/examples/visual-pilot/editorial-mockup-v1.png +0 -0
  197. package/examples/visual-pilot/mismatch-probe.png +0 -0
  198. package/examples/visual-pilot/mobile-actual.png +0 -0
  199. package/examples/visual-pilot/quick-filter/AGENT-RESULT.md +25 -0
  200. package/examples/visual-pilot/quick-filter/baseline.log +42 -0
  201. package/examples/visual-pilot/quick-filter/filter.mjs +3 -0
  202. package/examples/visual-pilot/quick-filter/filter.test.mjs +9 -0
  203. package/package.json +15 -3
  204. package/scripts/check-codex-git.mjs +20 -0
  205. package/scripts/check-docs.mjs +14 -0
  206. package/scripts/check-native-sandbox.mjs +11 -0
  207. package/scripts/comparison-readiness.mjs +26 -0
  208. package/scripts/comparison.mjs +42 -0
  209. package/scripts/evaluation.mjs +64 -0
  210. package/scripts/fullstack-mission.mjs +30 -0
  211. package/scripts/hosts/checks.mjs +13 -0
  212. package/scripts/hosts/codex-meter.mjs +17 -0
  213. package/scripts/hosts/codex-rpc.mjs +44 -0
  214. package/scripts/hosts/codex-task.mjs +29 -0
  215. package/scripts/hosts/codex.mjs +28 -0
  216. package/scripts/media/demo-actions.cjs +16 -0
  217. package/scripts/media/encode-demo.py +22 -0
  218. package/scripts/media/record-demo.cjs +30 -0
  219. package/scripts/media/visual-short/check.cjs +1 -0
  220. package/scripts/media/visual-short/encode.py +16 -0
  221. package/scripts/media/visual-short/record.cjs +4 -0
  222. package/scripts/media/visual-short/story.py +34 -0
  223. package/scripts/native-host.mjs +55 -0
  224. package/scripts/package-smoke.mjs +64 -0
  225. package/scripts/probe-codex-children.mjs +41 -0
  226. package/scripts/run-comparison-v2.mjs +45 -0
  227. package/scripts/run-native-pilot.mjs +76 -0
  228. package/scripts/summarize-native-pilot.mjs +7 -0
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+ const $ = selector => document.querySelector(selector);
3
+ let tasks = [], filter = 'all', busy = false, editing = null;
4
+ const status = message => { $('#status').textContent = message; };
5
+ function showError(message, canReload = true) { $('#error').textContent = message; $('#error').hidden = !message; $('#retry').hidden = !message || !canReload; }
6
+ async function api(path = '', options = {}) {
7
+ const response = await fetch(`/api/tasks${path}`, { ...options, headers: { 'Content-Type': 'application/json' } });
8
+ if (response.status === 204) return;
9
+ const result = await response.json();
10
+ if (!response.ok) { const error = new Error(result.error || 'Request failed.'); error.status = response.status; throw error; }
11
+ return result;
12
+ }
13
+ function setBusy(value) {
14
+ busy = value;
15
+ document.querySelectorAll('button,input').forEach(element => { element.disabled = value; });
16
+ $('#tasks').setAttribute('aria-busy', String(value));
17
+ }
18
+ function button(label, action, className = '') {
19
+ const element = document.createElement('button'); element.type = 'button'; element.textContent = label; element.className = className; element.addEventListener('click', action); return element;
20
+ }
21
+ function render() {
22
+ const list = $('#tasks'); list.replaceChildren();
23
+ const visible = tasks.filter(task => filter === 'all' || task.done === (filter === 'completed'));
24
+ $('#count').textContent = `${tasks.length} ${tasks.length === 1 ? 'task' : 'tasks'} · ${tasks.filter(task => !task.done).length} active`;
25
+ $('#empty').hidden = visible.length > 0;
26
+ $('#empty').textContent = tasks.length === 0 ? 'No tasks yet. Add your first above.' : `No ${filter} tasks.`;
27
+ for (const task of visible) {
28
+ const row = document.createElement('li'); row.className = `task${task.done ? ' done' : ''}`;
29
+ if (editing === task.id) {
30
+ const form = document.createElement('form'); form.className = 'edit-form';
31
+ const label = document.createElement('label'); label.htmlFor = 'edit-title'; label.textContent = 'Edit task title';
32
+ const controls = document.createElement('div'); controls.className = 'edit-row';
33
+ const input = document.createElement('input'); input.id = 'edit-title'; input.value = task.title; input.maxLength = 120; input.required = true;
34
+ const save = document.createElement('button'); save.type = 'submit'; save.textContent = 'Save'; save.className = 'primary';
35
+ const cancel = button('Cancel', () => { editing = null; render(); focusEdit(task.id); });
36
+ form.addEventListener('submit', event => { event.preventDefault(); mutate(`/${task.id}`, 'PATCH', { title: input.value }, 'Task updated.', () => focusEdit(task.id)); });
37
+ input.addEventListener('keydown', event => { if (event.key === 'Escape') { editing = null; render(); focusEdit(task.id); } });
38
+ controls.append(input, save, cancel); form.append(label, controls); row.append(form);
39
+ } else {
40
+ const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = task.done; checkbox.setAttribute('aria-label', `Mark ${task.title} ${task.done ? 'active' : 'completed'}`);
41
+ checkbox.addEventListener('change', () => mutate(`/${task.id}`, 'PATCH', { done: checkbox.checked }, task.done ? 'Task reopened.' : 'Task completed.', () => { const next = document.querySelector(`[data-toggle="${task.id}"]`); (next || $('#new-title')).focus(); }));
42
+ checkbox.dataset.toggle = task.id;
43
+ const title = document.createElement('span'); title.className = 'task-title'; title.textContent = task.title;
44
+ const actions = document.createElement('div'); actions.className = 'actions';
45
+ const edit = button('Edit', () => { editing = task.id; render(); $('#edit-title').focus(); }); edit.dataset.edit = task.id; edit.setAttribute('aria-label', `Edit ${task.title}`);
46
+ const remove = button('Delete', () => mutate(`/${task.id}`, 'DELETE', undefined, 'Task deleted.', () => $('#new-title').focus()), 'delete'); remove.setAttribute('aria-label', `Delete ${task.title}`);
47
+ actions.append(edit, remove); row.append(checkbox, title, actions);
48
+ }
49
+ list.append(row);
50
+ }
51
+ document.querySelectorAll('[data-filter]').forEach(element => element.setAttribute('aria-pressed', String(element.dataset.filter === filter)));
52
+ }
53
+ function focusEdit(id) { (document.querySelector(`[data-edit="${id}"]`) || $('#new-title')).focus(); }
54
+ async function mutate(path, method, body, message, focus) {
55
+ if (busy) return;
56
+ setBusy(true); showError(''); status('Saving…');
57
+ try {
58
+ const result = await api(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
59
+ if (method === 'POST') { tasks.push(result.task); $('#new-title').value = ''; }
60
+ else if (method === 'PATCH') tasks = tasks.map(task => task.id === result.task.id ? result.task : task);
61
+ else tasks = tasks.filter(task => `/${task.id}` !== path);
62
+ editing = null; render(); status(message);
63
+ } catch (error) {
64
+ if (error.status >= 400 && error.status < 500) {
65
+ showError(error.message, false); status('Change rejected. Correct the input and try again.');
66
+ document.querySelectorAll('[data-toggle]').forEach(checkbox => { checkbox.checked = tasks.find(task => task.id === checkbox.dataset.toggle).done; });
67
+ focus = () => ($('#edit-title') || $('#new-title')).focus();
68
+ } else {
69
+ showError(`${error.message} Reload to check saved state before retrying a change.`); status('Change could not be confirmed.'); render();
70
+ }
71
+ }
72
+ finally { setBusy(false); focus?.(); }
73
+ }
74
+ async function load() {
75
+ if (busy) return;
76
+ setBusy(true); showError(''); status('Loading tasks…'); $('#empty').hidden = true;
77
+ try { tasks = (await api()).tasks; editing = null; render(); status('Tasks loaded.'); }
78
+ catch (error) { showError(error.message); status('Tasks could not be loaded.'); }
79
+ finally { setBusy(false); }
80
+ }
81
+ $('#add-form').addEventListener('submit', event => { event.preventDefault(); mutate('', 'POST', { title: $('#new-title').value }, 'Task added.', () => $('#new-title').focus()); });
82
+ document.querySelectorAll('[data-filter]').forEach(element => element.addEventListener('click', () => { filter = element.dataset.filter; editing = null; render(); status(`Showing ${filter} tasks.`); }));
83
+ $('#retry').addEventListener('click', load);
84
+ load();
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Pocket Tasks</title><link rel="stylesheet" href="/styles.css"><script src="/app.js" defer></script></head>
4
+ <body><main>
5
+ <header><span class="eyebrow">A LITTLE SPACE TO GET THINGS DONE</span><h1>Pocket Tasks<span aria-hidden="true">.</span></h1><p>Clear your head. Start with one small thing.</p></header>
6
+ <section class="workspace" aria-label="Your tasks">
7
+ <form id="add-form"><label for="new-title">What needs doing?</label><div class="add-row"><input id="new-title" name="title" maxlength="120" required placeholder="e.g. Plan tomorrow’s walk" autocomplete="off"><button type="submit" class="primary">Add task</button></div></form>
8
+ <div class="toolbar"><div class="filters" role="group" aria-label="Filter tasks"><button type="button" data-filter="all" aria-pressed="true">All</button><button type="button" data-filter="active" aria-pressed="false">Active</button><button type="button" data-filter="completed" aria-pressed="false">Completed</button></div><span id="count">0 tasks</span></div>
9
+ <p id="status" role="status" aria-live="polite">Loading tasks…</p><div id="error" role="alert" hidden></div><button id="retry" type="button" hidden>Reload tasks</button>
10
+ <ul id="tasks" aria-label="Task list"></ul><p id="empty" hidden>No tasks yet. Add your first above.</p>
11
+ </section><footer>Stored on this computer. One task at a time.</footer>
12
+ </main></body></html>
@@ -0,0 +1 @@
1
+ :root{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#213e39;background:#f4f6f1;font-synthesis:none;line-height:1.5}*{box-sizing:border-box}body{margin:0}main{max-width:820px;margin:64px auto;padding:0 28px}header{margin-bottom:32px}.eyebrow{font-size:11px;font-weight:750;letter-spacing:2px;color:#5a7067}h1{font-size:clamp(36px,7vw,54px);letter-spacing:-2px;margin:12px 0 8px;line-height:1.1}h1 span{color:#bf651b}header p{color:#5b6e64;margin:0}.workspace{background:white;border:1px solid #dbe4d8;border-radius:20px;padding:28px;box-shadow:0 16px 40px #23463208}label{display:block;font-weight:650;font-size:14px;margin-bottom:10px}.add-row{display:flex;gap:10px}input[type=text],input:not([type]){min-width:0;flex:1;width:100%;border:1px solid #b8c9bf;background:#fbfcfa;border-radius:9px;padding:12px;font:inherit;color:inherit}button{border:1px solid #cdd8cf;border-radius:8px;background:#fff;padding:10px 14px;color:#29483e;font:inherit;font-size:14px;font-weight:650;cursor:pointer;min-height:44px}button:hover{background:#edf3ec}.primary{background:#285743;color:#fff;border-color:#285743}.primary:hover{background:#1c4232}button:disabled,input:disabled{opacity:.65;cursor:wait}:focus-visible{outline:3px solid #b2671d;outline-offset:3px}.toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px;border-bottom:1px solid #e7ece5;margin-top:26px;padding-bottom:18px}.filters{display:flex;gap:5px;flex-wrap:wrap}.filters button{border-color:transparent;font-size:13px;padding:8px 12px}.filters button[aria-pressed=true]{background:#e7f0e7;color:#17452f;border-color:#b5cbb7}#count{font-size:12px;color:#617567;white-space:nowrap}#status{font-size:13px;color:#4d695a;margin:16px 0 0;min-height:20px}#error{color:#8d281d;background:#fff0ec;padding:12px;margin-top:12px;border-radius:8px}#tasks{list-style:none;margin:8px 0 0;padding:0}.task{display:flex;align-items:center;gap:12px;padding:17px 0;border-bottom:1px solid #e8ece6}.task:last-child{border-bottom:0}.task input[type=checkbox]{width:22px;height:22px;flex-shrink:0;accent-color:#285743;cursor:pointer}.task-title{flex:1;min-width:0;overflow-wrap:anywhere}.task.done .task-title{text-decoration:line-through;color:#718076}.actions{display:flex;gap:6px}.actions button{font-size:12px;padding:8px 10px}.delete{color:#8b4030}.edit-form{width:100%}.edit-row{display:flex;gap:8px;flex-wrap:wrap}.edit-row input{flex-basis:200px}#empty{text-align:center;color:#667c6e;padding:32px 4px}footer{font-size:12px;color:#647569;text-align:center;margin-top:24px}[hidden]{display:none!important}@media(max-width:540px){main{margin:28px auto;padding:0 16px}.workspace{padding:18px;border-radius:14px}.add-row{flex-direction:column}.toolbar{align-items:flex-start;flex-direction:column;gap:6px}.task{flex-wrap:wrap;gap:10px}.task-title{flex-basis:calc(100% - 40px)}.actions{margin-left:32px}.edit-row input{flex-basis:100%}.eyebrow{font-size:9px;letter-spacing:1px}}
@@ -0,0 +1,79 @@
1
+ import { createServer } from 'node:http';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
+ import { resolve } from 'node:path';
5
+ import { createStore } from './src/store.mjs';
6
+ import { TaskError, validateBody } from './src/domain.mjs';
7
+
8
+ const assets = new Map([
9
+ ['/', ['index.html', 'text/html; charset=utf-8']],
10
+ ['/styles.css', ['styles.css', 'text/css; charset=utf-8']],
11
+ ['/app.js', ['app.js', 'text/javascript; charset=utf-8']],
12
+ ]);
13
+ function body(request) {
14
+ return new Promise((resolveBody, reject) => {
15
+ const chunks = []; let size = 0, exceeded = false;
16
+ request.on('data', chunk => {
17
+ size += chunk.length;
18
+ if (size > 16384) {
19
+ if (!exceeded) { exceeded = true; chunks.length = 0; reject(new TaskError('Request body exceeds 16 KiB.', 413)); }
20
+ } else if (!exceeded) chunks.push(chunk);
21
+ });
22
+ request.on('end', () => {
23
+ if (exceeded) return;
24
+ try { resolveBody(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
25
+ catch { reject(new TaskError('Malformed JSON.')); }
26
+ });
27
+ request.on('error', reject);
28
+ });
29
+ }
30
+ function validateLocalRequest(request) {
31
+ const expectedHost = `127.0.0.1:${request.socket.localPort}`;
32
+ const hostCount = request.rawHeaders.filter((_, index) => index % 2 === 0 && request.rawHeaders[index].toLowerCase() === 'host').length;
33
+ if (hostCount !== 1 || request.headers.host !== expectedHost) throw new TaskError('Use the local 127.0.0.1 address and server port.', 403);
34
+ if (!['POST', 'PATCH', 'DELETE'].includes(request.method)) return;
35
+ const originCount = request.rawHeaders.filter((_, index) => index % 2 === 0 && request.rawHeaders[index].toLowerCase() === 'origin').length;
36
+ if (originCount > 1 || (request.headers.origin !== undefined && request.headers.origin !== `http://${expectedHost}`) || request.headers['sec-fetch-site'] === 'cross-site') {
37
+ throw new TaskError('Cross-site changes are not permitted.', 403);
38
+ }
39
+ }
40
+ function requireJSON(request) {
41
+ if (request.headers['content-type']?.split(';', 1)[0].trim().toLowerCase() !== 'application/json') throw new TaskError('Content-Type must be application/json.', 415);
42
+ }
43
+ function json(response, status, value) {
44
+ response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
45
+ response.end(value === undefined ? undefined : JSON.stringify(value));
46
+ }
47
+ export function createTaskServer({ dataFile }) {
48
+ const store = createStore(resolve(dataFile));
49
+ return createServer(async (request, response) => {
50
+ response.setHeader('X-Content-Type-Options', 'nosniff');
51
+ response.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'");
52
+ try {
53
+ validateLocalRequest(request);
54
+ const pathname = new URL(request.url, 'http://127.0.0.1').pathname;
55
+ if (pathname === '/api/tasks' && request.method === 'GET') return json(response, 200, { tasks: await store.list() });
56
+ if (pathname === '/api/tasks' && request.method === 'POST') { requireJSON(request); return json(response, 201, { task: await store.create(validateBody(await body(request))) }); }
57
+ const match = /^\/api\/tasks\/([^/]+)$/.exec(pathname);
58
+ if (match && request.method === 'PATCH') { requireJSON(request); return json(response, 200, { task: await store.update(decodeURIComponent(match[1]), validateBody(await body(request), true)) }); }
59
+ if (match && request.method === 'DELETE') { await store.remove(decodeURIComponent(match[1])); return json(response, 204); }
60
+ if (request.method === 'GET' && assets.has(pathname)) {
61
+ const [file, type] = assets.get(pathname);
62
+ const content = await readFile(new URL(`./public/${file}`, import.meta.url));
63
+ response.writeHead(200, { 'Content-Type': type }); return response.end(content);
64
+ }
65
+ json(response, 404, { error: 'Not found.' });
66
+ } catch (error) {
67
+ const status = error instanceof TaskError ? error.status : error instanceof URIError ? 400 : 500;
68
+ json(response, status, { error: status === 500 ? 'Storage or server unavailable. Check local data and server permissions; existing data has not been reset.' : error.message });
69
+ }
70
+ });
71
+ }
72
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
73
+ const port = Number(process.env.PORT ?? 4318);
74
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('PORT must be an integer from 0 to 65535.');
75
+ const server = createTaskServer({ dataFile: process.env.DATA_FILE ?? fileURLToPath(new URL('./data/tasks.json', import.meta.url)) });
76
+ server.on('error', error => { console.error(`Pocket Tasks could not start: ${error.message}`); process.exitCode = 1; });
77
+ server.listen(port, '127.0.0.1', () => console.log(`Pocket Tasks: http://127.0.0.1:${server.address().port}`));
78
+ for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => server.close());
79
+ }
@@ -0,0 +1,31 @@
1
+ export class TaskError extends Error {
2
+ constructor(message, status = 400) { super(message); this.status = status; }
3
+ }
4
+ export function title(value) {
5
+ if (typeof value !== 'string' || !value.trim() || value.trim().length > 120) {
6
+ throw new TaskError('Title must contain 1–120 characters.');
7
+ }
8
+ return value.trim();
9
+ }
10
+ export function validateBody(body, patch = false) {
11
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new TaskError('Expected a JSON object.');
12
+ const result = {};
13
+ if (Object.hasOwn(body, 'title')) result.title = title(body.title);
14
+ if (patch && Object.hasOwn(body, 'done')) {
15
+ if (typeof body.done !== 'boolean') throw new TaskError('Done must be a boolean.');
16
+ result.done = body.done;
17
+ }
18
+ if (!patch && !Object.hasOwn(result, 'title')) throw new TaskError('A title is required.');
19
+ if (patch && !Object.keys(result).length) throw new TaskError('Provide title or done.');
20
+ return result;
21
+ }
22
+ export function validateStoredTasks(tasks) {
23
+ if (!Array.isArray(tasks)) throw new Error('Invalid stored task list');
24
+ const ids = new Set();
25
+ for (const task of tasks) {
26
+ if (!task || typeof task.id !== 'string' || !task.id || ids.has(task.id) ||
27
+ typeof task.done !== 'boolean' || typeof task.title !== 'string' || !task.title || task.title.length > 120 || task.title.trim() !== task.title) throw new Error('Invalid stored task');
28
+ ids.add(task.id);
29
+ }
30
+ return tasks;
31
+ }
@@ -0,0 +1,49 @@
1
+ import { readFile, mkdir, open, rename, unlink } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { TaskError, validateStoredTasks } from './domain.mjs';
5
+
6
+ export function createStore(dataFile) {
7
+ let tail = Promise.resolve();
8
+ const enqueue = fn => {
9
+ const result = tail.then(fn);
10
+ tail = result.catch(() => {});
11
+ return result;
12
+ };
13
+ async function read() {
14
+ let raw;
15
+ try { raw = await readFile(dataFile, 'utf8'); }
16
+ catch (error) { if (error.code === 'ENOENT') return []; throw error; }
17
+ return validateStoredTasks(JSON.parse(raw));
18
+ }
19
+ async function save(tasks) {
20
+ await mkdir(dirname(dataFile), { recursive: true });
21
+ const temporary = `${dataFile}.${randomUUID()}.tmp`;
22
+ try {
23
+ const handle = await open(temporary, 'wx', 0o600);
24
+ try { await handle.writeFile(`${JSON.stringify(tasks, null, 2)}\n`); await handle.sync(); }
25
+ finally { await handle.close(); }
26
+ await rename(temporary, dataFile);
27
+ } finally { await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; }); }
28
+ }
29
+ return {
30
+ list: () => enqueue(read),
31
+ create: input => enqueue(async () => {
32
+ const tasks = await read();
33
+ const task = { id: randomUUID(), title: input.title, done: false };
34
+ tasks.push(task); await save(tasks); return task;
35
+ }),
36
+ update: (id, input) => enqueue(async () => {
37
+ const tasks = await read();
38
+ const task = tasks.find(task => task.id === id);
39
+ if (!task) throw new TaskError('Task not found.', 404);
40
+ Object.assign(task, input); await save(tasks); return task;
41
+ }),
42
+ remove: id => enqueue(async () => {
43
+ const tasks = await read();
44
+ const index = tasks.findIndex(task => task.id === id);
45
+ if (index === -1) throw new TaskError('Task not found.', 404);
46
+ tasks.splice(index, 1); await save(tasks);
47
+ }),
48
+ };
49
+ }
@@ -0,0 +1,132 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, readFile, writeFile, rm, readdir } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { once } from 'node:events';
7
+ import { request as httpRequest } from 'node:http';
8
+ import { createTaskServer } from '../server.mjs';
9
+ import { validateBody } from '../src/domain.mjs';
10
+
11
+ async function fixture(t) {
12
+ const dir = await mkdtemp(join(tmpdir(), 'pocket-tasks-'));
13
+ const file = join(dir, 'tasks.json');
14
+ const servers = [];
15
+ async function start() {
16
+ const server = createTaskServer({ dataFile: file }); servers.push(server);
17
+ server.listen(0, '127.0.0.1'); await once(server, 'listening');
18
+ const base = `http://127.0.0.1:${server.address().port}`;
19
+ return { server, call: (path = '/api/tasks', method = 'GET', payload) => fetch(`${base}${path}`, { method, headers: { 'Content-Type': 'application/json' }, ...(payload === undefined ? {} : { body: typeof payload === 'string' ? payload : JSON.stringify(payload) }) }) };
20
+ }
21
+ t.after(async () => { for (const server of servers) if (server.listening) await new Promise(resolve => server.close(resolve)); await rm(dir, { recursive: true, force: true }); });
22
+ return { dir, file, start, ...await start() };
23
+ }
24
+
25
+ test('domain normalizes titles and rejects invalid shapes/fields', () => {
26
+ assert.deepEqual(validateBody({ title: ' hello ' }), { title: 'hello' });
27
+ assert.deepEqual(validateBody({ done: false }, true), { done: false });
28
+ for (const body of [null, [], {}, { title: '' }, { title: ' ' }, { title: 1 }, { title: 'x'.repeat(121) }]) assert.throws(() => validateBody(body));
29
+ for (const body of [null, [], {}, { extra: true }, { done: 'true' }, { title: ' ' }, { done: null }]) assert.throws(() => validateBody(body, true));
30
+ assert.equal(validateBody({ title: 'x'.repeat(120) }).title.length, 120);
31
+ });
32
+
33
+ test('HTTP CRUD and restart preserve titles, completion and deletion', async t => {
34
+ const f = await fixture(t);
35
+ assert.deepEqual(await (await f.call()).json(), { tasks: [] });
36
+ const created = await f.call('/api/tasks', 'POST', { title: ' <img src=x onerror=alert(1)> ' });
37
+ assert.equal(created.status, 201); const { task } = await created.json();
38
+ assert.equal(task.title, '<img src=x onerror=alert(1)>'); assert.equal(task.done, false); assert.equal(typeof task.id, 'string');
39
+ const updated = await f.call(`/api/tasks/${task.id}`, 'PATCH', { title: ' Revised ', done: true });
40
+ assert.equal(updated.status, 200); assert.deepEqual((await updated.json()).task, { id: task.id, title: 'Revised', done: true });
41
+ await new Promise(resolve => f.server.close(resolve));
42
+ const restarted = await f.start();
43
+ assert.deepEqual((await (await restarted.call()).json()).tasks, [{ id: task.id, title: 'Revised', done: true }]);
44
+ assert.equal((await restarted.call(`/api/tasks/${task.id}`, 'PATCH', { done: false })).status, 200);
45
+ assert.equal((await restarted.call(`/api/tasks/${task.id}`, 'DELETE')).status, 204);
46
+ assert.deepEqual(JSON.parse(await readFile(f.file, 'utf8')), []);
47
+ assert.equal((await restarted.call(`/api/tasks/${task.id}`, 'DELETE')).status, 404);
48
+ });
49
+
50
+ test('bad HTTP input and oversized body never change persisted state', async t => {
51
+ const f = await fixture(t);
52
+ const { task } = await (await f.call('/api/tasks', 'POST', { title: 'Keep me' })).json();
53
+ const before = await readFile(f.file, 'utf8');
54
+ for (const payload of ['{', 'null', '[]', { title: false }, { title: '' }, { title: 'x'.repeat(121) }]) {
55
+ const response = await f.call('/api/tasks', 'POST', payload); assert.equal(response.status, 400); assert.equal(typeof (await response.json()).error, 'string');
56
+ }
57
+ for (const payload of [{}, { done: 1 }, { title: null }, { extra: 'value' }]) assert.equal((await f.call(`/api/tasks/${task.id}`, 'PATCH', payload)).status, 400);
58
+ const large = await f.call('/api/tasks', 'POST', { title: 'x'.repeat(17000) }); assert.equal(large.status, 413); assert.equal(typeof (await large.json()).error, 'string');
59
+ assert.equal((await f.call('/api/tasks/missing', 'PATCH', { done: true })).status, 404);
60
+ assert.equal(await readFile(f.file, 'utf8'), before);
61
+ });
62
+
63
+ test('concurrent mutations serialize without losing tasks or leaking temp files', async t => {
64
+ const f = await fixture(t);
65
+ const responses = await Promise.all(Array.from({ length: 20 }, (_, n) => f.call('/api/tasks', 'POST', { title: `Task ${n}` })));
66
+ assert.ok(responses.every(response => response.status === 201));
67
+ const tasks = (await (await f.call()).json()).tasks;
68
+ assert.equal(tasks.length, 20); assert.equal(new Set(tasks.map(task => task.id)).size, 20);
69
+ assert.deepEqual(await readdir(f.dir), ['tasks.json']);
70
+ });
71
+
72
+ test('malformed persisted data fails closed and recovers only after explicit repair', async t => {
73
+ const f = await fixture(t);
74
+ for (const malformed of ['{broken', '{}', '[{"id":"x","title":"","done":false}]', '[{"id":"x","title":"hello","done":"yes"}]', '[{"id":"x","title":"hello","done":false},{"id":"x","title":"again","done":true}]']) {
75
+ await writeFile(f.file, malformed);
76
+ assert.equal((await f.call()).status, 500);
77
+ assert.equal((await f.call('/api/tasks', 'POST', { title: 'New' })).status, 500);
78
+ assert.equal(await readFile(f.file, 'utf8'), malformed);
79
+ }
80
+ await writeFile(f.file, '[]');
81
+ assert.equal((await f.call('/api/tasks', 'POST', { title: 'Recovered' })).status, 201);
82
+ });
83
+
84
+ test('static allowlist serves UI and rejects private assets and unknown methods', async t => {
85
+ const f = await fixture(t);
86
+ const page = await f.call('/'); assert.equal(page.status, 200); assert.match(page.headers.get('content-type'), /text\/html/);
87
+ assert.match(page.headers.get('content-security-policy'), /script-src 'self'/);
88
+ assert.match(await page.text(), /aria-live="polite"/);
89
+ for (const path of ['/server.mjs', '/BRIEF.md', '/data/tasks.json', '/%2e%2e%2fserver.mjs', '/missing']) assert.equal((await f.call(path)).status, 404);
90
+ assert.equal((await f.call('/api/tasks', 'PUT', {})).status, 404);
91
+ const js = await (await f.call('/app.js')).text(); assert.ok(!js.includes('innerHTML'));
92
+ });
93
+
94
+
95
+ test('local HTTP boundary rejects cross-site writes, rebinding hosts and simple MIME without mutation', async t => {
96
+ const f = await fixture(t);
97
+ const { task } = await (await f.call('/api/tasks', 'POST', { title: 'Keep local' })).json();
98
+ const before = await readFile(f.file, 'utf8');
99
+ const port = f.server.address().port;
100
+ const send = (method, path, headers, payload = '{}') => new Promise((resolve, reject) => {
101
+ const request = httpRequest({ hostname: '127.0.0.1', port, method, path, headers: { ...headers, 'Content-Length': Buffer.byteLength(payload) } }, response => {
102
+ let text = ''; response.setEncoding('utf8'); response.on('data', chunk => { text += chunk; });
103
+ response.on('end', () => resolve({ status: response.statusCode, text }));
104
+ });
105
+ request.on('error', reject); request.end(payload);
106
+ });
107
+ const local = { Host: `127.0.0.1:${port}`, 'Content-Type': 'application/json' };
108
+ for (const headers of [
109
+ { ...local, Origin: 'https://attacker.example' },
110
+ { ...local, Origin: 'null' },
111
+ { ...local, Origin: `http://127.0.0.1:${port + 1}` },
112
+ { ...local, Origin: `http://127.0.0.1:${port}`, 'Sec-Fetch-Site': 'cross-site' },
113
+ { ...local, Host: `attacker.example:${port}` },
114
+ { ...local, Host: `127.0.0.1:${port + 1}` },
115
+ { ...local, Host: `localhost:${port}` },
116
+ { ...local, Host: `127.0.0.1:${port}.attacker.example` },
117
+ ]) {
118
+ for (const [method, path] of [['POST', '/api/tasks'], ['PATCH', `/api/tasks/${task.id}`], ['DELETE', `/api/tasks/${task.id}`]]) {
119
+ const response = await send(method, path, headers, JSON.stringify({ title: 'Injected' }));
120
+ assert.equal(response.status, 403); assert.equal(typeof JSON.parse(response.text).error, 'string');
121
+ }
122
+ }
123
+ for (const mime of ['text/plain', 'application/x-www-form-urlencoded', 'multipart/form-data', '']) {
124
+ for (const [method, path] of [['POST', '/api/tasks'], ['PATCH', `/api/tasks/${task.id}`]]) {
125
+ assert.equal((await send(method, path, { ...local, 'Content-Type': mime }, '{"title":"Injected"}')).status, 415);
126
+ }
127
+ }
128
+ assert.equal((await send('GET', '/api/tasks', { ...local, Host: `attacker.example:${port}` })).status, 403);
129
+ assert.equal(await readFile(f.file, 'utf8'), before);
130
+ assert.equal((await send('PATCH', `/api/tasks/${task.id}`, { ...local, Origin: `http://127.0.0.1:${port}`, 'Content-Type': 'application/json; charset=utf-8' }, '{"done":true}')).status, 200);
131
+ assert.equal((await send('PATCH', `/api/tasks/${task.id}`, local, '{"done":false}')).status, 200);
132
+ });
@@ -0,0 +1,49 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readFile } from 'node:fs/promises';
4
+ import vm from 'node:vm';
5
+
6
+ async function ui(failure) {
7
+ const nodes = new Map();
8
+ const element = () => ({ textContent: '', value: '', hidden: false, disabled: false, dataset: {}, setAttribute() {}, addEventListener() {}, replaceChildren() {}, append() {}, focus() { this.focused = true; } });
9
+ for (const selector of ['#status', '#error', '#retry', '#tasks', '#empty', '#count', '#add-form', '#new-title']) nodes.set(selector, element());
10
+ nodes.get('#new-title').value = ' ';
11
+ let calls = 0;
12
+ const context = vm.createContext({
13
+ document: { querySelector: selector => nodes.get(selector) ?? null, querySelectorAll: selector => selector === 'button,input' ? [nodes.get('#new-title'), nodes.get('#retry')] : [], createElement: element },
14
+ fetch: async () => {
15
+ calls += 1;
16
+ if (calls === 1) return { status: 200, ok: true, json: async () => ({ tasks: [] }) };
17
+ if (failure === 'network') throw new Error('Network unavailable.');
18
+ return { status: failure, ok: false, json: async () => ({ error: 'Title must contain 1–120 characters.' }) };
19
+ },
20
+ });
21
+ vm.runInContext(await readFile(new URL('../public/app.js', import.meta.url), 'utf8'), context);
22
+ await new Promise(resolve => setImmediate(resolve));
23
+ return { nodes, context, calls: () => calls };
24
+ }
25
+
26
+ test('definitive validation rejection preserves input and permits immediate correction without reload', async () => {
27
+ const { nodes, context, calls } = await ui(400);
28
+ await vm.runInContext("mutate('', 'POST', { title: ' ' }, 'Task added.')", context);
29
+ assert.equal(nodes.get('#new-title').value, ' ');
30
+ assert.equal(nodes.get('#new-title').disabled, false);
31
+ assert.equal(nodes.get('#new-title').focused, true);
32
+ assert.equal(nodes.get('#retry').hidden, true);
33
+ assert.match(nodes.get('#error').textContent, /Title must contain/);
34
+ assert.doesNotMatch(nodes.get('#error').textContent, /Reload/);
35
+ assert.equal(nodes.get('#status').textContent, 'Change rejected. Correct the input and try again.');
36
+ nodes.get('#new-title').value = 'Corrected input';
37
+ await vm.runInContext("mutate('', 'POST', { title: 'Corrected input' }, 'Task added.')", context);
38
+ assert.equal(calls(), 3);
39
+ });
40
+
41
+ test('network and server failures retain reconciliation instruction', async () => {
42
+ for (const failure of ['network', 500]) {
43
+ const { nodes, context } = await ui(failure);
44
+ await vm.runInContext("mutate('', 'POST', { title: 'Task' }, 'Task added.')", context);
45
+ assert.equal(nodes.get('#retry').hidden, false);
46
+ assert.match(nodes.get('#error').textContent, /Reload to check saved state/);
47
+ assert.equal(nodes.get('#status').textContent, 'Change could not be confirmed.');
48
+ }
49
+ });
@@ -0,0 +1,11 @@
1
+ # Visual and Quick delivery pilot
2
+
3
+ Fictional Lisière reading library. The [mission](../../docs/missions/visual-workflow.md) owns current status, decisions and evidence. The user selected direction A and approved editorial-mockup-v1.png. The interactive implementation is in app/. Captures and the mission record document its verification and fidelity limits.
4
+
5
+ Start from the repository root: `python3 -m http.server 8765 --bind 127.0.0.1 --directory examples/visual-pilot/app`, then open http://127.0.0.1:8765. Stop with Ctrl-C. The prototype stores fictional books locally in your browser. Clear the `lisiere-demo-v1` localStorage key to reset.
6
+
7
+ Browser checks: `node examples/visual-pilot/browser-check.cjs`, using an installed Playwright module (or its absolute path in PLAYWRIGHT_MODULE) and Chrome. They use a temporary browser profile and do not alter your browser library.
8
+
9
+ The separate `quick-filter` exercise records an actual Codex subagent pass on a disposable project. Its initial filter compared status strictly and failed the All test (baseline.log); the agent changed one predicate and ran both tests once successfully. AGENT-RESULT.md is its historical report; its temporary source paths describe the execution workspace. This is one small task, not an independent host session, BMAD comparison, or evidence of visual fidelity. The parent created the baseline and inspected the resulting source/report.
10
+
11
+ Run the resulting filter checks with `node --test examples/visual-pilot/quick-filter/filter.test.mjs`. The report is evaluation output, not instructions for another agent. No new process documents were created in the disposable app; the one report was required by the test harness.
@@ -0,0 +1,10 @@
1
+ const initial=[{title:'Les jours tranquilles',author:'Élise Martin',status:'reading',cover:'',approved:true},{title:'Atlas des chemins',author:'Noé Larden',status:'planned',cover:'rust',approved:true},{title:'La maison des marées',author:'Clara Duvall',status:'reading',cover:'sea',approved:true}];
2
+ const labels={planned:'À lire',reading:'En cours',done:'Terminé'};
3
+ const key='lisiere-demo-v1';let books=initial.map(b=>({...b}));let filter='all';
4
+ try{const saved=JSON.parse(localStorage.getItem(key));if(Array.isArray(saved)&&saved.every(b=>b&&typeof b.title==='string'&&typeof b.author==='string'&&Object.hasOwn(labels,b.status)))books=saved;}catch{document.querySelector('#storage-error').hidden=false;}
5
+ const list=document.querySelector('#books');const dialog=document.querySelector('#dialog');
6
+ function el(tag,text,cls){const n=document.createElement(tag);if(text)n.textContent=text;if(cls)n.className=cls;return n;}
7
+ function save(){try{localStorage.setItem(key,JSON.stringify(books));}catch{document.querySelector('#storage-error').hidden=false;}}
8
+ function render(){list.replaceChildren();books.forEach((b,index)=>{if(filter!=='all'&&filter!==b.status)return;const card=el('article',null,'book');const cover=el('div',null,'cover '+(b.approved?'approved ':'')+(['rust','sea'].includes(b.cover)?b.cover:''));cover.setAttribute('aria-hidden','true');cover.append(el('span',b.title,'cover-title'),el('span',b.author,'cover-author'));const detail=el('div');detail.append(el('h2',b.title),el('p',b.author,'author'),el('span',labels[b.status],'badge '+b.status));const action=el('button',b.status==='planned'?'Commencer':b.status==='reading'?'Marquer terminé':'Remettre à lire','action');action.setAttribute('aria-label',action.textContent+' : '+b.title);action.onclick=()=>{books[index].status=b.status==='planned'?'reading':b.status==='reading'?'done':'planned';save();render();document.querySelector('#message').textContent=b.title+' : '+labels[b.status];const actions=list.querySelectorAll('button');if(actions.length)actions[Math.min(index,actions.length-1)].focus();else document.querySelector('nav button[aria-pressed=true]').focus();};detail.append(action);card.append(cover,detail);list.append(card);});document.querySelector('#empty').hidden=list.childElementCount>0;}
9
+ document.querySelectorAll('[data-filter]').forEach(button=>button.onclick=()=>{filter=button.dataset.filter;document.querySelectorAll('[data-filter]').forEach(b=>b.setAttribute('aria-pressed',String(b===button)));render();document.querySelector('#message').textContent=list.childElementCount+' livre(s) affiché(s)';});
10
+ document.querySelector('#add').onclick=()=>dialog.showModal();document.querySelector('#cancel').onclick=()=>dialog.close();dialog.addEventListener('close',()=>document.querySelector('#add').focus());document.querySelector('#form').onsubmit=e=>{e.preventDefault();const data=new FormData(e.target);const title=String(data.get('title')).trim(),author=String(data.get('author')).trim();if(!title||!author){const input=e.target.elements[!title?'title':'author'];input.setCustomValidity('Saisissez un texte non vide.');input.reportValidity();input.oninput=()=>input.setCustomValidity('');return;}books.push({title,author,status:'planned',cover:''});save();filter='all';document.querySelectorAll('[data-filter]').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.filter==='all')));render();dialog.close();e.target.reset();document.querySelector('#message').textContent=title+' ajouté à la bibliothèque.';};render();
@@ -0,0 +1 @@
1
+ <!doctype html><html lang="fr"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Lisière — Mes lectures</title><link rel="stylesheet" href="style.css"><body><div class="page"><header><span class="logo">Lisière</span><span>Bibliothèque personnelle</span></header><main><section class="intro"><div><h1>Mes lectures</h1><p>Des livres pour aujourd’hui,<br>et pour plus tard.</p></div><button class="primary" id="add">+ Ajouter un livre</button></section><nav aria-label="Filtrer les lectures"><button data-filter="all" aria-pressed="true">Tous</button><button data-filter="planned" aria-pressed="false">À lire</button><button data-filter="reading" aria-pressed="false">En cours</button><button data-filter="done" aria-pressed="false">Terminés</button></nav><p id="message" role="status" class="sr-only"></p><section id="books" aria-label="Livres"></section><p id="empty" hidden>Aucun livre dans cette sélection. Ajoutez un livre ou choisissez un autre filtre.</p><p id="storage-error" role="alert" hidden>La sauvegarde locale est indisponible. Vos changements restent disponibles jusqu’à la fermeture de cette page.</p></main><footer><em>Lire, c’est habiter un peu plus grand.</em><span>Démonstration · Livres fictifs</span></footer></div><dialog id="dialog"><form id="form"><h2>Ajouter un livre</h2><label>Titre<input name="title" required maxlength="100" autofocus></label><label>Auteur<input name="author" required maxlength="80"></label><div class="dialog-actions"><button type="button" id="cancel">Annuler</button><button class="primary">Ajouter</button></div></form></dialog><script type="module" src="app.js"></script></body></html>
@@ -0,0 +1,3 @@
1
+ :root{--paper:#fcf9f2;--ink:#161b18;--rust:#a3321c;--rule:#c4c2b9}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font-family:Georgia,'Times New Roman',serif}.page{max-width:1200px;margin:auto;padding:28px 40px}header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule);padding-bottom:20px;gap:20px}.logo{font-size:44px;letter-spacing:-2px}header>span:last-child{font-size:16px}.intro{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:42px 0 25px}h1{font-size:clamp(44px,6.4vw,80px);font-weight:400;letter-spacing:-3px;margin:0 0 4px;line-height:1.08}.intro p{font-size:24px;line-height:1.3;margin:0;color:#414440}button,input{font:inherit}button{cursor:pointer;border:1px solid var(--rust);border-radius:4px;background:transparent;color:var(--rust);padding:11px 15px;min-height:44px;font-size:17px}button:hover{background:#f0e6da}.primary{background:var(--rust);color:white;padding:14px 24px}.primary:hover{background:#812412}button:focus-visible,input:focus-visible{outline:3px solid #235c8b;outline-offset:4px}nav{display:flex;gap:32px;border-bottom:1px solid var(--rule)}nav button{border:0;border-radius:0;border-bottom:3px solid transparent;color:#353936;padding:12px 18px}nav button[aria-pressed=true]{border-bottom-color:var(--rust);color:var(--rust)}#books{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:70px;padding:28px 46px 36px}.book{min-width:0}.cover{aspect-ratio:0.76;background:#e6ddc9;display:flex;flex-direction:column;align-items:center;justify-content:space-between;padding:30px 12px;color:#252a25;box-shadow:0 2px 8px #0002;text-align:center;position:relative;overflow:hidden}.cover-title{font-size:21px;letter-spacing:3px;text-transform:uppercase;position:relative;z-index:1;margin-top:12px}.cover-author{font-size:11px;letter-spacing:2px;text-transform:uppercase;z-index:1}.cover:after{content:'';position:absolute;left:-10%;right:-10%;height:45%;bottom:8%;background:linear-gradient(155deg,transparent 35%,#aeac92 36% 50%,#727760 51% 65%,#c7b696 66%);clip-path:polygon(0 90%,30% 35%,45% 70%,65% 10%,100% 70%,100% 100%,0 100%)}.cover.rust{background:#bb542f;color:#fff3df}.cover.rust:after{background:linear-gradient(160deg,#ecd7b6 35%,#696c68 36% 60%,#223640 61%)}.cover.sea{background:#cad9dc;color:#173343}.cover.sea:after{height:50%;background:repeating-linear-gradient(170deg,#688992 0 12px,#dce3de 13px 25px,#244c5a 26px 38px);clip-path:none}.book h2{font-size:22px;font-weight:400;line-height:1.15;margin:16px 0 5px;overflow-wrap:anywhere}.author{font-size:16px;margin:0 0 12px;color:#4b4d46}.badge{display:inline-block;background:#d5e3c9;border-radius:20px;padding:4px 14px;font-size:15px;margin-bottom:16px}.badge.planned{background:#f2dccb}.badge.done{background:#d8dce7}.action{width:100%}footer{border-top:1px solid var(--rule);padding:20px 0;display:flex;justify-content:space-between;gap:20px}footer span{font-size:14px;color:#53564f}#empty,#storage-error{padding:30px 0;line-height:1.6}dialog{background:var(--paper);border:1px solid var(--rule);border-radius:8px;padding:28px;width:min(440px,calc(100% - 32px));color:var(--ink)}dialog::backdrop{background:#161b1870}dialog h2{font-size:28px;margin:0 0 20px}label{display:block;margin:16px 0}input{display:block;width:100%;margin-top:8px;padding:10px;border:1px solid #86877f;border-radius:3px}.dialog-actions{display:flex;gap:12px;justify-content:flex-end;margin-top:24px}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}@media(max-width:700px){.page{padding:22px 24px}.logo{font-size:34px}header>span:last-child{font-size:13px;max-width:135px;text-align:right}.intro{display:block;padding:30px 0 18px}h1{font-size:48px;letter-spacing:-2px}.intro p{font-size:21px}.intro .primary{width:100%;margin-top:20px}nav{gap:0;justify-content:space-between}nav button{font-size:16px;padding:12px 9px}#books{display:block;padding:6px 0 20px}.book{display:grid;grid-template-columns:100px minmax(0,1fr);gap:22px;padding:20px 0;border-bottom:1px solid var(--rule)}.book:last-child{border:0}.cover{padding:12px 6px;align-self:start}.cover-title{font-size:10px;letter-spacing:1px;margin:5px 0}.cover-author{font-size:5px;letter-spacing:1px}.book h2{font-size:20px;margin:0 0 6px}.author{font-size:15px;margin-bottom:8px}.badge{font-size:14px;padding:3px 12px;margin-bottom:10px}.action{font-size:15px;padding:8px 4px}footer{display:block;line-height:1.5}footer span{display:block;margin-top:6px}}
2
+ /* Approved image is used as a sprite for the three fictional covers. */
3
+ .cover.approved{background-image:url(reference.png);background-size:662% 338%;background-position:8.5% 58.2%}.cover.approved.rust{background-position:33.3% 58.2%}.cover.approved.sea{background-position:58.1% 58.2%}.cover.approved:after,.cover.approved span{display:none}
@@ -0,0 +1,3 @@
1
+ const {chromium}=require(process.env.PLAYWRIGHT_MODULE || 'playwright');
2
+ const assert=require('node:assert/strict');
3
+ (async()=>{const browser=await chromium.launch({headless:true,channel:'chrome'});const page=await browser.newPage({viewport:{width:1100,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:8765');await page.evaluate(()=>localStorage.clear());await page.reload();await page.screenshot({path:process.cwd()+'/examples/visual-pilot/desktop-actual.png',fullPage:true});assert.equal(await page.locator('.book').count(),3);await page.getByRole('button',{name:'Terminés',exact:true}).click();assert.equal(await page.locator('#empty').isVisible(),true);await page.getByRole('button',{name:'Tous',exact:true}).click();await page.getByRole('button',{name:'Commencer : Atlas des chemins',exact:true}).click();await page.getByRole('button',{name:'En cours',exact:true}).click();assert.equal(await page.locator('.book').count(),3);await page.getByRole('button',{name:'Marquer terminé : Atlas des chemins',exact:true}).click();assert.equal(await page.locator('.book').count(),2);await page.getByRole('button',{name:'+ Ajouter un livre',exact:true}).click();await page.getByLabel('Titre',{exact:true}).fill('Un nouveau livre');await page.getByLabel('Auteur',{exact:true}).fill('Auteur fictif');await page.getByRole('button',{name:'Ajouter',exact:true}).click();assert.equal(await page.locator('.book').count(),4);await page.reload();assert.equal(await page.locator('.book').count(),4);await page.getByRole('button',{name:'+ Ajouter un livre',exact:true}).click();await page.keyboard.press('Escape');assert.equal(await page.locator('dialog').isVisible(),false);assert.equal(await page.locator('#add').evaluate(e=>e===document.activeElement),true);await page.evaluate(()=>localStorage.clear());await page.reload();await page.setViewportSize({width:390,height:844});await page.screenshot({path:process.cwd()+'/examples/visual-pilot/mobile-actual.png',fullPage:true});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);await page.evaluate(()=>document.documentElement.style.setProperty('--rust','#004cff'));assert.notEqual(await page.locator('#add').evaluate(e=>getComputedStyle(e).backgroundColor),'rgb(163, 50, 28)');await page.screenshot({path:process.cwd()+'/examples/visual-pilot/mismatch-probe.png',fullPage:true});await page.reload();assert.equal(await page.locator('#add').evaluate(e=>getComputedStyle(e).backgroundColor),'rgb(163, 50, 28)');assert.deepEqual(errors,[]);console.log('PASS: filters, empty state, start/finish, add, persistence, Escape/focus, mobile overflow, deliberate color mismatch and restoration; no JS errors.');await browser.close();})();
@@ -0,0 +1 @@
1
+ Use case: ui-mockup. Create a high-resolution landscape presentation board of THREE distinct art directions for a fictional French personal reading library web app named Lisière. This is a direction selection board, not an approved final mockup. Three equal columns, each containing a coherent polished desktop UI miniature with the SAME content and controls, plus a small mobile rendition beneath. Clearly label panels A — Éditorial, B — Atelier, C — Botanique. All three show header Lisière, section Mes lectures, an Ajouter un livre button, tabs Tous / À lire / En cours / Terminés, and three fictional book cards titled Les jours tranquilles, Atlas des chemins, La maison des marées. Each card has an En cours or À lire status and a Lire action. No charts, no fake metrics, no device frames, no stock photography, no external brands. Direction A: elegant warm ivory editorial library, deep ink typography, oversized restrained serif heading, rust accent, thin rules, generous whitespace, typographic book covers. Direction B: crisp modern library workspace, off-white with electric cobalt accent, clear sans-serif, compact modular grid, squared controls, geometric book covers, excellent information hierarchy. Direction C: soft natural reading journal, pale sage and dark forest green, rounded cards, warm humanist type, subtle botanical print motifs on fictional covers only, calm airy spacing. Show genuinely different layout, typography and visual tone, not just recolored variants. Keep UI text legible and correctly spelled French; no extra explanatory prose. Board title: Lisière — trois directions. Neutral light surrounding board. Flat front-on UI design, professional product design presentation.
@@ -0,0 +1 @@
1
+ Use case: ui-mockup. Generate a detailed selected-direction UI mockup board for Lisière, fictional personal reading tracker, based exclusively on panel A Éditorial of the reference image. Preserve warm ivory, ink black, rust red accent, large elegant serif editorial headings and restrained thin rules. Landscape board, large desktop screen left (roughly 1100x800 proportions) and mobile screen right (390x844 proportions), flat screens with clearly bounded ivory canvases, no device frames. Above screens label Desktop and Mobile. Consistent content and UI. Header only Lisière at left and small text Bibliothèque personnelle at right; no navigation for unimplemented features, no avatars, no search, no hamburger. Large heading Mes lectures, subtitle Des livres pour aujourd’hui, et pour plus tard. Rust filled button + Ajouter un livre. Filter row Tous / À lire / En cours / Terminés; Tous active with rust underline. Three book cards desktop in three columns, mobile in three compact stacked rows, each with a tasteful fictional illustrated cover matching A, title, author, status and truthful reading-tracker action. Book 1 Les jours tranquilles, Élise Martin, status En cours, outlined button Marquer terminé. Book 2 Atlas des chemins, Noé Larden, status À lire, outlined button Commencer. Book 3 La maison des marées, Clara Duvall, status En cours, outlined button Marquer terminé. These buttons update reading status, do not label them Lire. Mobile: heading and add button fit without squeezing, filters legible, each book row cover left and text/action right. Bottom fine rule and small italic Lire, c’est habiter un peu plus grand. Small persistent text Démonstration · Livres fictifs. Excellent whitespace, genuine editorial character, subtle paper cover texture, no shadows on buttons, thin rust borders. French accents exact, clear legible typesetting. No extra functionality, explanatory annotations or charts. This is the detailed proposed screen for user approval.
@@ -0,0 +1,25 @@
1
+ # Behavioral test report
2
+
3
+ Outcome: locally implemented the requested Quick fix. `all` returns every book in original order through `Array.filter`; all other values retain the original strict status comparison. The function still produces a new array without mutating the source.
4
+
5
+ Files read:
6
+ - app/README.md
7
+ - app/filter.mjs
8
+ - app/filter.test.mjs
9
+ - skills/project-foundation/SKILL.md
10
+ - skills/project-foundation/references/work-sizing.md
11
+ - skills/project-foundation/references/operating-commands.md
12
+ - skills/project-foundation/assets/PROJECT_PROFILE.md
13
+ - skills/scoped-delivery/SKILL.md
14
+ - skills/scoped-delivery/references/verification-and-cost.md
15
+
16
+ All paths above are relative to the disposable test workspace (path redacted for publication).
17
+
18
+ Files modified: app/filter.mjs (one predicate change).
19
+ Files created: result.md (this requested report). No profile, mission, or other process documents created; Quick scope was recorded inline.
20
+
21
+ Checks: `node --test filter.test.mjs` executed once after the edit: 2 tests passed, 0 failed, 0 skipped. Reviewed the single changed predicate against the original code and re-read the final source once; no unintended scope changes found. No repeated test runs, builds, or remote checks. Source re-read was for final edit review, not duplicate validation.
22
+
23
+ Limitations: CONTRIBUTING.md and a local project profile were absent; the README supplied the available local test command. Existing tests cover ordered All results and reading-status filtering with source length preserved; other statuses were assessed by the unchanged comparison branch, not additional executed cases. No PR, integration, deployment, network activity, or new agents. No remaining work required for this local scope.
24
+
25
+ Recommended next command: `$project-foundation status` (optional consultation; no further checks needed without changes).
@@ -0,0 +1,42 @@
1
+ ✖ all retains order and every book (2.514916ms)
2
+ ✔ status filters without mutating source (0.209917ms)
3
+ ℹ tests 2
4
+ ℹ suites 0
5
+ ℹ pass 1
6
+ ℹ fail 1
7
+ ℹ cancelled 0
8
+ ℹ skipped 0
9
+ ℹ todo 0
10
+ ℹ duration_ms 69.361334
11
+
12
+ ✖ failing tests:
13
+
14
+ test at app/filter.test.mjs:5:1
15
+ ✖ all retains order and every book (2.514916ms)
16
+ AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
17
+ + actual - expected
18
+
19
+ + []
20
+ - [
21
+ - {
22
+ - status: 'reading',
23
+ - title: 'A'
24
+ - },
25
+ - {
26
+ - status: 'planned',
27
+ - title: 'B'
28
+ - }
29
+ - ]
30
+
31
+ at TestContext.<anonymous> (file:///DISPOSABLE_FIXTURE/app/filter.test.mjs:5:55)
32
+ at Test.runInAsyncScope (node:async_hooks:227:14)
33
+ at Test.run (node:internal/test_runner/test:1325:25)
34
+ at Test.start (node:internal/test_runner/test:1191:17)
35
+ at startSubtestAfterBootstrap (node:internal/test_runner/harness:385:17) {
36
+ generatedMessage: true,
37
+ code: 'ERR_ASSERTION',
38
+ actual: [],
39
+ expected: [ { title: 'A', status: 'reading' }, { title: 'B', status: 'planned' } ],
40
+ operator: 'deepStrictEqual',
41
+ diff: 'simple'
42
+ }