atris 3.78.0 → 3.80.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/commands/task.js CHANGED
@@ -63,8 +63,12 @@ const {
63
63
  buildInspectPayload,
64
64
  } = require('../lib/inspect-fields');
65
65
  const {
66
+ taskTagTokens,
67
+ isDecisionHoldTag,
66
68
  isDecisionTask,
67
69
  decisionMarkerFor,
70
+ summarizeSkippedDecisions,
71
+ formatDecisionRefusalLines,
68
72
  DECISION_REFUSE_REASON,
69
73
  } = require('../lib/task-decision');
70
74
  const {
@@ -79,713 +83,93 @@ const {
79
83
  taskNextCommand,
80
84
  } = require('../lib/first-minute');
81
85
  const { loadContext } = require('../lib/state-detection');
82
-
83
- const DEFAULT_OWNER = process.env.ATRIS_AGENT_ID
84
- || process.env.USER
85
- || os.userInfo().username
86
- || 'unknown';
87
- const AGENT_CERTIFICATION_REVIEW_PASSES = 2;
88
- const RESULT_SAVED_TEXT_LIMIT = 200;
89
- const REVIEW_LANE_LOOP_DEFAULT_MAX_STEPS = 3;
90
- const REVIEW_LANE_LOOP_MAX_STEPS = 10;
91
- const REVIEW_LANE_RUN_DEFAULT_MAX_RUNS = 3;
92
- const REVIEW_LANE_RUN_MAX_RUNS = 20;
93
- const PENDING_REVIEW_CHAT_STOP_REASON = 'pending_review_chat_waiting_for_agent_review';
94
- const PROOF_BOUNDARY_BLOCKED_ACTION = 'proof_boundary_blocked';
95
- const PROOF_BOUNDARY_BLOCKED_REASON = 'proof_boundary_blocked_requires_revision';
96
- const MISSION_XP_END_TO_END_REASON = 'mission_xp_requires_end_to_end_receipt';
97
- const MISSION_XP_END_TO_END_DETAIL = 'mission XP proof must name a zero-papercut end-to-end fresh-laptop pass through install, init, first mission, and first self-landed task; generic mission/tick receipts are not enough';
98
- const READY_RESULT_TEACHING = 'ready needs --result: one plain sentence someone new to the project can understand. say what someone can do now and why it matters. no ids, no paths, no commands. example: operators can now read the whole team day on one page instead of scrolling raw logs.';
99
-
100
- const STATUS_PLAN_TAGS = new Set([
101
- 'agent',
102
- 'autopilot',
103
- 'cron',
104
- 'endgame',
105
- 'execute',
106
- 'explore',
107
- 'feature',
108
- 'goal',
109
- 'goal-step',
110
- 'loop',
111
- 'plan',
112
- 'planned',
113
- 'schedule',
114
- 'scheduled',
115
- 'shape',
116
- 'shaping',
117
- 'ui',
118
- 'ux',
119
- ]);
120
- const TASK_QUEUE_COLUMN_ORDER = ['do', 'review', 'plan', 'backlog', 'blocked', 'done'];
121
- const TASK_QUEUE_COLUMN_LABELS = {
122
- backlog: 'Backlog',
123
- plan: 'Plan',
124
- do: 'Do',
125
- review: 'Review',
126
- blocked: 'Blocked',
127
- done: 'Done',
128
- };
129
- const TASK_REVIEW_STATE_LANES = ['needs-agent', 'continue-work', 'proof-boundary-blocked', 'human-accept-waiting', 'certified'];
130
- const TASK_REVIEW_STATE_ALIASES = {
131
- 'needs-agent': ['needs-review', 'agent-review'],
132
- 'continue-work': ['continue', 'agent-actionable', 'executable'],
133
- 'proof-boundary-blocked': ['proof-boundary', 'boundary-blocked', 'stale-pr-proof', 'unmerged-pr-proof'],
134
- 'human-accept-waiting': ['human-accept', 'accept-waiting', 'waiting-accept', 'no-next-task'],
135
- certified: ['waiting-human', 'human-waiting'],
136
- };
137
-
138
- let taskDbModule = null;
139
-
140
- function getTaskDb() {
141
- if (taskDbModule) return taskDbModule;
142
- try {
143
- taskDbModule = require('../lib/task-db');
144
- return taskDbModule;
145
- } catch (e) {
146
- const message = String(e && (e.message || e));
147
- const missingSqlite = e && (
148
- e.code === 'ERR_UNKNOWN_BUILTIN_MODULE'
149
- || /node:sqlite|No such built-in module/i.test(message)
150
- );
151
- if (missingSqlite) {
152
- console.error('atris task requires Node.js 22+ because it uses built-in node:sqlite.');
153
- console.error('Use the markdown TODO.md flow on older Node versions.');
154
- process.exit(1);
155
- }
156
- throw e;
157
- }
158
- }
159
-
160
- function warnIfTaskTitleNeedsOperatorWhy(title, options = {}) {
161
- const text = String(title || '').trim();
162
- if (!text || operatorReady(text)) return null;
163
- const warning = 'Warning: put the why in this task title in plain words: what it buys or costs, and who benefits. Drop flags and ids.';
164
- if (options.print !== false) console.error(warning);
165
- return warning;
166
- }
167
-
168
- function taskUsageText() {
169
- return `
170
- atris task - durable local task state (SQLite, gitignored)
171
-
172
- golden path (one tick, by cron or by hand):
173
- atris task delegate "fix the login bug" --to <member>
174
- atris task claim <id> --as <member>
175
- ... build ...
176
- atris task ready <id> --verify
177
- atris autoland tick # second check runs, task lands
178
-
179
- atris task Same two-line next as bare atris
180
- atris task desk [--all] Show the full task desk
181
- atris task new "<title>" [--what-changes "..."] [--why-it-matters "..."] [--done-looks-like "..."] [--verify <cmd>]
182
- Create a task with a plain explanation; omitted fields get honest defaults
183
- atris task next [--tag <tag>] [--create-next]
184
- Same next command as bare atris and the task desk; --create-next still seeds Endgame fallback
185
- atris task continue-work <id> Create/reuse a certified Review follow-up task
186
- atris task say <id> "<message>" Add context to a task
187
- atris task chat <id> "<message>" [--goal "..."] Refine a task chat + working goal
188
- atris task ready <id> --proof "..." --result "<sentence>"
189
- Agent proof ready; native goal can complete
190
- atris task ready <id> --verify "<cmd>" --result "<sentence>"
191
- Run <cmd>; only ready if it exits 0 (executed proof)
192
- Writes atris/runs/ receipt (pass or fail); that local pass is enough proof
193
- Optional CI URL only with --proof-url and --i-fetched
194
- atris task receipt <id> --verify "<cmd>" Run <cmd> and write an atris/runs/ receipt without going to ready
195
- atris task plan-preview "<purpose>" [--tag <tag>] [--owner <member>] [--task <id>]
196
- Show the plain Plan before work starts
197
- atris task ready <id> --proof "..." --result "<sentence>" [--changed "..." --checked "..." --saved "..." --try "..."]
198
- Agent proof ready; records Result if needed
199
- atris task ready <id> --proof "..." --result "<sentence>" [--happened "..." --checked "..." --tested "..." --decision "..."]
200
- Agent proof ready; writes the human result receipt
201
- atris task result <id> "<sentence>" Set or replace the day-one PM result sentence
202
- atris task result <id> --changed "..." --checked "..." [--saved "..."] [--try "..."]
203
- Show the plain Result and store trace on the task
204
- atris task review-chat <id> [--as <owner>] Start a task-owned /codex verification chat
205
- atris task accept <id> [--proof "..."] [--public]
206
- Human accepts proof, marks done; --public also publishes AgentXP
207
- atris task certify-verified [--dry-run] [--limit <n>] [--as <actor>]
208
- Re-run the runnable check named in each Review proof as a second actor; passing rows certify (denied lanes and check-less rows wait for a human)
209
- atris task auto-accept-certified --dry-run [--strict-verify] [--all] [--limit <n>]
210
- Preview certified Review rows; live accept needs --confirm-human-accept --as <human>
211
- atris task sweep --auto-accept [--json] Auto-accept verified Review rows; protected lanes wait for human
212
- atris task sweep [--json] [--stale-days N] [--claimer NAME] [--dry-run] Release expired claims
213
- atris task sweep --held [--everywhere] [--dry-run] [--json] Check held jobs against GitHub: credit a merge, release a closed pull request
214
- atris task audit [--limit <n>] [--revise] re-run stored verifies for newest accepted tasks; report-only unless --revise
215
- atris task revise <id> --note "..." Send reviewed work back to Do
216
-
217
- atris task add "<title>" [--tag <tag>] [--goal-id <id>] [--what-changes "..."] [--why-it-matters "..."] [--done-looks-like "..."] [--verify <cmd>] Create a task
218
- atris task delegate "<title>" [--to <member>] [--executed-by <engine>] [--goal-id <id>] [--tag <tag>] [--what-changes "..."] [--why-it-matters "..."] [--done-looks-like "..."] [--verify <cmd>] Create assigned work
219
- atris task plan <id> --goal "..." --exit "..." --proof-needed "..."
220
- Record a task-owned Plan stage
221
- atris task do <id> --as <owner> --first-move "..."
222
- Start task-owned Do work from the plan
223
- atris task backlog <id> [--reason "..."] Move a planned open task back to Backlog
224
- atris task clear-plan --yes Move all planned open tasks back to Backlog
225
- atris task day [--full] [--all] [--everywhere] [--json] show today's owner-grouped task list
226
- text shows eight current rows; --full shows every active row
227
- --all stays in this workspace; --everywhere spans workspaces
228
- atris task list [--all] [--everywhere] [--status <s>]
229
- list tasks in this workspace; --everywhere spans workspaces
230
- atris task claim <id> [--as <owner>] Atomic claim
231
- atris task release <id> [--as <owner>] [--reason "<why>"] Release your own mistaken claim back to open
232
- atris task capabilities [--json] Read-only task CLI/API capability contract
233
- atris task capabilities-check [--json] Verify task capability contract conformance
234
- atris task review-lane-drain [--json] Pick next safe Review-lane agent action
235
- atris task review-lane-act [--json] Execute next safe Review-lane agent action
236
- atris task review-lane-loop [--json] Run bounded safe Review-lane actions
237
- atris task review-lane-run [--json] Run bounded review-lane loops and write receipts
238
- atris task current [--json] [--goal-id <id>] [--tag <tag>] [--status <s>] [--review-state <lane>]
239
- Read-only best next task page + queue
240
- atris task queue [--json] [--goal-id <id>] [--tag <tag>] [--status <s>] [--review-state <lane>]
241
- Read-only task lanes + current page
242
- atris task current-step [--json] [--goal-id <id>] [--tag <tag>] [--review-state <lane>]
243
- Advance the scoped current task one safe step
244
- review-state lanes: needs-agent, continue-work, human-accept-waiting, certified
245
- atris task note <id> "<message>" Append dialogue/context to a task
246
- atris task retitle <id> "<new title>" Rename a task and preserve the old title in dialogue
247
- atris task tag <id> --add <tag> [--remove <tag>]
248
- Update tags on an existing task (e.g. --add needs-human to hold it
249
- from sweep + fleet staffing); logs a task_tags_updated event
250
- atris task show <id> [--json] Show a task card + dialogue
251
- atris task inspect <id> --fields review,status,title [--json]
252
- Field-selectable task state (review metadata, status, title, owner, tag)
253
- atris task page <id> [--json] Show the one-task page contract
254
- atris task step <id> [--json] Refine chat, then advance one safe Plan/Do/Review step
255
- atris task done <id> --proof "..." Mark complete with proof
256
- atris task done <id> --failed [--proof "..."] Mark failed, optionally reviewed
257
- atris task archive <id> --reason "..." [--from-failed] [--not-needed]
258
- Sweep off-roadmap/duplicate work as archived (not failed);
259
- --from-failed opts in to relabel a fail-closed row (never done)
260
- --not-needed: a person confirms the job did not need doing, so a
261
- drop on it stops counting against the member (needs --reason;
262
- refused inside an agent session or a dispatched worker)
263
- atris task reopen <id> [--reason "..."] Send a done, waiting, failed, or archived task back to open
264
- atris task clear-done [--before <days>] [--dry-run] [--json] Archive completed rows, oldest first
265
- atris task prune-test-junk [--yes] [--vacuum] [--json]
266
- Count rows test runs left from temp folders; --yes backs up the db, then removes them
267
- atris task keep [--json] Put away finished and untouched work, then refresh the list
268
- atris task reap-mission-blockers [--json] Close blocker rows whose missions are complete or stopped
269
- atris task relabel-archived [--dry-run|--apply]
270
- One-time OBL-1622 migration: relabel June-10 backlog-reset rows failed -> archived
271
- atris task finish <id> --proof "..." Legacy alias for done with proof
272
- atris task review <id> --reward <n> [--verify "<cmd>"]
273
- Write review event + RSI episode
274
- atris task reviews [--all|--limit <n>] [--verbose] Show certified Review items for human accept/revise
275
- atris task reviews --group-by <tag|owner|source> Cluster approval-ready work for fast triage
276
- atris task accept-group <key>=<value> --spot-check K --confirm-human-accept --as <you> --verified <ids>
277
- Accept a whole cluster; career XP only on the K you verified
278
- atris task status [--json] [--history] Compact live status for web/Swarlo
279
- atris task setup [--import-todo] Create/refresh task projection
280
- atris task serve [--port <n>] Open local task factory board
281
- atris task sync --dry-run Plan cloud/Swarlo task sync writes
282
- atris task import <file> One-shot import from TODO.md
283
- atris task lineage <id> [--json] Show endgame -> tasks -> commits chain
284
- atris task events [id] [--limit <n>] print recent task events
285
- atris task events --all print the full current-workspace ledger
286
- atris task events --everywhere print the full ledger across workspaces
287
- atris task export [--all] [--everywhere] [--out <file>]
288
- write web/desktop JSON projection
289
- atris task render [--all] [--everywhere] [--out <file>]
290
- regenerate compact TODO.md view from state
291
- atris task where Print db path + workspace scope
292
- atris task help This help
293
-
294
- Confidence Gate:
295
- Before plan/do/review advances, find loopholes, patch them with proof,
296
- verifier, owner, rollback, or name the residual risk.
297
-
298
- Env:
299
- ATRIS_TASKS_DB Override db path (default ~/.atris/tasks.db)
300
- ATRIS_AGENT_ID Owner id for claim/done (default: $USER)
301
-
302
- Refs:
303
- Human views use semantic refs like OBL-18. Commands accept OBL-18,
304
- OBL18, full 26-char task IDs, and any unique legacy prefix. JSON/API
305
- keep the full id as canonical and also expose display_id + legacy_ref.
306
-
307
- Headless:
308
- Add --json to task commands for machine-readable output and stable automation.
309
- `.trim();
310
- }
311
-
312
- function taskUsageLines() {
313
- return taskUsageText().split('\n');
314
- }
315
-
316
- function help() {
317
- console.log(taskUsageText());
318
- }
319
-
320
- function flag(args, name) {
321
- const i = args.indexOf(name);
322
- if (i === -1) return null;
323
- return args[i + 1] || true;
324
- }
325
-
326
- function taskScopeEverywhere(args = [], options = {}) {
327
- if (options.everywhere !== undefined) return Boolean(options.everywhere);
328
- return hasFlag(args, '--everywhere');
329
- }
330
-
331
- function scopedWorkspaceRoot(taskDb, args = [], options = {}) {
332
- return taskScopeEverywhere(args, options) ? null : taskDb.workspaceRoot();
333
- }
334
-
335
- function hasEmptyFlagValue(args, name) {
336
- const i = args.indexOf(name);
337
- return i !== -1 && args[i + 1] === '';
338
- }
339
-
340
- function wantsJson(args) {
341
- return hasFlag(args, '--json');
342
- }
343
-
344
- function parseAcceptReward(value, { defaultValue = 1 } = {}) {
345
- if (value === undefined || value === null || value === true) return { ok: true, value: defaultValue };
346
- const numeric = Number(value);
347
- if (!Number.isFinite(numeric) || numeric <= 0) {
348
- return { ok: false, reason: 'invalid_reward' };
349
- }
350
- return { ok: true, value: numeric };
351
- }
352
-
353
- function validHumanActorFlag(value) {
354
- if (typeof value !== 'string') return false;
355
- const actor = value.trim();
356
- return Boolean(actor) && !actor.startsWith('--') && actor !== 'auto-accept-certified';
357
- }
358
-
359
- const AGENT_ENV_MARKERS = [
360
- 'CLAUDECODE',
361
- 'CLAUDE_CODE_ENTRYPOINT',
362
- 'CODEX_SANDBOX',
363
- 'CURSOR_AGENT',
364
- 'DEVIN_SESSION_ID',
365
- ];
366
-
367
- function agentProofOnlyMode() {
368
- const explicit = process.env.ATRIS_AGENT_PROOF_ONLY;
369
- if (explicit === '1') return true;
370
- if (explicit === '0') return false;
371
- return AGENT_ENV_MARKERS.some((marker) => String(process.env[marker] || '').trim() !== '');
372
- }
373
-
374
- // Everything an engine process carries: what the engine tools export in the
375
- // shells they run, and what a mission flight sets on the drivers and engines it
376
- // starts. ATRIS_AGENT_ID is left out on purpose: it is the owner's own id, which
377
- // a person may export. The fleet's own dispatch adds no marker of its own, so
378
- // it relies on the engine tool's.
379
- const WORKER_ENV_MARKERS = [
380
- ...AGENT_ENV_MARKERS,
381
- 'CODEX_THREAD_ID',
382
- 'CODEX_SESSION_ID',
383
- 'ATRIS_MISSION_DRIVER_DETACHED',
384
- 'ATRIS_MISSION_DRIVER_PARENT_PID',
385
- 'ATRIS_DISPATCH_DEPTH',
386
- ];
387
-
388
- // The first worker marker set in this shell, or ''. Unlike agentProofOnlyMode
389
- // it cannot be switched off with ATRIS_AGENT_PROOF_ONLY=0.
390
- function workerEnvMarker(env = process.env) {
391
- const found = WORKER_ENV_MARKERS.find((marker) => String(env[marker] || '').trim() !== '');
392
- if (found) return found;
393
- return String(env.ATRIS_AGENT_PROOF_ONLY || '').trim() === '1' ? 'ATRIS_AGENT_PROOF_ONLY' : '';
394
- }
395
-
396
- function failAgentProofOnly(label, detail) {
397
- failTask(
398
- label,
399
- 'agent_proof_only_human_accept_blocked',
400
- detail || 'Agent proof-only mode can write notes, ready proof, and zero-reward reviews, but cannot mark tasks done or accept XP.',
401
- );
402
- }
403
-
404
- // Write straight to stdout and wait until it is all out, so a command that exits right after
405
- // printing (process.exit drops whatever a pipe still holds) never loses the end of its output.
406
- function writeStdoutSync(text) {
407
- const buffer = Buffer.from(text, 'utf8');
408
- const retryWait = new Int32Array(new SharedArrayBuffer(4));
409
- let offset = 0;
410
- while (offset < buffer.length) {
411
- try {
412
- offset += fs.writeSync(process.stdout.fd, buffer, offset, buffer.length - offset);
413
- } catch (err) {
414
- if (err && err.code === 'EAGAIN') {
415
- Atomics.wait(retryWait, 0, 0, 10);
416
- continue;
417
- }
418
- throw err;
419
- }
420
- }
421
- }
422
-
423
- function printJson(value) {
424
- writeStdoutSync(`${JSON.stringify(value, null, 2)}\n`);
425
- }
426
-
427
- function refreshCareerXpProjection(workspaceRoot) {
428
- if (!workspaceRoot) return null;
429
- try {
430
- const { collectLocalXpProjection } = require('../commands/xp');
431
- const { careerXpRoot } = require('../lib/career-xp-root');
432
- return collectLocalXpProjection(['--workspace', careerXpRoot(workspaceRoot)]);
433
- } catch (error) {
434
- return {
435
- ok: false,
436
- error: error && error.message ? error.message : String(error),
437
- };
438
- }
439
- }
440
-
441
- function refreshCareerXpAfterReview(reviewed) {
442
- return refreshCareerXpProjection(reviewed?.episode?.workspace_root);
443
- }
444
-
445
- function compactCareerXpProjection(projection) {
446
- if (!projection || typeof projection !== 'object') return projection;
447
- const progress = projection.next_level_progress && typeof projection.next_level_progress === 'object'
448
- ? {
449
- level: projection.next_level_progress.level ?? null,
450
- next_level: projection.next_level_progress.next_level ?? null,
451
- current_xp: projection.next_level_progress.current_xp ?? null,
452
- required_xp: projection.next_level_progress.required_xp ?? null,
453
- remaining_xp: projection.next_level_progress.remaining_xp ?? null,
454
- percent: projection.next_level_progress.percent ?? null,
455
- }
456
- : null;
457
- const latest = projection.latest_accepted_proof && typeof projection.latest_accepted_proof === 'object'
458
- ? {
459
- label: projection.latest_accepted_proof.label ?? null,
460
- receipt_id: projection.latest_accepted_proof.receipt_id ?? null,
461
- source: projection.latest_accepted_proof.source ?? null,
462
- source_task_id: projection.latest_accepted_proof.source_task_id ?? null,
463
- title: projection.latest_accepted_proof.title ?? null,
464
- xp: projection.latest_accepted_proof.xp ?? null,
465
- reward: projection.latest_accepted_proof.reward ?? null,
466
- accepted_at: projection.latest_accepted_proof.accepted_at ?? null,
467
- goal: projection.latest_accepted_proof.goal ?? null,
468
- }
469
- : null;
470
- const integrity = projection.integrity && typeof projection.integrity === 'object'
471
- ? {
472
- status: projection.integrity.status ?? null,
473
- receipts_count: projection.integrity.receipts_count ?? projection.receipts_count ?? null,
474
- head_hash: projection.integrity.head_hash ?? null,
475
- }
476
- : null;
477
- return {
478
- schema: projection.schema,
479
- compact: true,
480
- total_xp: projection.total_xp ?? projection.total_agent_xp ?? projection.agent_xp ?? null,
481
- agent_xp: projection.agent_xp ?? projection.total_agent_xp ?? null,
482
- today_xp: projection.today_xp ?? projection.today_agent_xp ?? null,
483
- collected_receipts: projection.collected_receipts ?? 0,
484
- receipts_count: projection.receipts_count ?? null,
485
- level: projection.level ?? null,
486
- leaderboard_eligible: projection.leaderboard_eligible ?? null,
487
- integrity_status: projection.integrity_status ?? integrity?.status ?? null,
488
- next_level_progress: progress,
489
- latest_accepted_proof: latest,
490
- integrity,
491
- omitted: [
492
- 'contribution_graph',
493
- 'career',
494
- 'earning_model',
495
- 'local_activity',
496
- 'ledger',
497
- 'sources',
498
- ],
499
- };
500
- }
501
-
502
- function jsonModeActive() {
503
- return process.argv.includes('--json');
504
- }
505
-
506
- function failTask(label, reason, detail, exitCode = 2) {
507
- if (jsonModeActive()) {
508
- printCliJson(
509
- {
510
- ok: false,
511
- command: label,
512
- reason,
513
- detail: detail || null,
514
- selected_ref: null,
515
- next_command: null,
516
- },
517
- compactErrorPayload({ reason, detail: detail || null }),
518
- process.argv.slice(2),
519
- );
520
- } else {
521
- console.error(detail || `${label}: ${reason}`);
522
- }
523
- process.exit(exitCode);
524
- }
525
-
526
- function refuseCandidatePolicyGate(label, gate) {
527
- if (jsonModeActive()) {
528
- printJson({ ok: false, command: label, ...gate });
529
- } else {
530
- console.error(`${label}: ${gate.reason}: ${gate.message}`);
531
- if (Array.isArray(gate.offenders)) {
532
- gate.offenders.forEach((offender) => console.error(` ${offender}`));
533
- }
534
- if (Array.isArray(gate.lesson_ids) && gate.lesson_ids.length) {
535
- console.error(` lessons: ${gate.lesson_ids.join(', ')}`);
536
- }
537
- }
538
- process.exit(1);
539
- }
540
-
541
- function proofFlagValue(args) {
542
- const proof = flag(args, '--proof');
543
- return typeof proof === 'string' ? proof.trim() : '';
544
- }
545
-
546
- function resultSentenceIssue(value, { allowCommandMention = false } = {}) {
547
- const check = explainResult(value, { allowCommandMention });
548
- return check.ok ? null : check.reason;
549
- }
550
-
551
- function readyResultDetail(reason) {
552
- return reason ? `${READY_RESULT_TEACHING}\n${reason}` : READY_RESULT_TEACHING;
553
- }
554
-
555
- function requireResultSentence(label, value, { ready = false, allowCommandMention = false } = {}) {
556
- const issue = resultSentenceIssue(value, { allowCommandMention });
557
- if (!issue) return String(value || '').replace(/\s+/g, ' ').trim();
558
- const detail = ready ? readyResultDetail(issue) : issue;
559
- failTask(label, 'weak_result', detail);
560
- }
561
-
562
- function weakProofDetail(issue) {
563
- return `meaningful proof required: ${issue}\n${LOCAL_SUCCESS_PROOF_EXAMPLE}`;
564
- }
565
-
566
- function requireMeaningfulTaskProof(label, proof, { required = true } = {}) {
567
- const issue = meaningfulTaskProofIssue(proof, { required });
568
- if (issue) failTask(label, 'weak_proof', weakProofDetail(issue));
569
- }
570
-
571
- function requireRanNamedProofCommand(label, proof, ranCommand = '') {
572
- const issue = unrunNamedProofCommandIssue(proof, ranCommand);
573
- if (issue) failTask(label, issue.reason, issue.detail);
574
- }
575
-
576
- function sendProofIssue(res, proof, issue) {
577
- return sendJson(res, 400, {
578
- ok: false,
579
- reason: String(proof || '').trim() ? 'weak_proof' : 'proof_required',
580
- detail: weakProofDetail(issue),
581
- });
582
- }
583
-
584
- function textFlag(args, names) {
585
- for (const name of names) {
586
- const value = flag(args, name);
587
- if (typeof value === 'string') return value.trim();
588
- }
589
- return '';
590
- }
591
-
592
- // Callers may write the plain explanation themselves. Anything they leave out
593
- // gets an honest derived default at creation time.
594
- function explanationFlags(args) {
595
- return explanationFieldsFromInput({
596
- what_changes: textFlag(args, ['--what-changes']),
597
- why_it_matters: textFlag(args, ['--why-it-matters']),
598
- done_looks_like: textFlag(args, ['--done-looks-like']),
599
- });
600
- }
601
-
602
- function landingFlags(args) {
603
- return {
604
- happened: textFlag(args, ['--landing', '--happened', '--what-happened']),
605
- checked: textFlag(args, ['--checked', '--how-checked']),
606
- tested: textFlag(args, ['--tested', '--what-tested']),
607
- decision: textFlag(args, ['--decision', '--acceptance']),
608
- };
609
- }
610
-
611
- // The pull request a flight opened or merged, so the job record names it.
612
- // Only a merge commit makes it count as landed. A number or link alone is an
613
- // open pull request still waiting for a person to land it.
614
- function readyMergeFlags(args) {
615
- const commit = textFlag(args, ['--merge-commit']);
616
- const number = textFlag(args, ['--pr-number']);
617
- const url = textFlag(args, ['--pr-url']);
618
- if (!commit && !number && !url) return null;
619
- return {
620
- merge_commit: commit,
621
- number,
622
- url,
623
- merged_at: textFlag(args, ['--merged-at']),
624
- self_landed: hasFlag(args, '--self-landed'),
625
- };
626
- }
627
-
628
- function normalizedLandingSentence(value) {
629
- return String(value || '').replace(/\s+/g, ' ').trim();
630
- }
631
-
632
- function defaultLandingSentenceForTitle(title) {
633
- return `Completed: ${String(title || '').trim()}.`;
634
- }
635
-
636
- function landingNeedsDayOnePm(sentence, title) {
637
- const text = normalizedLandingSentence(sentence);
638
- if (!text) return true;
639
- if (text.toLowerCase() === normalizedLandingSentence(defaultLandingSentenceForTitle(title)).toLowerCase()) return true;
640
- return hasAgentJargon(text) || /\bas\s+exists?\b/i.test(text) || !operatorReady(text);
641
- }
642
-
643
- function warnIfLandingNeedsDayOnePm(landing, title) {
644
- const sentence = landing && typeof landing === 'object' ? landing.happened : '';
645
- if (!landingNeedsDayOnePm(sentence, title)) return null;
646
- const warning = 'Advisory: add --landing with one plain sentence saying what someone can do now, in words a new teammate would get. No flags, no ids.';
647
- console.error(warning);
648
- return warning;
649
- }
650
-
651
- function requireExplicitLandingDayOnePm(label, landing, title) {
652
- const sentence = landing && typeof landing === 'object' ? normalizedLandingSentence(landing.happened) : '';
653
- if (!sentence || !landingNeedsDayOnePm(sentence, title)) return;
654
- failTask(
655
- label,
656
- 'weak_landing',
657
- 'landing needs one plain sentence saying what someone can do now and why it matters, in words a new teammate would understand. no flags, ids, or unnamed filters.',
658
- );
659
- }
660
-
661
- function numericFlag(args, name) {
662
- const value = flag(args, name);
663
- if (value === null || value === true || value === undefined) return null;
664
- const parsed = Number(value);
665
- return Number.isFinite(parsed) ? parsed : null;
666
- }
667
-
668
- function meaningfulTaskProofIssue(proof, { required = true } = {}) {
669
- const text = String(proof || '').trim();
670
- if (!required && !text) return null;
671
- const state = taskProofState(text);
672
- return state.ok ? null : state.reason;
673
- }
674
-
675
- function goldenPathMissionXpTask(task) {
676
- if (!task) return false;
677
- const metadata = task.metadata || {};
678
- const text = [
679
- task.title,
680
- task.tag,
681
- metadata.goal_id,
682
- metadata.goalId,
683
- metadata.mission_id,
684
- metadata.stop_condition,
685
- metadata.goal_objective,
686
- metadata.objective,
687
- ].filter(Boolean).join(' ');
688
- const missionXp = String(task.title || '').trim().toLowerCase().startsWith('mission xp:')
689
- || String(task.tag || '').toLowerCase() === 'agent-xp';
690
- return missionXp
691
- && /\b(?:golden[- ]path|zero[- ]knowledge|zero[- ]papercuts?|fresh[- ](?:laptop|environment|install|home)|self[- ]landed)\b/i.test(text);
692
- }
693
-
694
- // A receipt that cannot be read drops out of review evidence; name it under
695
- // ATRIS_DEBUG so the gap is findable, matching lib/todo.js.
696
- function debugUnreadableReceipt(rel, err) {
697
- if (process.env.ATRIS_DEBUG) console.error(`[task] receipt unreadable, skipped: ${rel}: ${err && err.message}`);
698
- }
699
-
700
- function receiptTextForProof(proof, root = process.cwd()) {
701
- const chunks = [];
702
- const pattern = new RegExp(RECEIPT_PATH_PATTERN.source, 'g');
703
- let match;
704
- while ((match = pattern.exec(String(proof || ''))) && chunks.length < 3) {
705
- const rel = match[1];
706
- if (!rel || rel.includes('*')) continue;
707
- try {
708
- const raw = fs.readFileSync(path.resolve(root, rel), 'utf8');
709
- const parsed = JSON.parse(raw);
710
- chunks.push(JSON.stringify({
711
- schema: parsed.schema || null,
712
- mission_id: parsed.mission_id || null,
713
- result: parsed.result || null,
714
- landing: parsed.landing || null,
715
- last_landing: parsed.last_landing || null,
716
- summary: parsed.summary || null,
717
- }).slice(0, 12000));
718
- } catch (err) {
719
- debugUnreadableReceipt(rel, err);
720
- }
721
- }
722
- return chunks.join(' ');
723
- }
724
-
725
- // Mission ticks already compose a real landing sentence into the run receipt.
726
- // When a mission-bridged task lands with only a receipt proof, lift that
727
- // sentence so the review queue shows the work instead of echoing the title.
728
- function missionReceiptResultForProof(task, proof, root = process.cwd()) {
729
- const metadata = task?.metadata || {};
730
- if (!metadata.mission_id && !metadata.goal_id) return null;
731
- const pattern = new RegExp(RECEIPT_PATH_PATTERN.source, 'g');
732
- let match;
733
- while ((match = pattern.exec(String(proof || '')))) {
734
- const rel = match[1];
735
- if (!rel || rel.includes('*')) continue;
736
- let landing = null;
737
- try {
738
- const parsed = JSON.parse(fs.readFileSync(path.resolve(root, rel), 'utf8'));
739
- landing = parsed?.result?.landing || parsed?.landing || parsed?.last_landing || null;
740
- } catch (err) {
741
- debugUnreadableReceipt(rel, err);
742
- continue;
743
- }
744
- const changed = String(landing?.changed || landing?.happened || '').replace(/\s+/g, ' ').trim();
745
- if (!changed) continue;
746
- if (/\brecorded tick \d+\.?$/i.test(changed) || /^recorded a proof heartbeat\b/i.test(changed)) continue;
747
- const reason = String(landing?.reason || landing?.why || '').replace(/\s+/g, ' ').trim();
748
- return { changed, reason: reason && !isRetiredFillerReason(reason) ? reason : null };
749
- }
750
- return null;
751
- }
752
-
753
- function missionXpEndToEndProofIssue(task, proof, root = process.cwd()) {
754
- if (!goldenPathMissionXpTask(task)) return null;
755
- const corpus = `${String(proof || '')} ${receiptTextForProof(proof, root)}`
756
- .replace(/\s+/g, ' ')
757
- .trim();
758
- const hasZeroPapercut = /\b(?:zero|0|no)\s+(?:new\s+)?papercuts?\b|\bzero[- ]papercut\b/i.test(corpus);
759
- const hasEndToEnd = /\bend[- ]to[- ]end\b|\bfull\s+(?:fresh[- ](?:laptop|environment)\s+)?pass\b|\bfresh[- ](?:laptop|environment|install)\b|\bclean\s+temp\s+home\b|\bnpm\s+pack\b/i.test(corpus);
760
- const hasSelfLanded = /\bself[- ]landed\b|\bfirst\s+self[- ]landed\s+task\b|\btask\s+reaches\s+done\b|\binstall\b.{0,120}\binit\b.{0,120}\bmission\b.{0,120}\b(?:self[- ]landed|task)\b/i.test(corpus);
761
- return hasZeroPapercut && hasEndToEnd && hasSelfLanded ? null : MISSION_XP_END_TO_END_DETAIL;
762
- }
763
-
764
- function missionXpProofBoundaryEvaluation(task, proofOverride = null) {
765
- if (!goldenPathMissionXpTask(task)) return null;
766
- const metadata = task.metadata || {};
767
- const review = task.review || {};
768
- const proof = proofOverride === null
769
- ? String(review.proof || metadata.latest_agent_proof || '').trim()
770
- : String(proofOverride || '').trim();
771
- const issue = missionXpEndToEndProofIssue(task, proof, task.workspace_root || process.cwd());
772
- if (!issue) return null;
773
- return {
774
- eligible: false,
775
- ref: taskRef(task),
776
- reason: MISSION_XP_END_TO_END_REASON,
777
- next_action: 'attach the zero-papercut end-to-end fresh-laptop receipt, then resubmit Mission XP proof',
778
- proof,
779
- };
780
- }
781
-
782
- function positional(args) {
783
- return args.filter((a, i) => {
784
- if (a.startsWith('--')) return false;
785
- if (i > 0 && args[i - 1].startsWith('--')) return false;
786
- return true;
787
- });
788
- }
86
+ const {
87
+ DEFAULT_OWNER,
88
+ AGENT_CERTIFICATION_REVIEW_PASSES,
89
+ RESULT_SAVED_TEXT_LIMIT,
90
+ REVIEW_LANE_LOOP_DEFAULT_MAX_STEPS,
91
+ REVIEW_LANE_LOOP_MAX_STEPS,
92
+ REVIEW_LANE_RUN_DEFAULT_MAX_RUNS,
93
+ REVIEW_LANE_RUN_MAX_RUNS,
94
+ PENDING_REVIEW_CHAT_STOP_REASON,
95
+ PROOF_BOUNDARY_BLOCKED_ACTION,
96
+ PROOF_BOUNDARY_BLOCKED_REASON,
97
+ MISSION_XP_END_TO_END_REASON,
98
+ MISSION_XP_END_TO_END_DETAIL,
99
+ READY_RESULT_TEACHING,
100
+ STATUS_PLAN_TAGS,
101
+ TASK_QUEUE_COLUMN_ORDER,
102
+ TASK_QUEUE_COLUMN_LABELS,
103
+ TASK_REVIEW_STATE_LANES,
104
+ TASK_REVIEW_STATE_ALIASES,
105
+ AGENT_ENV_MARKERS,
106
+ WORKER_ENV_MARKERS,
107
+ TASK_BOARD_COLUMNS,
108
+ getTaskDb,
109
+ warnIfTaskTitleNeedsOperatorWhy,
110
+ taskUsageText,
111
+ taskUsageLines,
112
+ help,
113
+ flag,
114
+ taskScopeEverywhere,
115
+ scopedWorkspaceRoot,
116
+ hasEmptyFlagValue,
117
+ wantsJson,
118
+ parseAcceptReward,
119
+ validHumanActorFlag,
120
+ agentProofOnlyMode,
121
+ workerEnvMarker,
122
+ failAgentProofOnly,
123
+ writeStdoutSync,
124
+ printJson,
125
+ refreshCareerXpProjection,
126
+ refreshCareerXpAfterReview,
127
+ compactCareerXpProjection,
128
+ jsonModeActive,
129
+ failTask,
130
+ refuseCandidatePolicyGate,
131
+ proofFlagValue,
132
+ resultSentenceIssue,
133
+ readyResultDetail,
134
+ requireResultSentence,
135
+ weakProofDetail,
136
+ requireMeaningfulTaskProof,
137
+ requireRanNamedProofCommand,
138
+ sendProofIssue,
139
+ textFlag,
140
+ explanationFlags,
141
+ landingFlags,
142
+ readyMergeFlags,
143
+ normalizedLandingSentence,
144
+ defaultLandingSentenceForTitle,
145
+ landingNeedsDayOnePm,
146
+ warnIfLandingNeedsDayOnePm,
147
+ requireExplicitLandingDayOnePm,
148
+ numericFlag,
149
+ meaningfulTaskProofIssue,
150
+ goldenPathMissionXpTask,
151
+ debugUnreadableReceipt,
152
+ receiptTextForProof,
153
+ missionReceiptResultForProof,
154
+ missionXpEndToEndProofIssue,
155
+ missionXpProofBoundaryEvaluation,
156
+ positional,
157
+ taskFromProjection,
158
+ taskRef,
159
+ normalizeTaskLookupRef,
160
+ taskLookupRefs,
161
+ resolveProjectionTaskRef,
162
+ normalizedStatusPart,
163
+ taskIsPlannedOpen,
164
+ taskColumn,
165
+ taskHasReview,
166
+ taskBoardViewModel,
167
+ taskBoardTemplate,
168
+ taskBoardHtml,
169
+ readJsonBody,
170
+ sendJson,
171
+ sendHtml,
172
+ } = require('../lib/task-core');
789
173
 
790
174
  // options.root is for a caller that is not the command line, the dispatch flight: it
791
175
  // keeps the list for its own workspace instead of for the directory this process is in.
@@ -811,32 +195,6 @@ function writeDefaultProjection(taskDb, db, options = {}) {
811
195
  return { projection, outPath };
812
196
  }
813
197
 
814
- function taskFromProjection(projection, id) {
815
- return projection.tasks.find(t => t.id === id) || null;
816
- }
817
-
818
- function taskRef(taskOrId) {
819
- if (!taskOrId) return 'TASK';
820
- if (typeof taskOrId === 'string') return taskOrId.replace(/[^a-zA-Z0-9]/g, '').toUpperCase().slice(0, 8);
821
- return taskOrId.display_id || taskOrId.legacy_ref || taskRef(taskOrId.id);
822
- }
823
-
824
- function normalizeTaskLookupRef(value) {
825
- return String(value || '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
826
- }
827
-
828
- function taskLookupRefs(task) {
829
- if (!task) return [];
830
- return [task.id, task.display_id, task.legacy_ref, taskRef(task)]
831
- .map(normalizeTaskLookupRef)
832
- .filter(Boolean);
833
- }
834
-
835
- function resolveProjectionTaskRef(ref, taskByRef) {
836
- const key = normalizeTaskLookupRef(ref);
837
- return key ? taskByRef.get(key) || null : null;
838
- }
839
-
840
198
  function reviewNextTaskTitle(task) {
841
199
  return normalizeReviewNextTaskInput(rawReviewNextTaskTitle(task)).nextTask;
842
200
  }
@@ -2985,12 +2343,21 @@ function taskReviewStateMatches(task, reviewState, { hasExistingReviewFollowUp =
2985
2343
  return false;
2986
2344
  }
2987
2345
 
2346
+ function taskMatchesScopeTag(task, wantedTag) {
2347
+ if (!wantedTag) return true;
2348
+ if (taskScopeEquals(task && task.tag, wantedTag)) return true;
2349
+ const tokens = taskTagTokens(task);
2350
+ if (tokens.some((token) => taskScopeEquals(token, wantedTag))) return true;
2351
+ if (isDecisionHoldTag(wantedTag) && isDecisionTask(task)) return true;
2352
+ return false;
2353
+ }
2354
+
2988
2355
  function taskMatchesQueueScope(task, scope = {}, options = {}) {
2989
2356
  const normalized = normalizeTaskQueueScope(scope);
2990
2357
  if (normalized.goal_id && !taskGoalScopeValues(task).some(value => taskScopeEquals(value, normalized.goal_id))) {
2991
2358
  return false;
2992
2359
  }
2993
- if (normalized.tag && !taskScopeEquals(task && task.tag, normalized.tag)) {
2360
+ if (normalized.tag && !taskMatchesScopeTag(task, normalized.tag)) {
2994
2361
  return false;
2995
2362
  }
2996
2363
  if (normalized.status) {
@@ -5594,19 +4961,6 @@ function formatTaskEventCompact(event, refById = new Map()) {
5594
4961
  return `${when}\t${event.event_type.padEnd(9)}\t${refById.get(event.task_id) || taskRef(event.task_id)}${actor}\t${taskEventSummary(event)}`;
5595
4962
  }
5596
4963
 
5597
- function normalizedStatusPart(value) {
5598
- return String(value || '').trim().toLowerCase().replace(/\s+/g, '-');
5599
- }
5600
-
5601
- function taskIsPlannedOpen(task) {
5602
- const metadata = task && task.metadata || {};
5603
- const tag = normalizedStatusPart(task && task.tag);
5604
- const stage = normalizedStatusPart(metadata.stage);
5605
- return STATUS_PLAN_TAGS.has(tag)
5606
- || STATUS_PLAN_TAGS.has(stage)
5607
- || Boolean(metadata.verify || metadata.goal || metadata.loop || metadata.cron || metadata.next_run_at);
5608
- }
5609
-
5610
4964
  function formatTaskLine(task) {
5611
4965
  if (!task) return 'none';
5612
4966
  const explanation = task.explanation || taskExplanation(task);
@@ -6569,20 +5923,7 @@ function cmdNextTruth(args) {
6569
5923
  const task = picked.task || null;
6570
5924
 
6571
5925
  if (!task && openDecisions.length) {
6572
- if (wantsJson(args)) {
6573
- printJson({
6574
- ok: false,
6575
- action: 'refused',
6576
- reason: DECISION_REFUSE_REASON,
6577
- skipped_decision_count: openDecisions.length,
6578
- owner: String(owner),
6579
- scope: normalizeTaskQueueScope(scope),
6580
- projection_path: outPath,
6581
- });
6582
- process.exit(1);
6583
- }
6584
- console.error(DECISION_REFUSE_REASON);
6585
- process.exit(1);
5926
+ reportSkippedDecisionsAndExit(openDecisions, { args, owner, scope, outPath });
6586
5927
  }
6587
5928
 
6588
5929
  if (wantsJson(args)) {
@@ -6607,6 +5948,33 @@ function cmdNextTruth(args) {
6607
5948
  console.log(`next: ${command}`);
6608
5949
  }
6609
5950
 
5951
+ function reportSkippedDecisionsAndExit(openDecisions, { args, owner, scope, outPath }) {
5952
+ const summary = summarizeSkippedDecisions(openDecisions);
5953
+ if (wantsJson(args)) {
5954
+ printJson({
5955
+ ok: false,
5956
+ action: 'refused',
5957
+ reason: DECISION_REFUSE_REASON,
5958
+ command: summary.command,
5959
+ show_command: summary.show_command,
5960
+ next_command: summary.next_command,
5961
+ choice: summary.choice,
5962
+ skipped_decision_count: summary.skipped_decision_count,
5963
+ skipped_decision_refs: summary.skipped_decision_refs,
5964
+ skipped_decisions: summary.skipped_decisions,
5965
+ decisions: summary.decisions,
5966
+ owner: String(owner),
5967
+ scope: normalizeTaskQueueScope(scope),
5968
+ projection_path: outPath,
5969
+ });
5970
+ process.exit(1);
5971
+ }
5972
+ const text = formatDecisionRefusalLines(openDecisions).join('\n');
5973
+ console.log(text);
5974
+ console.error(text);
5975
+ process.exit(1);
5976
+ }
5977
+
6610
5978
  function isUninitializedTaskFolder(cwd = process.cwd()) {
6611
5979
  const workspace = getTaskDb().workspaceRoot(cwd);
6612
5980
  if (fs.existsSync(path.join(workspace, 'atris'))) return false;
@@ -6679,20 +6047,14 @@ function cmdNext(args) {
6679
6047
  if (!open.length) {
6680
6048
  const { projection, outPath } = reviewProjection;
6681
6049
  if (skippedDecisions > 0 && !reviewTasks.length) {
6682
- if (wantsJson(args)) {
6683
- printJson({
6684
- ok: false,
6685
- action: 'refused',
6686
- reason: DECISION_REFUSE_REASON,
6687
- skipped_decision_count: skippedDecisions,
6688
- owner: String(owner),
6689
- scope: normalizeTaskQueueScope(scope),
6690
- projection_path: outPath,
6691
- });
6692
- process.exit(1);
6693
- }
6694
- console.error(DECISION_REFUSE_REASON);
6695
- process.exit(1);
6050
+ const openDecisionTasks = filterTasksByScope(projection.tasks || [], scope)
6051
+ .filter((task) => task && isDecisionTask(task) && task.status === 'open');
6052
+ reportSkippedDecisionsAndExit(
6053
+ openDecisionTasks.length
6054
+ ? openDecisionTasks
6055
+ : taskDb.withTaskDisplayRefs(openAll.filter((task) => isDecisionTask(task))),
6056
+ { args, owner, scope, outPath }
6057
+ );
6696
6058
  }
6697
6059
  const reviewTask = reviewTasks.find(task => task.review.handoff.next_action === 'agent_review_again')
6698
6060
  || reviewTasks.find(task => task.review.handoff.next_action === 'continue_work')
@@ -11967,590 +11329,6 @@ function cmdSync(args) {
11967
11329
  }
11968
11330
  }
11969
11331
 
11970
- function taskColumn(task) {
11971
- if (task.status === 'open') return taskIsPlannedOpen(task) ? 'open' : 'backlog';
11972
- if (task.status === 'claimed') return 'doing';
11973
- if (task.status === 'review') return 'review';
11974
- if (task.status === 'failed' && taskHasReview(task)) return 'done';
11975
- if (task.status === 'failed') return 'blocked';
11976
- if (task.status === 'done' && !taskHasReview(task)) return 'review';
11977
- return 'done';
11978
- }
11979
-
11980
- function taskHasReview(task) {
11981
- if (task.latest_event_type === 'reviewed') return true;
11982
- const review = task.review || {};
11983
- return review.reward != null || Boolean(review.proof || review.lesson || review.next_task);
11984
- }
11985
-
11986
- const TASK_BOARD_COLUMNS = [
11987
- ['backlog', 'Backlog'],
11988
- ['open', 'Open'],
11989
- ['doing', 'Doing'],
11990
- ['review', 'Review'],
11991
- ['blocked', 'Blocked'],
11992
- ['done', 'Done'],
11993
- ];
11994
-
11995
- // Pure state shaping: projection in, board view model out. No markup here.
11996
- function taskBoardViewModel(projection = {}) {
11997
- const tasks = Array.isArray(projection.tasks) ? projection.tasks : [];
11998
- const rows = tasks.map((task) => ({
11999
- id: task.id,
12000
- task,
12001
- column: taskColumn(task),
12002
- decision: taskHasReview(task),
12003
- }));
12004
- const columns = TASK_BOARD_COLUMNS.map(([key, label]) => ({
12005
- key,
12006
- label,
12007
- rows: rows.filter((row) => row.column === key),
12008
- }));
12009
- const counts = {};
12010
- for (const column of columns) counts[column.key] = column.rows.length;
12011
- return {
12012
- columns,
12013
- counts,
12014
- rows,
12015
- total: rows.length,
12016
- planTags: Array.from(STATUS_PLAN_TAGS),
12017
- };
12018
- }
12019
-
12020
- // Markup only: view model in, board page out. The page hydrates itself from
12021
- // /api/tasks in the browser, so the template bakes in just the shared
12022
- // plan-tag list; the column keys and labels must stay in step with
12023
- // TASK_BOARD_COLUMNS above.
12024
- function taskBoardTemplate(model) {
12025
- return `<!doctype html>
12026
- <html lang="en">
12027
- <head>
12028
- <meta charset="utf-8">
12029
- <meta name="viewport" content="width=device-width, initial-scale=1">
12030
- <title>Atris Task Factory</title>
12031
- <link rel="preconnect" href="https://fonts.googleapis.com">
12032
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
12033
- <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
12034
- <style>
12035
- /* aesthetic: machine-room telemetry, warm-tinted dark, mono data, calm signals (no neon) */
12036
- :root {
12037
- color-scheme: dark;
12038
- --bg: oklch(18% 0.012 160);
12039
- --panel: oklch(22% 0.014 160);
12040
- --panel-2: oklch(25% 0.016 160);
12041
- --line: oklch(32% 0.015 160);
12042
- --text: oklch(93% 0.012 150);
12043
- --muted: oklch(70% 0.018 160);
12044
- --accent: oklch(74% 0.115 158);
12045
- --warn: oklch(81% 0.11 80);
12046
- --bad: oklch(69% 0.14 25);
12047
- --info: oklch(75% 0.10 240);
12048
- --violet: oklch(73% 0.10 300);
12049
- --sans: 'Space Grotesk', ui-sans-serif, system-ui, -apple-system, sans-serif;
12050
- --mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
12051
- }
12052
- * { box-sizing: border-box; }
12053
- body {
12054
- margin:0; color:var(--text);
12055
- font-family: var(--sans);
12056
- -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
12057
- background:
12058
- radial-gradient(1100px 520px at 82% -12%, oklch(30% 0.04 160 / 0.55), transparent 62%),
12059
- repeating-linear-gradient(0deg, oklch(32% 0.015 160 / 0.16) 0 1px, transparent 1px 34px),
12060
- var(--bg);
12061
- background-attachment: fixed;
12062
- }
12063
- header { height:60px; display:flex; align-items:center; justify-content:space-between; padding:0 22px; border-bottom:1px solid var(--line); background:linear-gradient(180deg, var(--panel-2), var(--panel)); }
12064
- h1 { font-size:17px; margin:0; font-weight:700; letter-spacing:-0.01em; }
12065
- .sub { color:var(--muted); font-size:12px; }
12066
- main { display:grid; grid-template-columns: 320px 1fr; height:calc(100vh - 60px); }
12067
- aside { border-right:1px solid var(--line); padding:16px; overflow:auto; background:var(--panel); }
12068
- section { min-width:0; overflow:auto; padding:16px; }
12069
- label { display:block; color:var(--muted); font-size:12px; margin:10px 0 5px; }
12070
- input, textarea, select { width:100%; border:1px solid var(--line); background:oklch(15% 0.012 160); color:var(--text); border-radius:8px; padding:9px 11px; font:inherit; font-size:13px; transition:border-color .18s cubic-bezier(0.25,1,0.5,1); }
12071
- input:focus, textarea:focus, select:focus { outline:2px solid var(--accent); outline-offset:1px; border-color:transparent; }
12072
- textarea { min-height:82px; resize:vertical; font-family:var(--mono); font-size:12px; }
12073
- button { border:1px solid var(--line); background:var(--panel-2); color:var(--text); border-radius:8px; padding:8px 12px; font:inherit; font-size:12px; cursor:pointer; transition:background .18s cubic-bezier(0.25,1,0.5,1), border-color .18s; }
12074
- button:hover { border-color:var(--muted); background:oklch(29% 0.018 160); }
12075
- button:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
12076
- button:active { transform:translateY(1px); }
12077
- .primary { background:oklch(38% 0.07 158); border-color:oklch(48% 0.09 158); color:oklch(96% 0.02 158); }
12078
- .primary:hover { background:oklch(43% 0.085 158); border-color:var(--accent); }
12079
- .grid { display:grid; grid-template-columns: repeat(var(--board-columns, 6), minmax(160px, 1fr)); gap:12px; align-items:start; }
12080
- .overview { display:grid; grid-template-columns: minmax(260px, 1.4fr) minmax(260px, 1fr); gap:12px; margin-bottom:12px; }
12081
- .goalbox, .chainbox { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:11px; min-height:88px; }
12082
- .goalbox h2, .chainbox h2 { margin:0 0 8px; color:var(--muted); font-size:12px; font-weight:650; }
12083
- .goalitem { font-size:13px; line-height:1.3; margin:5px 0; }
12084
- .chainitem { display:grid; grid-template-columns:72px 1fr; gap:8px; font-size:12px; line-height:1.3; margin:5px 0; color:var(--muted); }
12085
- .chainitem strong { color:var(--text); font-weight:600; }
12086
- .streams { display:grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap:12px; margin-bottom:12px; }
12087
- .stream { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:11px; min-height:126px; }
12088
- .stream h2 { margin:0 0 8px; font-size:12px; color:var(--text); line-height:1.25; font-weight:500; }
12089
- .streambar { display:flex; height:7px; overflow:hidden; border-radius:999px; background:oklch(15% 0.012 160); border:1px solid var(--line); margin:8px 0; }
12090
- .streambar span { display:block; min-width:2px; }
12091
- .seg-open { background:var(--muted); }
12092
- .seg-doing { background:var(--accent); }
12093
- .seg-review { background:var(--warn); }
12094
- .seg-blocked { background:var(--bad); }
12095
- .streamtask { display:grid; grid-template-columns:64px 1fr; gap:8px; color:var(--muted); font-size:11px; line-height:1.25; margin-top:6px; }
12096
- .streamtask strong { color:var(--text); font-weight:550; }
12097
- .col { background:var(--panel); border:1px solid var(--line); border-radius:8px; min-height:160px; overflow:hidden; }
12098
- .col h2 { margin:0; padding:10px 11px; font-size:12px; color:var(--muted); border-bottom:1px solid var(--line); display:flex; justify-content:space-between; }
12099
- .cards { padding:8px; display:flex; flex-direction:column; gap:8px; }
12100
- .card { text-align:left; width:100%; background:var(--panel-2); border:1px solid var(--line); border-radius:8px; padding:9px; transition:transform .18s cubic-bezier(0.25,1,0.5,1), border-color .18s, background .18s; }
12101
- .card:hover { transform:scale(1.02); border-color:var(--muted); background:oklch(28% 0.018 160); }
12102
- .card.active { border-color:var(--accent); box-shadow:0 0 0 1px oklch(74% 0.115 158 / 0.3); }
12103
- .title { font-size:13px; line-height:1.25; }
12104
- .meta { margin-top:6px; color:var(--muted); font-size:11px; display:flex; gap:6px; flex-wrap:wrap; font-family:var(--mono); }
12105
- .pill { border:1px solid var(--line); border-radius:999px; padding:1px 6px; }
12106
- .why { margin-top:7px; color:var(--muted); font-size:11px; line-height:1.25; }
12107
- .plain { margin-top:6px; font-size:11px; line-height:1.35; color:var(--text); }
12108
- .plain div + div { color:var(--muted); }
12109
- .fact { margin:10px 0; background:oklch(15% 0.012 160); border:1px solid var(--line); border-radius:7px; padding:8px; font-size:12px; line-height:1.35; }
12110
- .fact b { color:var(--muted); font-size:11px; display:block; margin-bottom:3px; }
12111
- .room { margin-top:14px; border-top:1px solid var(--line); padding-top:12px; }
12112
- .room h3 { margin:0 0 4px; font-size:14px; }
12113
- .thread { margin:10px 0; display:flex; flex-direction:column; gap:7px; }
12114
- .msg { background:oklch(15% 0.012 160); border:1px solid var(--line); border-radius:7px; padding:8px; font-size:12px; }
12115
- .msg .who { color:var(--muted); font-size:11px; margin-bottom:3px; }
12116
- .actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:10px; }
12117
- .full { grid-column:1 / -1; }
12118
- .empty { color:var(--muted); font-size:12px; padding:10px; }
12119
- /* heartbeat strip */
12120
- .beat { display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); font-family:var(--mono); }
12121
- .beat .dot { width:9px; height:9px; border-radius:50%; background:var(--muted); flex:none; }
12122
- .beat.alive .dot { background:var(--accent); box-shadow:0 0 0 0 oklch(74% 0.115 158 / 0.6); animation:beat 2s cubic-bezier(0.25,1,0.5,1) infinite; }
12123
- .beat.stale .dot { background:var(--bad); }
12124
- .beat b { color:var(--accent); font-weight:600; }
12125
- .beat .warn { color:var(--warn); }
12126
- @keyframes beat { 0%{box-shadow:0 0 0 0 oklch(74% 0.115 158 / 0.5)} 70%{box-shadow:0 0 0 8px oklch(74% 0.115 158 / 0)} 100%{box-shadow:0 0 0 0 oklch(74% 0.115 158 / 0)} }
12127
- @media (prefers-reduced-motion: reduce) { .beat.alive .dot { animation:none; } *, *::before, *::after { transition:none !important; } }
12128
- /* activity feed */
12129
- .activity { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:0; margin-bottom:12px; max-height:46vh; overflow:auto; }
12130
- .activity h2 { margin:0; position:sticky; top:0; background:var(--panel); padding:11px; font-size:12px; color:var(--muted); font-weight:650; border-bottom:1px solid var(--line); z-index:1; }
12131
- .ev { display:grid; grid-template-columns:52px 64px 1fr auto; gap:10px; align-items:baseline; padding:7px 11px; border-bottom:1px solid oklch(28% 0.013 160); font-size:12px; line-height:1.3; font-family:var(--mono); transition:background .15s ease; }
12132
- .ev:last-child { border-bottom:0; }
12133
- .ev:hover { background:oklch(25% 0.016 160); }
12134
- .ev .t { color:var(--muted); font-variant-numeric:tabular-nums; font-size:11px; }
12135
- .ev .src { font-size:11px; border:1px solid var(--line); border-radius:999px; padding:1px 7px; color:var(--muted); text-align:center; }
12136
- .ev .src.pulse { color:var(--accent); border-color:oklch(74% 0.115 158 / 0.45); }
12137
- .ev .src.reward { color:var(--warn); border-color:oklch(81% 0.11 80 / 0.45); }
12138
- .ev .src.xp { color:var(--info); border-color:oklch(75% 0.10 240 / 0.45); }
12139
- .ev .src.mission { color:var(--violet); border-color:oklch(73% 0.10 300 / 0.45); }
12140
- .ev .msg { color:var(--text); min-width:0; }
12141
- .ev .msg .d { color:var(--muted); font-size:11px; }
12142
- .ev.bad .msg { color:var(--bad); }
12143
- .ev .rw { font-variant-numeric:tabular-nums; font-size:11px; color:var(--muted); }
12144
- .ev .rw.pos { color:var(--accent); } .ev .rw.neg { color:var(--bad); }
12145
- @media (max-width: 980px) { main { grid-template-columns:1fr; height:auto; } aside { border-right:0; border-bottom:1px solid var(--line); } .grid, .overview { grid-template-columns:1fr; } }
12146
- </style>
12147
- </head>
12148
- <body>
12149
- <header>
12150
- <div>
12151
- <h1>Atris Task Factory</h1>
12152
- <div class="beat" id="heartbeat"><span class="dot"></span><span>heartbeat: loading…</span></div>
12153
- </div>
12154
- <button id="refresh">Refresh</button>
12155
- </header>
12156
- <main>
12157
- <aside>
12158
- <form id="create">
12159
- <label>New task</label>
12160
- <textarea id="title" placeholder="Need something done..."></textarea>
12161
- <label>Lane</label>
12162
- <input id="tag" value="tasks">
12163
- <button class="primary full" type="submit" style="margin-top:10px;width:100%">Create task</button>
12164
- </form>
12165
- <div class="room" id="room">
12166
- <div class="empty">Select a task to open its room.</div>
12167
- </div>
12168
- </aside>
12169
- <section>
12170
- <div class="overview" id="overview"></div>
12171
- <div class="activity" id="activity"><h2>Live Stream</h2><div class="empty">waiting for the agent…</div></div>
12172
- <div class="streams" id="streams"></div>
12173
- <div class="grid" id="board"></div>
12174
- </section>
12175
- </main>
12176
- <script>
12177
- const columns = [
12178
- ['backlog', 'Backlog'],
12179
- ['open', 'Open'],
12180
- ['doing', 'Doing'],
12181
- ['review', 'Review'],
12182
- ['blocked', 'Blocked'],
12183
- ['done', 'Done']
12184
- ];
12185
- const planTags = new Set(${JSON.stringify(model.planTags)});
12186
- let state = { tasks: [] };
12187
- let selected = null;
12188
- const $ = (id) => document.getElementById(id);
12189
-
12190
- async function api(path, options = {}) {
12191
- const res = await fetch(path, {
12192
- ...options,
12193
- headers: { 'content-type': 'application/json', 'x-atris-token': new URLSearchParams(location.search).get('t') || '', ...(options.headers || {}) }
12194
- });
12195
- const data = await res.json();
12196
- if (!res.ok || data.ok === false) throw new Error(data.detail || data.reason || 'request failed');
12197
- return data;
12198
- }
12199
-
12200
- function taskColumn(task) {
12201
- if (task.status === 'open') {
12202
- const metadata = task.metadata || {};
12203
- const tag = String(task.tag || '').trim().toLowerCase().replace(/\\s+/g, '-');
12204
- const stage = String(metadata.stage || '').trim().toLowerCase().replace(/\\s+/g, '-');
12205
- const planned = planTags.has(tag) || planTags.has(stage) || metadata.verify || metadata.goal || metadata.loop || metadata.cron || metadata.next_run_at;
12206
- return planned ? 'open' : 'backlog';
12207
- }
12208
- if (task.status === 'claimed') return 'doing';
12209
- if (task.status === 'review') return 'review';
12210
- const reviewed = task.latest_event_type === 'reviewed' || !!(task.review && (task.review.reward != null || task.review.proof || task.review.lesson || task.review.next_task));
12211
- if (task.status === 'failed' && reviewed) return 'done';
12212
- if (task.status === 'failed') return 'blocked';
12213
- if (task.status === 'done' && !reviewed) return 'review';
12214
- return 'done';
12215
- }
12216
-
12217
- function esc(s) {
12218
- return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
12219
- }
12220
-
12221
- function fmtTime(ms) {
12222
- try { return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); }
12223
- catch (e) { return ''; }
12224
- }
12225
-
12226
- function renderHeartbeat(hb) {
12227
- const el = $('heartbeat');
12228
- el.className = 'beat ' + (hb.state || 'idle');
12229
- if (hb.state === 'stale') {
12230
- el.innerHTML = '<span class="dot"></span><span class="warn">stale</span><span>· ' + esc(hb.stale_reason || '') + ' · ' + (hb.total_ticks || 0) + ' ticks</span>';
12231
- return;
12232
- }
12233
- if (!hb.total_ticks) {
12234
- el.innerHTML = '<span class="dot"></span><span>idle · no ticks yet · run: atris pulse tick</span>';
12235
- return;
12236
- }
12237
- const age = hb.last_tick_age_min == null ? '' : (hb.last_tick_age_min === 0 ? 'just now' : hb.last_tick_age_min + 'm ago');
12238
- el.innerHTML = '<span class="dot"></span><b>alive</b>'
12239
- + '<span>· last tick ' + age + '</span>'
12240
- + (hb.last_what ? '<span>· ' + esc(hb.last_what) + '</span>' : '')
12241
- + '<span>· ' + hb.total_ticks + ' ticks, reward ' + (hb.reward_sum || 0) + '</span>';
12242
- }
12243
-
12244
- function renderActivity(events) {
12245
- const el = $('activity');
12246
- if (!events.length) { el.innerHTML = '<h2>Live Stream</h2><div class="empty">no activity yet, fire a tick: atris pulse tick</div>'; return; }
12247
- let html = '<h2>Live Stream · ' + events.length + ' events</h2>';
12248
- for (const e of events) {
12249
- const rwCls = e.reward == null ? '' : (e.reward > 0 ? 'pos' : (e.reward < 0 ? 'neg' : ''));
12250
- const rw = e.reward == null ? '' : (e.reward > 0 ? '+' + e.reward : '' + e.reward);
12251
- html += '<div class="ev ' + (e.status === 'bad' ? 'bad' : '') + '">'
12252
- + '<span class="t">' + (e.ms ? fmtTime(e.ms) : '') + '</span>'
12253
- + '<span class="src ' + esc(e.source) + '">' + esc(e.source) + '</span>'
12254
- + '<span class="msg">' + esc(e.title) + (e.detail ? ' <span class="d">' + esc(e.detail) + '</span>' : '') + '</span>'
12255
- + '<span class="rw ' + rwCls + '">' + rw + '</span>'
12256
- + '</div>';
12257
- }
12258
- el.innerHTML = html;
12259
- }
12260
-
12261
- async function loadStream() {
12262
- const data = await api('/api/stream');
12263
- renderHeartbeat(data.heartbeat || {});
12264
- renderActivity(data.events || []);
12265
- }
12266
-
12267
- async function load() {
12268
- const data = await api('/api/tasks');
12269
- state = data.projection;
12270
- render();
12271
- loadStream().catch(() => {});
12272
- }
12273
-
12274
- function render() {
12275
- renderOverview();
12276
- renderStreams();
12277
- const board = $('board');
12278
- board.style.setProperty('--board-columns', columns.length);
12279
- board.innerHTML = '';
12280
- for (const [key, label] of columns) {
12281
- const tasks = state.tasks.filter((task) => taskColumn(task) === key);
12282
- const col = document.createElement('div');
12283
- col.className = 'col';
12284
- col.innerHTML = '<h2><span>' + label + '</span><span>' + tasks.length + '</span></h2><div class="cards"></div>';
12285
- const cards = col.querySelector('.cards');
12286
- if (!tasks.length) cards.innerHTML = '<div class="empty">No tasks</div>';
12287
- for (const task of tasks) cards.appendChild(card(task));
12288
- board.appendChild(col);
12289
- }
12290
- renderRoom();
12291
- }
12292
-
12293
- function taskById(id) {
12294
- return state.tasks.find((task) => task.id === id) || null;
12295
- }
12296
-
12297
- function renderOverview() {
12298
- const active = state.tasks.filter((task) => task.status !== 'done');
12299
- const reviewed = state.tasks.filter((task) => task.latest_event_type === 'reviewed');
12300
- const goals = state.goals && state.goals.items || [];
12301
- const goalHtml = goals.length
12302
- ? goals.slice(0, 4).map((goal) => '<div class="goalitem"></div>').join('')
12303
- : '<div class="empty">No atris/goals.md found. Add goals to give tasks a north star.</div>';
12304
- const latest = reviewed.slice(0, 3);
12305
- const chainHtml = latest.length
12306
- ? latest.map((task) => '<div class="chainitem"><span>' + (task.display_id || task.id.slice(0, 8)) + '</span><strong></strong></div>').join('')
12307
- : '<div class="empty">Complete a task with proof to start the chain.</div>';
12308
- $('overview').innerHTML = [
12309
- '<div class="goalbox"><h2>Goals</h2>' + goalHtml + '</div>',
12310
- '<div class="chainbox"><h2>Compounding Chain</h2><div class="chainitem"><span>active</span><strong>' + active.length + ' open loops</strong></div>' + chainHtml + '</div>'
12311
- ].join('');
12312
- $('overview').querySelectorAll('.goalitem').forEach((el, i) => { el.textContent = goals[i]; });
12313
- $('overview').querySelectorAll('.chainbox .chainitem strong').forEach((el, i) => {
12314
- if (i === 0) return;
12315
- const task = latest[i - 1];
12316
- const what = task.explanation && task.explanation.what_changes || task.title;
12317
- el.textContent = (task.review && task.review.next_task) ? what + ' -> ' + task.review.next_task : what;
12318
- });
12319
- }
12320
-
12321
- function renderStreams() {
12322
- const streams = (state.streams || []).filter((stream) => stream.active_count || stream.done_count).slice(0, 6);
12323
- const root = $('streams');
12324
- if (!streams.length) {
12325
- root.innerHTML = '';
12326
- return;
12327
- }
12328
- root.innerHTML = streams.map((stream) => {
12329
- const total = Math.max(1, stream.open_count + stream.doing_count + stream.review_count + stream.blocked_count);
12330
- const widths = {
12331
- open: Math.max(0, Math.round(stream.open_count / total * 100)),
12332
- doing: Math.max(0, Math.round(stream.doing_count / total * 100)),
12333
- review: Math.max(0, Math.round(stream.review_count / total * 100)),
12334
- blocked: Math.max(0, Math.round(stream.blocked_count / total * 100))
12335
- };
12336
- const tasks = stream.tasks.filter((task) => task.status !== 'done').slice(0, 3);
12337
- const taskHtml = tasks.length
12338
- ? tasks.map((task) => '<div class="streamtask"><span>' + (task.display_id || task.id.slice(0, 8)) + '</span><strong></strong></div>').join('')
12339
- : '<div class="empty">No active tasks in this stream.</div>';
12340
- return [
12341
- '<div class="stream">',
12342
- '<h2></h2>',
12343
- '<div class="meta"><span class="pill">' + stream.active_count + ' active</span><span class="pill">' + stream.done_count + ' done</span></div>',
12344
- '<div class="streambar"><span class="seg-open" style="width:' + widths.open + '%"></span><span class="seg-doing" style="width:' + widths.doing + '%"></span><span class="seg-review" style="width:' + widths.review + '%"></span><span class="seg-blocked" style="width:' + widths.blocked + '%"></span></div>',
12345
- taskHtml,
12346
- '</div>'
12347
- ].join('');
12348
- }).join('');
12349
- root.querySelectorAll('.stream h2').forEach((el, i) => { el.textContent = streams[i].objective; });
12350
- root.querySelectorAll('.stream').forEach((streamEl, i) => {
12351
- const tasks = streams[i].tasks.filter((task) => task.status !== 'done').slice(0, 3);
12352
- streamEl.querySelectorAll('.streamtask strong').forEach((el, idx) => {
12353
- el.textContent = tasks[idx].explanation && tasks[idx].explanation.what_changes || tasks[idx].title;
12354
- });
12355
- });
12356
- }
12357
-
12358
- function card(task) {
12359
- const btn = document.createElement('button');
12360
- btn.className = 'card' + (selected === task.id ? ' active' : '');
12361
- btn.onclick = () => { selected = task.id; render(); };
12362
- const owner = task.claimed_by ? '@' + task.claimed_by : 'unowned';
12363
- // Plain layer first: what changes, why it matters, what done looks like.
12364
- // Ids, owner, and version stay below it as detail.
12365
- btn.innerHTML = '<div class="title"></div><div class="plain"></div>'
12366
- + '<div class="meta"><span class="pill"></span><span class="pill"></span><span class="pill"></span></div><div class="why"></div>';
12367
- const plain = task.explanation || {};
12368
- btn.querySelector('.title').textContent = plain.what_changes || task.title;
12369
- const plainRows = [];
12370
- plainRows.push('Why it matters: ' + (plain.why_it_matters || 'No reason recorded yet.'));
12371
- plainRows.push('Done looks like: ' + (plain.done_looks_like || 'Proof of the work, then review.'));
12372
- const plainBox = btn.querySelector('.plain');
12373
- for (const row of plainRows) {
12374
- const line = document.createElement('div');
12375
- line.textContent = row;
12376
- plainBox.appendChild(line);
12377
- }
12378
- const pills = btn.querySelectorAll('.pill');
12379
- pills[0].textContent = task.display_id || task.id.slice(0, 8);
12380
- pills[1].textContent = owner;
12381
- pills[2].textContent = 'v' + task.current_version;
12382
- btn.querySelector('.why').textContent = 'Technical details: ' + task.title;
12383
- return btn;
12384
- }
12385
-
12386
- function renderRoom() {
12387
- const task = state.tasks.find((t) => t.id === selected);
12388
- const room = $('room');
12389
- if (!task) {
12390
- room.innerHTML = '<div class="empty">Select a task to open its room.</div>';
12391
- return;
12392
- }
12393
- const messages = task.messages.map((m) => '<div class="msg"><div class="who">' + (m.actor || 'unknown') + ' / v' + m.version + '</div><div></div></div>').join('');
12394
- const parent = task.lineage && task.lineage.parent_title ? task.lineage.parent_title : 'none';
12395
- const children = task.lineage && task.lineage.child_titles && task.lineage.child_titles.length ? task.lineage.child_titles.join(' | ') : (task.review && task.review.next_task || 'none yet');
12396
- room.innerHTML = [
12397
- '<h3></h3>',
12398
- '<div class="meta"><span class="pill">' + task.status + '</span><span class="pill">' + (task.claimed_by || 'unowned') + '</span><span class="pill">v' + task.current_version + '</span></div>',
12399
- '<div class="fact"><b>What changes</b><div id="taskWhatChanges"></div></div>',
12400
- '<div class="fact"><b>Why it matters</b><div id="taskWhyItMatters"></div></div>',
12401
- '<div class="fact"><b>Done looks like</b><div id="taskDoneLooksLike"></div></div>',
12402
- '<div class="fact"><b>Approval</b><div id="taskApproval"></div></div>',
12403
- '<div class="fact"><b>Technical details</b><div id="taskTechnicalDetails"></div></div>',
12404
- '<div class="fact"><b>Goal</b><div id="taskGoal"></div></div>',
12405
- '<div class="fact"><b>Lineage</b><div id="taskLineage"></div></div>',
12406
- '<div class="fact"><b>Result</b><div id="taskHappened"></div></div>',
12407
- '<div class="fact"><b>How I checked</b><div id="taskChecked"></div></div>',
12408
- '<div class="fact"><b>What I tested</b><div id="taskTested"></div></div>',
12409
- '<div class="fact"><b>Decision</b><div id="taskDecision"></div></div>',
12410
- '<div class="fact"><b>Proof / lesson</b><div id="taskProof"></div></div>',
12411
- '<div class="thread">' + (messages || '<div class="empty">No thread yet.</div>') + '</div>',
12412
- '<label>Add context</label><textarea id="note" placeholder="Decision, blocker, context, update..."></textarea>',
12413
- '<label>Proof</label><input id="proof" placeholder="npm test, PR link, screenshot, blocked reason...">',
12414
- '<label>Lesson</label><textarea id="lesson" placeholder="What did this task teach us?"></textarea>',
12415
- '<label>Next task</label><input id="nextTask" placeholder="Optional next sharper task">',
12416
- '<div class="actions"><button id="claim">Claim</button><button id="saveNote">Say</button><button id="requestChange">Ask for a change</button><button id="finish" class="primary full"></button></div>'
12417
- ].join('');
12418
- const plain = task.explanation || {};
12419
- const approval = task.approval || {};
12420
- room.querySelector('h3').textContent = plain.what_changes || task.title;
12421
- $('taskWhatChanges').textContent = plain.what_changes || task.title;
12422
- $('taskWhyItMatters').textContent = plain.why_it_matters || 'No reason recorded yet.';
12423
- $('taskDoneLooksLike').textContent = plain.done_looks_like || 'Proof of the work, then review.';
12424
- $('taskApproval').textContent = approval.approve && approval.approve.enabled
12425
- ? approval.question
12426
- : (approval.approve && approval.approve.blocked_reason) || 'Nothing to approve yet.';
12427
- $('taskTechnicalDetails').textContent = task.title;
12428
- $('taskGoal').textContent = task.objective || 'No matching goal yet.';
12429
- $('taskLineage').textContent = 'parent: ' + parent + ' / next: ' + children;
12430
- const result = task.review && task.review.result || {};
12431
- const landing = task.review && task.review.landing || {
12432
- happened: result.changed,
12433
- checked: result.checked,
12434
- tested: task.review && task.review.proof ? 'Proof is attached below.' : '',
12435
- decision: result.accept,
12436
- };
12437
- $('taskHappened').textContent = landing.happened || (task.review && task.review.summary) || 'No review result yet.';
12438
- $('taskChecked').textContent = landing.checked || 'No check yet.';
12439
- $('taskTested').textContent = landing.tested || 'No test recorded yet.';
12440
- $('taskDecision').textContent = landing.decision || 'No accept action yet.';
12441
- $('taskProof').textContent = task.review && (task.review.proof || task.review.lesson)
12442
- ? ((task.review.proof || 'no proof') + ' / ' + (task.review.lesson || 'no lesson'))
12443
- : 'No proof yet.';
12444
- room.querySelectorAll('.msg div:last-child').forEach((el, i) => { el.textContent = task.messages[i].content; });
12445
- const canApprove = Boolean(approval.approve && approval.approve.enabled);
12446
- const planned = task.metadata && task.metadata.stage === 'plan';
12447
- $('finish').textContent = canApprove
12448
- ? approval.approve.label
12449
- : task.status === 'claimed' ? 'Move to Review' : 'Approval not ready';
12450
- $('finish').disabled = !canApprove && task.status !== 'claimed';
12451
- $('claim').onclick = () => mutate('/api/tasks/' + task.id + '/claim', { owner: 'operator' });
12452
- // Ask for a change runs the existing revise gate; it never lands work.
12453
- $('requestChange').disabled = !(approval.request_change && approval.request_change.enabled);
12454
- $('requestChange').textContent = approval.request_change && approval.request_change.label || 'Ask for a change';
12455
- $('requestChange').onclick = () => {
12456
- const note = $('note').value.trim();
12457
- if (!note) { $('note').focus(); return; }
12458
- const action = task.status === 'review' ? 'revise' : planned ? 'backlog' : 'message';
12459
- const body = action === 'message' ? { actor: 'operator', content: note } : { actor: 'operator', note: note, reason: note };
12460
- mutate('/api/tasks/' + task.id + '/' + action, body);
12461
- };
12462
- $('saveNote').onclick = () => mutate('/api/tasks/' + task.id + '/message', { actor: 'operator', content: $('note').value });
12463
- $('finish').onclick = () => {
12464
- const proof = $('proof').value.trim();
12465
- const lesson = $('lesson').value.trim();
12466
- const nextTask = $('nextTask').value.trim();
12467
- const payload = { actor: 'operator' };
12468
- if (proof) payload.proof = proof;
12469
- if (lesson) payload.lesson = lesson;
12470
- if (nextTask) payload.next = nextTask;
12471
- if (task.status === 'review' && canApprove) {
12472
- payload.createNext = Boolean(nextTask || (task.review && task.review.next_task));
12473
- mutate('/api/tasks/' + task.id + '/accept', payload);
12474
- } else if (planned && canApprove) {
12475
- mutate('/api/tasks/' + task.id + '/do', { actor: task.metadata.assigned_to || task.claimed_by || 'operator', first_move: task.metadata.first_move });
12476
- } else {
12477
- mutate('/api/tasks/' + task.id + '/ready', payload);
12478
- }
12479
- };
12480
- }
12481
-
12482
- async function mutate(path, body) {
12483
- await api(path, { method: 'POST', body: JSON.stringify(body) });
12484
- await load();
12485
- }
12486
-
12487
- $('create').onsubmit = async (e) => {
12488
- e.preventDefault();
12489
- const title = $('title').value.trim();
12490
- if (!title) return;
12491
- const data = await api('/api/tasks', { method: 'POST', body: JSON.stringify({ title, tag: $('tag').value || 'tasks' }) });
12492
- selected = data.task_id;
12493
- $('title').value = '';
12494
- await load();
12495
- };
12496
- $('refresh').onclick = load;
12497
- load();
12498
- setInterval(load, 2500);
12499
- </script>
12500
- </body>
12501
- </html>`;
12502
- }
12503
-
12504
- function taskBoardHtml() {
12505
- return taskBoardTemplate(taskBoardViewModel());
12506
- }
12507
-
12508
- function readJsonBody(req) {
12509
- if (String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase() !== 'application/json') {
12510
- return Promise.reject(Object.assign(new Error('json required'), { statusCode: 415 }));
12511
- }
12512
- return new Promise((resolve, reject) => {
12513
- let size = 0;
12514
- const chunks = [];
12515
- req.on('data', chunk => {
12516
- if (size > 1_000_000) return;
12517
- size += chunk.length;
12518
- if (size > 1_000_000) {
12519
- reject(Object.assign(new Error('body too large'), { statusCode: 413 }));
12520
- return;
12521
- }
12522
- chunks.push(chunk);
12523
- });
12524
- req.on('end', () => {
12525
- if (size > 1_000_000) return;
12526
- const body = Buffer.concat(chunks).toString('utf8');
12527
- if (!body.trim()) return resolve({});
12528
- try {
12529
- const value = JSON.parse(body);
12530
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('object required');
12531
- resolve(value);
12532
- } catch (e) { reject(Object.assign(e, { statusCode: 400 })); }
12533
- });
12534
- req.on('error', reject);
12535
- });
12536
- }
12537
-
12538
- function sendJson(res, status, value, headers = {}) {
12539
- res.writeHead(status, {
12540
- 'content-type': 'application/json; charset=utf-8',
12541
- 'cache-control': 'no-store',
12542
- 'referrer-policy': 'no-referrer',
12543
- 'x-content-type-options': 'nosniff',
12544
- ...headers,
12545
- });
12546
- res.end(JSON.stringify(value, null, 2));
12547
- }
12548
-
12549
- function sendHtml(res, value) {
12550
- res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer', 'x-content-type-options': 'nosniff' });
12551
- res.end(value);
12552
- }
12553
-
12554
11332
  function taskApiQueueOptions(url) {
12555
11333
  const limitParam = url.searchParams.get('limit');
12556
11334
  const limit = limitParam ? Number(limitParam) : 8;