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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +2 -2
- package/package.json +2 -1
- package/scripts/providers/adapter.mjs +196 -0
- package/scripts/providers/detect.mjs +519 -0
- package/scripts/providers/registry.mjs +248 -0
- package/scripts/validate/packaging.mjs +116 -0
- package/scripts/validate/validate-all.mjs +2 -1
- package/skills/docs/scripts/profile-detect.mjs +56 -2
- package/src/cli/product-map.mjs +10 -2
- package/src/cli/render.mjs +11 -3
- package/src/cli.mjs +540 -33
- package/src/db/migrations/013_question_options.sql +28 -0
- package/src/docs/proposals.mjs +19 -1
- package/src/init/discovery.mjs +46 -0
- package/src/init/index.mjs +142 -22
- package/src/init/questions.mjs +115 -5
- package/src/product/authoring.mjs +649 -0
- package/src/service/assets/control-center.html +62 -62
- package/src/service/assets/control-center.manifest.json +4 -4
- package/src/service/mutations.mjs +78 -30
- package/src/tasks/lifecycle.mjs +45 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"source": "ui/",
|
|
3
|
-
"sourceHash": "
|
|
4
|
-
"sourceFileCount":
|
|
5
|
-
"bundleHash": "
|
|
6
|
-
"bundleBytes":
|
|
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
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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.
|
|
339
|
-
`${questionId}
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
package/src/tasks/lifecycle.mjs
CHANGED
|
@@ -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) => {
|