superdev-cli 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "source": "ui/",
3
- "sourceHash": "57c0be5885fc42ef1aa0df9442a8128372ab0db907bc347bfcc7fe02b6e0f462",
4
- "sourceFileCount": 84,
5
- "bundleHash": "59cd09db3329c8937d2dce5e9c210df58feb9bcfb9d8874fb80cd54c1e0ff682",
6
- "bundleBytes": 2002963
3
+ "sourceHash": "f542076f8b1126465fb2eed5f508a4aeacb00acee8a6b18541afd82081c9f8d7",
4
+ "sourceFileCount": 85,
5
+ "bundleHash": "6af5d685e8252a7259e163159fd31bc980e74268d645c4a3317c398920407f4c",
6
+ "bundleBytes": 2007051
7
7
  }
@@ -317,12 +317,40 @@ function dependencyIds(payload) {
317
317
 
318
318
  // ----------------------------------------------------------------- questions
319
319
 
320
+ /** The question's own options. The row is read unhydrated, so the JSON is text. */
321
+ function offeredOptions(question) {
322
+ try {
323
+ const parsed = JSON.parse(question.alternatives_json ?? "[]");
324
+ return Array.isArray(parsed) ? parsed : [];
325
+ } catch {
326
+ return [];
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Answer a question, from its options or in the reader's own words.
332
+ *
333
+ * `selected` carries the options chosen; `answer` carries typed text. Either is
334
+ * enough, both together is fine (an option plus a qualification), neither is
335
+ * refused. A question that takes one answer refuses several, because a record
336
+ * saying "a single service, several services" says nothing.
337
+ *
338
+ * The work is delegated to the engine rather than repeated here. This handler
339
+ * used to write the answer and set the status itself, which looked equivalent and
340
+ * was not: the engine also settles the capability area the question belongs to,
341
+ * records a reversible assumption when the answer is "I do not know", and writes a
342
+ * project-level answer through to the field it exists to fill. So a question
343
+ * answered in the control centre left its readiness area awaiting a decision,
344
+ * while the same question answered from the command line settled it. One answer
345
+ * path is the fix; two that agree by coincidence is the defect.
346
+ */
320
347
  async function answerQuestion(root, payload) {
321
348
  const questionId = identifier(payload, "questionId", { required: true });
322
349
  const status = oneOf(payload, "status", ["answered", "deferred", "withdrawn"], {
323
350
  fallback: "answered",
324
351
  });
325
- const answer = text(payload, "answer", { required: status === "answered" });
352
+ const selected = list(payload, "selected") ?? [];
353
+ const typed = text(payload, "answer", { required: status === "answered" && !selected.length });
326
354
  const deferralReason = text(payload, "deferralReason", {
327
355
  required: status === "deferred",
328
356
  max: LIMIT.prose,
@@ -330,40 +358,60 @@ async function answerQuestion(root, payload) {
330
358
  const answeredBy = text(payload, "answeredBy", { max: LIMIT.actor });
331
359
  const actor = actorOf(payload);
332
360
 
333
- return mutate(root, async (db) => {
334
- const question = await db.get("SELECT * FROM questions WHERE id = ?", questionId);
335
- if (!question) throw new MutationError(E.NOT_FOUND, `There is no question ${questionId}.`);
336
- if (question.status !== "open" && question.status !== "deferred") {
361
+ const question = await query(root, (db) => db.get("SELECT * FROM questions WHERE id = ?", questionId));
362
+ if (!question) throw new MutationError(E.NOT_FOUND, `There is no question ${questionId}.`);
363
+ if (question.status !== "open" && question.status !== "deferred") {
364
+ throw new MutationError(
365
+ E.INVALID_TRANSITION,
366
+ `${questionId} is already ${question.status} and is not waiting on an answer.`,
367
+ );
368
+ }
369
+
370
+ if (selected.length) {
371
+ const offered = offeredOptions(question);
372
+ const unknown = selected.filter((choice) => !offered.includes(choice));
373
+ if (unknown.length) {
337
374
  throw new MutationError(
338
- E.INVALID_TRANSITION,
339
- `${questionId} is already ${question.status} and is not waiting on an answer.`,
375
+ E.INVALID_PAYLOAD,
376
+ `${questionId} does not offer ${unknown.map((u) => JSON.stringify(u)).join(", ")}. Choose from its own options, or type an answer instead.`,
340
377
  );
341
378
  }
342
- const project = await currentProject(db);
343
- await patch(
344
- db,
345
- "question",
346
- questionId,
347
- question.version,
348
- pick(
349
- {
350
- answer,
351
- answered_by: answeredBy ?? actor,
352
- answered_at: new Date().toISOString(),
353
- deferral_reason: deferralReason,
354
- },
355
- ["answer", "answered_by", "answered_at", "deferral_reason"],
356
- ),
357
- { projectId: project.id, actor, activity: false },
358
- );
359
- return setStatus(db, "question", questionId, status, {
360
- projectId: project.id,
361
- actor,
362
- note: answer ?? deferralReason,
363
- activityType: "specification_changed",
364
- activitySummary: `${questionId} ${status}: ${(answer ?? deferralReason ?? "").slice(0, 160)}`,
379
+ if (question.select_mode !== "many" && selected.length > 1) {
380
+ throw new MutationError(
381
+ E.INVALID_PAYLOAD,
382
+ `${questionId} takes one answer, and ${selected.length} were chosen. Pick one, or type an answer that says what you mean.`,
383
+ );
384
+ }
385
+ }
386
+
387
+ if (status === "withdrawn") {
388
+ return mutate(root, async (db) => {
389
+ const project = await currentProject(db);
390
+ return setStatus(db, "question", questionId, "withdrawn", {
391
+ projectId: project.id,
392
+ actor,
393
+ note: typed ?? deferralReason,
394
+ activityType: "specification_changed",
395
+ activitySummary: `${questionId} withdrawn: ${(typed ?? deferralReason ?? "no reason given").slice(0, 160)}`,
396
+ });
365
397
  });
398
+ }
399
+
400
+ // The chosen options and the typed words become one sentence, in that order,
401
+ // because the options are the shared vocabulary and the typing qualifies them.
402
+ const answer = [selected.join("; "), typed].filter(Boolean).join(". ") || null;
403
+ const { answerQuestion: record } = await import("../init/questions.mjs");
404
+ const outcome = await record(root, questionId, {
405
+ answer: status === "deferred" ? null : answer,
406
+ // Kept apart from the composed answer so a question that writes through to a
407
+ // project field gets the sentence somebody wrote, not the option they picked.
408
+ inOwnWords: typed,
409
+ unknown: status === "deferred",
410
+ revisitTrigger: status === "deferred" ? deferralReason : null,
411
+ actor,
412
+ answeredBy: answeredBy ?? actor,
366
413
  });
414
+ return { id: questionId, status, answer, selected, outcome };
367
415
  }
368
416
 
369
417
  // ----------------------------------------------------------------- decisions
@@ -129,6 +129,13 @@ async function release(db, task, actor, note) {
129
129
  summary: note ?? `Assignment on ${task.id} released.`,
130
130
  metadata: { assignment: assignment.id },
131
131
  });
132
+ // Every path that ends a claim runs through here: releasing it, completing it,
133
+ // cancelling it. So this is the one place that has to stop pointing a session at
134
+ // a task it no longer holds, and only the session that held it is cleared.
135
+ await db.run(
136
+ "UPDATE work_sessions SET active_task_id = NULL WHERE project_id = ? AND active_task_id = ?",
137
+ task.project_id, task.id,
138
+ );
132
139
  return assignment;
133
140
  }
134
141
 
@@ -382,10 +389,48 @@ export async function claimTask(root, taskId, who = {}) {
382
389
  throw new TaskError(E.ALREADY_CLAIMED,
383
390
  `${taskId} was claimed by ${await holderOf(db, now)} a moment before this claim. Pick up another task.`);
384
391
  }
392
+ // The session is pointed at the task it just claimed.
393
+ //
394
+ // Claiming recorded an assignment and nothing else, so work_sessions.active_task_id
395
+ // stayed null on every session on every project. The session-start hook reads
396
+ // exactly that field to decide whether work is tracked, so it recorded an
397
+ // "untracked work" marker on every file edit, including edits made under a
398
+ // task that was claimed and in progress. The readiness report then raised its
399
+ // only high-severity warning, "changes were made while no task was claimed",
400
+ // against work that was properly tracked. A warning that fires when the rule
401
+ // was followed teaches the reader to stop reading warnings, and this is the
402
+ // one that means the record has fallen behind.
403
+ //
404
+ // Written here rather than in each caller, because the control centre claims
405
+ // tasks too and the field has to mean the same thing whichever surface moved it.
406
+ await pointSessionAt(db, task.project_id, sessionId, taskId);
407
+
385
408
  return db.get("SELECT * FROM tasks WHERE id = ?", taskId);
386
409
  });
387
410
  }
388
411
 
412
+ /**
413
+ * Point a session at the task it is working on, or at nothing.
414
+ *
415
+ * The session is the one named, or the most recently active one on this machine,
416
+ * because a claim from the command line does not carry a session identifier and
417
+ * the hook that reads this field cannot ask.
418
+ */
419
+ async function pointSessionAt(db, projectId, sessionId, taskId) {
420
+ const session = sessionId
421
+ ? await db.get("SELECT id FROM work_sessions WHERE id = ?", sessionId)
422
+ : await db.get(
423
+ `SELECT id FROM work_sessions
424
+ WHERE project_id = ? AND ended_at IS NULL
425
+ ORDER BY last_activity_at DESC, started_at DESC LIMIT 1`, projectId);
426
+ if (!session) return null;
427
+ await db.run(
428
+ "UPDATE work_sessions SET active_task_id = ?, last_activity_at = ? WHERE id = ?",
429
+ taskId, nowIso(), session.id,
430
+ );
431
+ return session.id;
432
+ }
433
+
389
434
  /** Hand a task back. The task keeps its status; only the claim ends. */
390
435
  export async function releaseTask(root, taskId, { actor = "superdev", reason = null } = {}) {
391
436
  return mutate(root, async (db) => {