devmethod-ai 0.1.0-rc.2 → 0.1.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.
- package/.agents/skills/decision-architecture/SKILL.md +15 -13
- package/.agents/skills/decision-architecture/assets/ADR.md +18 -17
- package/.agents/skills/decision-architecture/references/api-contracts.md +21 -16
- package/.agents/skills/decision-architecture/references/backend-boundaries.md +21 -18
- package/.agents/skills/decision-architecture/references/product-decisions.md +14 -17
- package/.agents/skills/design-to-code/SKILL.md +11 -10
- package/.agents/skills/design-to-code/assets/UI_ACCEPTANCE.md +13 -12
- package/.agents/skills/design-to-code/references/ux-contract.md +20 -15
- package/.agents/skills/project-foundation/SKILL.md +39 -30
- package/.agents/skills/project-foundation/assets/AGENTS.foundation.md +16 -15
- package/.agents/skills/project-foundation/assets/PROJECT_PROFILE.md +23 -23
- package/.agents/skills/project-foundation/assets/START_HERE.md +17 -14
- package/.agents/skills/project-foundation/references/mission-context.md +20 -0
- package/.agents/skills/project-foundation/references/operating-commands.md +37 -34
- package/.agents/skills/project-foundation/references/profiles/README.md +18 -0
- package/.agents/skills/project-foundation/references/profiles/ci.md +11 -0
- package/.agents/skills/project-foundation/references/profiles/cloud-delivery.md +13 -0
- package/.agents/skills/project-foundation/references/profiles/messaging.md +11 -0
- package/.agents/skills/project-foundation/references/profiles/mongodb.md +11 -0
- package/.agents/skills/project-foundation/references/profiles/node-nest.md +11 -0
- package/.agents/skills/project-foundation/references/profiles/postgres-drizzle.md +11 -0
- package/.agents/skills/project-foundation/references/profiles/react-next.md +11 -0
- package/.agents/skills/project-foundation/references/profiles/typescript.md +11 -0
- package/.agents/skills/project-foundation/references/work-sizing.md +31 -0
- package/.agents/skills/react-feature-engineering/SKILL.md +26 -22
- package/.agents/skills/react-feature-engineering/references/review-and-sources.md +41 -37
- package/.agents/skills/reliable-ai-integration/SKILL.md +13 -10
- package/.agents/skills/reliable-ai-integration/assets/AI_EVALUATION.md +19 -18
- package/.agents/skills/reliable-ai-integration/references/evidence-and-media.md +22 -18
- package/.agents/skills/reliable-ai-integration/references/jobs-and-costs.md +20 -15
- package/.agents/skills/scoped-delivery/SKILL.md +23 -18
- package/.agents/skills/scoped-delivery/assets/CHECKPOINT.md +22 -11
- package/.agents/skills/scoped-delivery/assets/MISSION.md +18 -0
- package/.agents/skills/scoped-delivery/assets/SLICE.md +15 -14
- package/.agents/skills/scoped-delivery/assets/VERIFICATION.md +19 -0
- package/.agents/skills/scoped-delivery/references/verification-and-cost.md +21 -18
- package/COMPATIBILITY.md +3 -3
- package/CONTRIBUTING.md +13 -0
- package/README.md +74 -8
- package/START_HERE.md +24 -0
- package/VALIDATION.md +37 -0
- package/dist/checkpoint.js +171 -0
- package/dist/cli.js +118 -17
- package/dist/doctor.js +113 -0
- package/dist/filesystem.js +32 -0
- package/dist/init.js +38 -34
- package/dist/mission.js +85 -0
- package/dist/planner.js +65 -0
- package/dist/records.js +76 -0
- package/dist/update.js +57 -0
- package/docs/ADR-001-distribution.md +13 -0
- package/docs/ADR-002-read-only-diagnostics.md +25 -0
- package/docs/ADR-003-installation-provenance.md +15 -0
- package/docs/ADR-004-checkpoint-resumption.md +23 -0
- package/docs/ADR-005-mission-context.md +11 -0
- package/docs/ADR-006-opt-in-native-pilot.md +15 -0
- package/docs/BMAD-COMPARISON.md +34 -0
- package/docs/CODEX-ADAPTER-VALIDATION.md +20 -0
- package/docs/COMPARISON-ITERATION.md +25 -0
- package/docs/DOCTOR.md +23 -0
- package/docs/EVALUATION.md +39 -0
- package/docs/EVOLUTION.md +34 -0
- package/docs/GREENFIELD-VALIDATION.md +36 -0
- package/docs/MILESTONES.md +39 -0
- package/docs/MISSIONS.md +45 -0
- package/docs/NATIVE-PILOT-RESULTS.md +22 -0
- package/docs/NATIVE-PILOT.md +17 -0
- package/docs/ORCHESTRATION.md +11 -0
- package/docs/RC2-VALIDATION.md +50 -0
- package/docs/RELEASE-0.1.0.md +49 -0
- package/docs/RELEASE-CHECKLIST.md +13 -0
- package/docs/RELEASE-NOTES-0.1.0-rc.2.md +32 -0
- package/docs/RELEASE-NOTES-0.1.0.md +11 -0
- package/docs/RESUMPTION.md +87 -0
- package/docs/ROADMAP.md +37 -0
- package/docs/STACK-PROFILES.md +9 -0
- package/docs/TROUBLESHOOTING.md +16 -0
- package/docs/UPDATES.md +30 -0
- package/docs/images/devmethod-flow.svg +26 -0
- package/evaluation/COMPARISONS.md +33 -0
- package/evaluation/README.md +35 -0
- package/evaluation/candidate-setup.json +33 -0
- package/evaluation/comparison-v2-readiness.json +18 -0
- package/evaluation/comparison-v2-results.json +58 -0
- package/evaluation/fixtures/dependency/CONTRIBUTING.md +3 -0
- package/evaluation/fixtures/dependency/TICKET.md +3 -0
- package/evaluation/fixtures/dependency/acceptance.test.mjs +4 -0
- package/evaluation/fixtures/dependency/api.mjs +1 -0
- package/evaluation/fixtures/failing-verification/CONTRIBUTING.md +3 -0
- package/evaluation/fixtures/failing-verification/acceptance.test.mjs +4 -0
- package/evaluation/fixtures/failing-verification/total.mjs +1 -0
- package/evaluation/fixtures/stale-handoff/CHECKPOINT.md +3 -0
- package/evaluation/fixtures/stale-handoff/CONTRACT.md +3 -0
- package/evaluation/fixtures/stale-handoff/CONTRIBUTING.md +3 -0
- package/evaluation/fixtures/stale-handoff/acceptance.test.mjs +4 -0
- package/evaluation/fixtures/stale-handoff/label.mjs +1 -0
- package/evaluation/fixtures/update-customization/README.md +13 -0
- package/evaluation/fixtures.json +85 -0
- package/evaluation/greenfield/acceptance.test.mjs +57 -0
- package/evaluation/greenfield/security.test.mjs +28 -0
- package/evaluation/native-pilot-results.json +155 -0
- package/examples/README.md +36 -0
- package/examples/bugfix/acceptance.test.mjs +17 -0
- package/examples/bugfix/page-size.mjs +4 -0
- package/examples/fullstack/CONTRACT.md +23 -0
- package/examples/fullstack/DEPENDENCIES.md +18 -0
- package/examples/fullstack/README.md +100 -0
- package/examples/fullstack/api/application/tasks.ts +13 -0
- package/examples/fullstack/api/domain/task.ts +12 -0
- package/examples/fullstack/api/http.ts +26 -0
- package/examples/fullstack/api/infrastructure/migrate.ts +15 -0
- package/examples/fullstack/api/infrastructure/postgres.ts +18 -0
- package/examples/fullstack/api/infrastructure/schema.ts +6 -0
- package/examples/fullstack/api/main.ts +17 -0
- package/examples/fullstack/api/tsconfig.json +17 -0
- package/examples/fullstack/compose.yaml +14 -0
- package/examples/fullstack/migrations/0000_tasks.sql +5 -0
- package/examples/fullstack/migrations/meta/_journal.json +13 -0
- package/examples/fullstack/mission.json +34 -0
- package/examples/fullstack/package-lock.json +2661 -0
- package/examples/fullstack/package.json +45 -0
- package/examples/fullstack/tests/e2e.test.cjs +54 -0
- package/examples/fullstack/tests/http.test.cjs +33 -0
- package/examples/fullstack/tests/postgres.test.cjs +29 -0
- package/examples/fullstack/tests/unit.test.cjs +20 -0
- package/examples/fullstack/tests/web-model.test.cjs +23 -0
- package/examples/fullstack/web/app/layout.tsx +4 -0
- package/examples/fullstack/web/app/page.tsx +11 -0
- package/examples/fullstack/web/features/tasks/components/task-list.tsx +11 -0
- package/examples/fullstack/web/features/tasks/hooks/use-task-filter.ts +7 -0
- package/examples/fullstack/web/features/tasks/model/tasks.ts +12 -0
- package/examples/fullstack/web/features/tasks/server/list-tasks.ts +8 -0
- package/examples/fullstack/web/next.config.mjs +2 -0
- package/examples/fullstack/web/tsconfig.json +36 -0
- package/examples/mission/CONTRACT.md +3 -0
- package/examples/mission/mission.json +18 -0
- package/examples/mission/plan.json +11 -0
- package/examples/pocket-tasks/BRIEF.md +21 -0
- package/examples/pocket-tasks/PROJECT_PROFILE.md +19 -0
- package/examples/pocket-tasks/README.md +43 -0
- package/examples/pocket-tasks/docs/ADR-001.md +7 -0
- package/examples/pocket-tasks/docs/HANDOFF.md +16 -0
- package/examples/pocket-tasks/docs/MISSION.md +15 -0
- package/examples/pocket-tasks/docs/VERIFICATION.md +58 -0
- package/examples/pocket-tasks/package.json +1 -0
- package/examples/pocket-tasks/public/app.js +84 -0
- package/examples/pocket-tasks/public/index.html +12 -0
- package/examples/pocket-tasks/public/styles.css +1 -0
- package/examples/pocket-tasks/server.mjs +79 -0
- package/examples/pocket-tasks/src/domain.mjs +31 -0
- package/examples/pocket-tasks/src/store.mjs +49 -0
- package/examples/pocket-tasks/tests/app.test.mjs +132 -0
- package/examples/pocket-tasks/tests/ui-errors.test.mjs +49 -0
- package/package.json +15 -3
- package/scripts/check-codex-git.mjs +20 -0
- package/scripts/check-docs.mjs +14 -0
- package/scripts/check-native-sandbox.mjs +11 -0
- package/scripts/comparison-readiness.mjs +26 -0
- package/scripts/comparison.mjs +42 -0
- package/scripts/evaluation.mjs +64 -0
- package/scripts/fullstack-mission.mjs +30 -0
- package/scripts/hosts/checks.mjs +13 -0
- package/scripts/hosts/codex-meter.mjs +17 -0
- package/scripts/hosts/codex-rpc.mjs +44 -0
- package/scripts/hosts/codex-task.mjs +29 -0
- package/scripts/hosts/codex.mjs +28 -0
- package/scripts/native-host.mjs +55 -0
- package/scripts/package-smoke.mjs +64 -0
- package/scripts/probe-codex-children.mjs +41 -0
- package/scripts/run-comparison-v2.mjs +45 -0
- package/scripts/run-native-pilot.mjs +76 -0
- 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
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "devmethod-ai",
|
|
3
|
-
"version": "0.1.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "From idea to delivery with your AI coding agents",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -13,8 +13,15 @@
|
|
|
13
13
|
"files": [
|
|
14
14
|
"dist/",
|
|
15
15
|
".agents/skills/",
|
|
16
|
+
"docs/",
|
|
17
|
+
"examples/",
|
|
18
|
+
"evaluation/",
|
|
19
|
+
"scripts/",
|
|
16
20
|
"LICENSE",
|
|
17
|
-
"COMPATIBILITY.md"
|
|
21
|
+
"COMPATIBILITY.md",
|
|
22
|
+
"CONTRIBUTING.md",
|
|
23
|
+
"VALIDATION.md",
|
|
24
|
+
"START_HERE.md"
|
|
18
25
|
],
|
|
19
26
|
"repository": {
|
|
20
27
|
"type": "git",
|
|
@@ -22,7 +29,12 @@
|
|
|
22
29
|
},
|
|
23
30
|
"scripts": {
|
|
24
31
|
"build": "tsc",
|
|
25
|
-
"test": "npm run build && node --test tests/*.test.mjs"
|
|
32
|
+
"test": "npm run build && node --test tests/*.test.mjs",
|
|
33
|
+
"test:fixtures": "node --test tests/evaluation.test.mjs",
|
|
34
|
+
"check:docs": "node scripts/check-docs.mjs",
|
|
35
|
+
"test:cli": "node --test tests/install.test.mjs tests/doctor.test.mjs tests/update.test.mjs tests/checkpoint.test.mjs tests/resume-cli.test.mjs tests/mission.test.mjs tests/planner.test.mjs",
|
|
36
|
+
"test:protocols": "node --test tests/comparison.test.mjs",
|
|
37
|
+
"test:greenfield": "node --test examples/pocket-tasks/tests/*.test.mjs evaluation/greenfield/*.test.mjs"
|
|
26
38
|
},
|
|
27
39
|
"devDependencies": {
|
|
28
40
|
"@types/node": "^22.0.0",
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Native sandbox preflight, no model invocation. Run explicitly on macOS.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { startCodexRpc } from './hosts/codex-rpc.mjs';
|
|
5
|
+
export async function checkCodexGit() {
|
|
6
|
+
if(process.platform!=='darwin')throw new Error('macOS preflight only');
|
|
7
|
+
const cwd=fs.mkdtempSync('/private/tmp/devmethod-git-sandbox-');
|
|
8
|
+
execFileSync('git',['init',cwd]);fs.writeFileSync(cwd+'/sentinel.txt','fictional\n');
|
|
9
|
+
const rpc=startCodexRpc({cwd,timeoutMs:20000});const results=[];
|
|
10
|
+
try {
|
|
11
|
+
await rpc.call('initialize',{clientInfo:{name:'devmethod_probe',version:'0.1.0'},capabilities:{experimentalApi:true}});rpc.notify({method:'initialized'});
|
|
12
|
+
for(const includeGit of [false,true]) {
|
|
13
|
+
const result=await rpc.call('command/exec',{command:['git','add','sentinel.txt'],cwd,timeoutMs:5000,sandboxPolicy:{type:'workspaceWrite',writableRoots:includeGit?[cwd,cwd+'/.git']:[cwd],networkAccess:false,excludeTmpdirEnvVar:true,excludeSlashTmp:true}});
|
|
14
|
+
results.push({includeGit,exitCode:result.exitCode});
|
|
15
|
+
}
|
|
16
|
+
if(results[0].exitCode===0||results[1].exitCode!==0)throw new Error('Unexpected Git sandbox result');
|
|
17
|
+
return {gitMetadataWritable:true,results};
|
|
18
|
+
}finally{rpc.close();fs.rmSync(cwd,{recursive:true,force:true});}
|
|
19
|
+
}
|
|
20
|
+
if(process.argv[1]?.endsWith('/check-codex-git.mjs')) console.log(JSON.stringify(await checkCodexGit()));
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const skip = new Set(['.git', 'node_modules', '.next', 'dist', 'evaluation-private']);
|
|
4
|
+
function files(root) { return fs.readdirSync(root, { withFileTypes: true }).flatMap(e => skip.has(e.name) || e.isSymbolicLink() ? [] : e.isDirectory() ? files(path.join(root, e.name)) : e.name.endsWith('.md') ? [path.join(root, e.name)] : []); }
|
|
5
|
+
const failures = [];
|
|
6
|
+
for (const file of files('.')) {
|
|
7
|
+
for (const [, target] of fs.readFileSync(file, 'utf8').matchAll(/\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) {
|
|
8
|
+
if (/^[a-z]+:|^#|^<|\$|\{/.test(target)) continue;
|
|
9
|
+
const relative = decodeURIComponent(target.split('#')[0]);
|
|
10
|
+
if (relative && !fs.existsSync(path.resolve(path.dirname(file), relative))) failures.push(`${file}: ${target}`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
if (failures.length) { console.error(failures.join('\n')); process.exitCode = 1; }
|
|
14
|
+
else console.log('Local Markdown file links resolve (external URLs and anchors are not checked).');
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { verifyFixture } from './hosts/checks.mjs';
|
|
6
|
+
const root=fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()),'devmethod-sandbox-probe-'));
|
|
7
|
+
const worker=path.join(root,'worker');fs.mkdirSync(worker);const sentinel=path.join(root,'sentinel');fs.writeFileSync(sentinel,'fictional sentinel');
|
|
8
|
+
try {
|
|
9
|
+
const code=`const fs=require('node:fs'),net=require('node:net'),assert=require('node:assert/strict');assert.throws(()=>fs.readFileSync(${JSON.stringify(sentinel)}));assert.throws(()=>fs.writeFileSync('forbidden.txt','x'));const socket=net.connect({host:'127.0.0.1',port:9});socket.on('error',e=>{assert.equal(e.code,'EPERM');console.log('read/write/network denied');});`;
|
|
10
|
+
const result=verifyFixture(worker,['-e',code]); assert.equal(result.status,0,result.stdout+result.stderr);console.log(result.stdout.trim());
|
|
11
|
+
}finally{fs.rmSync(root,{recursive:true,force:true});}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
/** Admission requirements for an unattended BMAD/DevMethod comparison.
|
|
6
|
+
* Evidence is operator supplied; this check does not certify a host.
|
|
7
|
+
*/
|
|
8
|
+
export function comparisonReadiness(evidence) {
|
|
9
|
+
const required = [
|
|
10
|
+
'bmadInitializationPassed', 'taskLocalTemporaryDirectory',
|
|
11
|
+
'methodArtifactsAllowed', 'gitMetadataWritable',
|
|
12
|
+
'synchronousSubagentsAvailable', 'descendantUsageMeasured',
|
|
13
|
+
'descendantCancellationVerified', 'equalHostPermissions',
|
|
14
|
+
'frozenMethodRevisions', 'independentAcceptanceChecks',
|
|
15
|
+
];
|
|
16
|
+
const missing = required.filter(key => evidence[key] !== true);
|
|
17
|
+
return { ready: missing.length === 0, missing,
|
|
18
|
+
limitation: 'Requires reviewed native evidence. A true label alone proves no capability.' };
|
|
19
|
+
}
|
|
20
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
21
|
+
const file = process.argv[2];
|
|
22
|
+
if (!file) throw new Error('Usage: node scripts/comparison-readiness.mjs EVIDENCE_JSON');
|
|
23
|
+
const result = comparisonReadiness(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
24
|
+
console.log(JSON.stringify(result, null, 2));
|
|
25
|
+
if (!result.ready) process.exitCode = 2;
|
|
26
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
const arms = ['none', 'devmethod', 'bmad'];
|
|
5
|
+
const nonempty = value => typeof value === 'string' && value.trim().length > 0;
|
|
6
|
+
const finite = value => typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
|
7
|
+
/** Validate a complete matched batch, including blocked/failed runs. Never dispatch models. */
|
|
8
|
+
export function summarize(batch) {
|
|
9
|
+
if (!batch || batch.format !== 1 || !Array.isArray(batch.runs) || !batch.runs.length) throw new Error('Expected format 1 nonempty batch');
|
|
10
|
+
const { budget } = batch;
|
|
11
|
+
if (!budget || !Number.isSafeInteger(budget.runs) || budget.runs < 1 || !Number.isSafeInteger(budget.tokens) || budget.tokens < 1 || !finite(budget.costUSD) || !nonempty(budget.authorization)) throw new Error('Explicit run/token/cost budget and authorization required');
|
|
12
|
+
if (batch.runs.length > budget.runs) throw new Error('Run budget exceeded');
|
|
13
|
+
const groups = new Map();
|
|
14
|
+
const matchedKeys = ['fixtureDigest', 'promptDigest', 'hostVersion', 'model', 'toolsDigest', 'permissionsDigest', 'perRunTokens', 'perRunCostUSD', 'timeoutSeconds'];
|
|
15
|
+
const digests = ['fixtureDigest', 'promptDigest', 'toolsDigest', 'permissionsDigest'];
|
|
16
|
+
for (const run of batch.runs) {
|
|
17
|
+
if (!run || !nonempty(run.caseId) || !Number.isSafeInteger(run.repetition) || run.repetition < 1 || !arms.includes(run.arm) || !['passed', 'failed', 'blocked', 'timeout'].includes(run.status)) throw new Error('Invalid run identity or status');
|
|
18
|
+
if (!matchedKeys.every(key => ['perRunTokens', 'perRunCostUSD', 'timeoutSeconds'].includes(key) ? finite(run[key]) && (key === 'perRunCostUSD' || run[key] > 0) : nonempty(run[key])) || !digests.every(key => /^[a-f0-9]{64}$/.test(run[key]))) throw new Error('Missing pinned matched conditions');
|
|
19
|
+
if (!nonempty(run.methodRevision) || !nonempty(run.wrapper) || (run.arm === 'none' && run.methodRevision !== 'none')) throw new Error('Pin method revision and invocation wrapper');
|
|
20
|
+
if (!nonempty(run.evidence) || !nonempty(run.review)) throw new Error('Evidence reference and review or blocked reason required');
|
|
21
|
+
for (const metric of ['tokens', 'costUSD', 'elapsedSeconds']) if (run[metric] !== null && !finite(run[metric])) throw new Error('Usage must be measured nonnegative values or null (unavailable)');
|
|
22
|
+
if ((run.tokens !== null && run.tokens > run.perRunTokens) || (run.costUSD !== null && run.costUSD > run.perRunCostUSD)) throw new Error('Per-run budget exceeded');
|
|
23
|
+
const key = `${run.caseId}:${run.repetition}`;
|
|
24
|
+
const group = groups.get(key) ?? [];
|
|
25
|
+
if (group.some(other => other.arm === run.arm)) throw new Error('Duplicate arm');
|
|
26
|
+
if (group.length && matchedKeys.some(key => run[key] !== group[0][key])) throw new Error('Unmatched conditions');
|
|
27
|
+
group.push(run); groups.set(key, group);
|
|
28
|
+
}
|
|
29
|
+
for (const group of groups.values()) if (group.length !== 3) throw new Error('Each case/repetition requires all three arms, including blocked records');
|
|
30
|
+
for (const metric of ['tokens', 'costUSD']) if (batch.runs.reduce((sum, run) => sum + (run[metric] ?? 0), 0) > budget[metric]) throw new Error('Total budget exceeded');
|
|
31
|
+
const cells = [];
|
|
32
|
+
for (const caseId of new Set(batch.runs.map(run => run.caseId))) for (const arm of arms) {
|
|
33
|
+
const runs = batch.runs.filter(run => run.caseId === caseId && run.arm === arm);
|
|
34
|
+
cells.push({ caseId, arm, denominator: runs.length, ...Object.fromEntries(['passed', 'failed', 'blocked', 'timeout'].map(status => [status, runs.filter(run => run.status === status).length])) });
|
|
35
|
+
}
|
|
36
|
+
return { format: 1, cells, usageComplete: batch.runs.every(run => run.tokens !== null && run.costUSD !== null),
|
|
37
|
+
limitation: 'Validates record consistency only. Evidence authenticity and behavioral claims require independent inspection; no aggregate superiority score.' };
|
|
38
|
+
}
|
|
39
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
40
|
+
try { if (process.argv.length !== 3) throw new Error('Usage: node scripts/comparison.mjs BATCH_JSON'); console.log(JSON.stringify(summarize(JSON.parse(fs.readFileSync(process.argv[2]))), null, 2)); }
|
|
41
|
+
catch (error) { console.error(error.message); process.exitCode = 2; }
|
|
42
|
+
}
|