superdev-cli 0.1.2 → 0.2.1
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 +1 -1
- package/src/cli.mjs +148 -24
- package/src/db/migrations/013_question_options.sql +28 -0
- package/src/init/index.mjs +9 -0
- package/src/init/questions.mjs +115 -5
- package/src/product/authoring.mjs +65 -0
- package/src/runtime/hooks.mjs +84 -5
- package/src/service/assets/control-center.html +62 -62
- package/src/service/assets/control-center.manifest.json +4 -4
- package/src/service/manage.mjs +42 -5
- 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
|
}
|
package/src/service/manage.mjs
CHANGED
|
@@ -135,6 +135,19 @@ export function probeHealth(host, port, timeout = PROBE_TIMEOUT_MS) {
|
|
|
135
135
|
});
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* A path a reader can retype, shortened at home so a long absolute path does not
|
|
140
|
+
* bury the part that identifies the project.
|
|
141
|
+
*
|
|
142
|
+
* Every message about a port used to describe it without saying whose it was,
|
|
143
|
+
* even though the health probe had already been handed the answer, so finding out
|
|
144
|
+
* meant opening candidate directories one at a time.
|
|
145
|
+
*/
|
|
146
|
+
export function displayRoot(path) {
|
|
147
|
+
const home = homedir();
|
|
148
|
+
return home && path.startsWith(`${home}/`) ? `~/${path.slice(home.length + 1)}` : path;
|
|
149
|
+
}
|
|
150
|
+
|
|
138
151
|
/**
|
|
139
152
|
* What is actually true for this project right now. Distinguishes a service we
|
|
140
153
|
* manage from a stale lock, from a start in progress, from someone else holding
|
|
@@ -160,14 +173,31 @@ export async function inspect(root, { port = null, host = DEFAULTS.host } = {})
|
|
|
160
173
|
};
|
|
161
174
|
}
|
|
162
175
|
if (health) {
|
|
176
|
+
// Not running, and the default port happens to be busy. Those are two
|
|
177
|
+
// separate facts and only the first one is about this project.
|
|
178
|
+
//
|
|
179
|
+
// This used to report `foreign`, which made both commands lie. `start`
|
|
180
|
+
// never blocked on it: the server scans forward from the requested port on
|
|
181
|
+
// EADDRINUSE, so a second project quietly lands on the next free one, and
|
|
182
|
+
// the message told the reader to do something the tool was already doing.
|
|
183
|
+
// `stop` did worse. On a project with nothing running it refused, and told
|
|
184
|
+
// the reader to go and stop a different project, which they have no reason
|
|
185
|
+
// to touch.
|
|
186
|
+
//
|
|
187
|
+
// Reserving `foreign` for the case that is genuinely somebody else's
|
|
188
|
+
// service in our place is what makes the word mean something.
|
|
163
189
|
return {
|
|
164
|
-
state: "
|
|
190
|
+
state: "stopped",
|
|
165
191
|
managed: false,
|
|
166
192
|
lock: null,
|
|
167
193
|
health,
|
|
168
|
-
port:
|
|
194
|
+
port: null,
|
|
169
195
|
host,
|
|
170
|
-
|
|
196
|
+
portInUse: candidate,
|
|
197
|
+
heldBy: health.projectRoot ?? null,
|
|
198
|
+
explanation: `Nothing is running for this project. Port ${candidate} is held by ${
|
|
199
|
+
health.projectRoot ? `the Superdev service for ${displayRoot(health.projectRoot)}` : "another Superdev service"
|
|
200
|
+
}, so starting this one will use the next free port instead. Pass --port <number> to choose.`,
|
|
171
201
|
};
|
|
172
202
|
}
|
|
173
203
|
return {
|
|
@@ -217,7 +247,9 @@ export async function inspect(root, { port = null, host = DEFAULTS.host } = {})
|
|
|
217
247
|
health,
|
|
218
248
|
port: lock.port,
|
|
219
249
|
host: lock.host ?? host,
|
|
220
|
-
explanation: `Port ${lock.port} is answering, but it is not the service this lock file describes
|
|
250
|
+
explanation: `Port ${lock.port} is answering, but it is not the service this lock file describes${
|
|
251
|
+
health.projectRoot ? `: it belongs to ${displayRoot(health.projectRoot)}` : ""
|
|
252
|
+
}. This project's service is gone and something else took its port. Run \`superdev start --apply\` to bring this one back on a free port.`,
|
|
221
253
|
};
|
|
222
254
|
}
|
|
223
255
|
return {
|
|
@@ -335,7 +367,12 @@ export async function startService(root, options = {}) {
|
|
|
335
367
|
);
|
|
336
368
|
if (!held) {
|
|
337
369
|
if (view.state === "foreign") {
|
|
338
|
-
throw new ServiceError(E.FOREIGN, view.explanation, {
|
|
370
|
+
throw new ServiceError(E.FOREIGN, view.explanation, {
|
|
371
|
+
port: view.port,
|
|
372
|
+
// The caller cannot act on a refusal whose subject it cannot see, and the
|
|
373
|
+
// probe already knows it. `superdev services` prints it; a script reads it.
|
|
374
|
+
heldBy: view.health?.projectRoot ?? null,
|
|
375
|
+
});
|
|
339
376
|
}
|
|
340
377
|
return { ...summarize(root, view), alreadyRunning: true };
|
|
341
378
|
}
|
|
@@ -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) => {
|