@kynetic-ai/spec 0.9.0 → 0.10.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/README.md +2 -1
- package/dist/acp/client.d.ts +13 -1
- package/dist/acp/client.d.ts.map +1 -1
- package/dist/acp/client.js +17 -2
- package/dist/acp/client.js.map +1 -1
- package/dist/acp/framing.d.ts +12 -1
- package/dist/acp/framing.d.ts.map +1 -1
- package/dist/acp/framing.js +27 -4
- package/dist/acp/framing.js.map +1 -1
- package/dist/agent-runtime/dispatch.d.ts +261 -0
- package/dist/agent-runtime/dispatch.d.ts.map +1 -0
- package/dist/agent-runtime/dispatch.js +791 -0
- package/dist/agent-runtime/dispatch.js.map +1 -0
- package/dist/agent-runtime/index.d.ts +11 -0
- package/dist/agent-runtime/index.d.ts.map +1 -0
- package/dist/agent-runtime/index.js +11 -0
- package/dist/agent-runtime/index.js.map +1 -0
- package/dist/agent-runtime/invocation.d.ts +86 -0
- package/dist/agent-runtime/invocation.d.ts.map +1 -0
- package/dist/agent-runtime/invocation.js +442 -0
- package/dist/agent-runtime/invocation.js.map +1 -0
- package/dist/agent-runtime/prompts.d.ts +50 -0
- package/dist/agent-runtime/prompts.d.ts.map +1 -0
- package/dist/agent-runtime/prompts.js +108 -0
- package/dist/agent-runtime/prompts.js.map +1 -0
- package/dist/agents/spawner.d.ts.map +1 -1
- package/dist/agents/spawner.js +60 -4
- package/dist/agents/spawner.js.map +1 -1
- package/dist/cli/batch-exec.d.ts.map +1 -1
- package/dist/cli/batch-exec.js +183 -81
- package/dist/cli/batch-exec.js.map +1 -1
- package/dist/cli/batch-write-buffer.d.ts +141 -0
- package/dist/cli/batch-write-buffer.d.ts.map +1 -0
- package/dist/cli/batch-write-buffer.js +400 -0
- package/dist/cli/batch-write-buffer.js.map +1 -0
- package/dist/cli/commands/agent.d.ts +20 -0
- package/dist/cli/commands/agent.d.ts.map +1 -0
- package/dist/cli/commands/agent.js +831 -0
- package/dist/cli/commands/agent.js.map +1 -0
- package/dist/cli/commands/agents.d.ts +1 -1
- package/dist/cli/commands/agents.d.ts.map +1 -1
- package/dist/cli/commands/agents.js +2 -1
- package/dist/cli/commands/agents.js.map +1 -1
- package/dist/cli/commands/batch.js +1 -1
- package/dist/cli/commands/batch.js.map +1 -1
- package/dist/cli/commands/inbox.d.ts.map +1 -1
- package/dist/cli/commands/inbox.js +46 -22
- package/dist/cli/commands/inbox.js.map +1 -1
- package/dist/cli/commands/index.d.ts +1 -0
- package/dist/cli/commands/index.d.ts.map +1 -1
- package/dist/cli/commands/index.js +1 -0
- package/dist/cli/commands/index.js.map +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +4 -6
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/item.d.ts.map +1 -1
- package/dist/cli/commands/item.js +34 -17
- package/dist/cli/commands/item.js.map +1 -1
- package/dist/cli/commands/log.js +1 -1
- package/dist/cli/commands/log.js.map +1 -1
- package/dist/cli/commands/merge-driver.d.ts.map +1 -1
- package/dist/cli/commands/merge-driver.js +8 -3
- package/dist/cli/commands/merge-driver.js.map +1 -1
- package/dist/cli/commands/meta.d.ts.map +1 -1
- package/dist/cli/commands/meta.js +159 -6
- package/dist/cli/commands/meta.js.map +1 -1
- package/dist/cli/commands/module.d.ts.map +1 -1
- package/dist/cli/commands/module.js +2 -1
- package/dist/cli/commands/module.js.map +1 -1
- package/dist/cli/commands/plan-import.js +19 -3
- package/dist/cli/commands/plan-import.js.map +1 -1
- package/dist/cli/commands/plan.d.ts.map +1 -1
- package/dist/cli/commands/plan.js +87 -43
- package/dist/cli/commands/plan.js.map +1 -1
- package/dist/cli/commands/ralph.d.ts +5 -51
- package/dist/cli/commands/ralph.d.ts.map +1 -1
- package/dist/cli/commands/ralph.js +52 -1462
- package/dist/cli/commands/ralph.js.map +1 -1
- package/dist/cli/commands/search.d.ts.map +1 -1
- package/dist/cli/commands/search.js +22 -13
- package/dist/cli/commands/search.js.map +1 -1
- package/dist/cli/commands/serve.d.ts.map +1 -1
- package/dist/cli/commands/serve.js +70 -11
- package/dist/cli/commands/serve.js.map +1 -1
- package/dist/cli/commands/session/checkpoint.d.ts.map +1 -1
- package/dist/cli/commands/session/checkpoint.js +7 -2
- package/dist/cli/commands/session/checkpoint.js.map +1 -1
- package/dist/cli/commands/session/commands.d.ts.map +1 -1
- package/dist/cli/commands/session/commands.js +15 -0
- package/dist/cli/commands/session/commands.js.map +1 -1
- package/dist/cli/commands/session/context.d.ts.map +1 -1
- package/dist/cli/commands/session/context.js +10 -5
- package/dist/cli/commands/session/context.js.map +1 -1
- package/dist/cli/commands/session/log.d.ts +1 -0
- package/dist/cli/commands/session/log.d.ts.map +1 -1
- package/dist/cli/commands/session/log.js +124 -8
- package/dist/cli/commands/session/log.js.map +1 -1
- package/dist/cli/commands/session/stale-close.d.ts +17 -0
- package/dist/cli/commands/session/stale-close.d.ts.map +1 -0
- package/dist/cli/commands/session/stale-close.js +378 -0
- package/dist/cli/commands/session/stale-close.js.map +1 -0
- package/dist/cli/commands/setup.d.ts +4 -0
- package/dist/cli/commands/setup.d.ts.map +1 -1
- package/dist/cli/commands/setup.js +150 -6
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/skill-crud.d.ts.map +1 -1
- package/dist/cli/commands/skill-crud.js +4 -3
- package/dist/cli/commands/skill-crud.js.map +1 -1
- package/dist/cli/commands/skill-diff.d.ts.map +1 -1
- package/dist/cli/commands/skill-diff.js +15 -0
- package/dist/cli/commands/skill-diff.js.map +1 -1
- package/dist/cli/commands/skill-install.d.ts.map +1 -1
- package/dist/cli/commands/skill-install.js +50 -18
- package/dist/cli/commands/skill-install.js.map +1 -1
- package/dist/cli/commands/task.d.ts.map +1 -1
- package/dist/cli/commands/task.js +552 -323
- package/dist/cli/commands/task.js.map +1 -1
- package/dist/cli/commands/tasks.js +1 -1
- package/dist/cli/commands/tasks.js.map +1 -1
- package/dist/cli/commands/triage.d.ts.map +1 -1
- package/dist/cli/commands/triage.js +37 -13
- package/dist/cli/commands/triage.js.map +1 -1
- package/dist/cli/commands/validate.d.ts.map +1 -1
- package/dist/cli/commands/validate.js +99 -50
- package/dist/cli/commands/validate.js.map +1 -1
- package/dist/cli/help/content.d.ts.map +1 -1
- package/dist/cli/help/content.js +5 -0
- package/dist/cli/help/content.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +2 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/output.d.ts.map +1 -1
- package/dist/cli/output.js +5 -1
- package/dist/cli/output.js.map +1 -1
- package/dist/cli/validators.d.ts +4 -0
- package/dist/cli/validators.d.ts.map +1 -1
- package/dist/cli/validators.js +12 -0
- package/dist/cli/validators.js.map +1 -1
- package/dist/daemon/project-context.ts +22 -0
- package/dist/daemon/routes/agent-dispatch.ts +272 -0
- package/dist/daemon/server.ts +55 -20
- package/dist/daemon/websocket/handler.ts +67 -6
- package/dist/daemon/websocket/lifecycle.ts +19 -0
- package/dist/daemon/websocket/pubsub.ts +74 -3
- package/dist/export/html.d.ts.map +1 -1
- package/dist/export/html.js +5 -2
- package/dist/export/html.js.map +1 -1
- package/dist/export/triage.d.ts +1 -1
- package/dist/export/triage.d.ts.map +1 -1
- package/dist/export/triage.js +5 -3
- package/dist/export/triage.js.map +1 -1
- package/dist/parser/alignment.d.ts.map +1 -1
- package/dist/parser/alignment.js +6 -3
- package/dist/parser/alignment.js.map +1 -1
- package/dist/parser/assess.js +1 -1
- package/dist/parser/assess.js.map +1 -1
- package/dist/parser/config.d.ts +6 -6
- package/dist/parser/meta.d.ts.map +1 -1
- package/dist/parser/meta.js +9 -8
- package/dist/parser/meta.js.map +1 -1
- package/dist/parser/plan-document.d.ts +12 -12
- package/dist/parser/plans.d.ts +7 -0
- package/dist/parser/plans.d.ts.map +1 -1
- package/dist/parser/plans.js +100 -15
- package/dist/parser/plans.js.map +1 -1
- package/dist/parser/refs.d.ts +5 -0
- package/dist/parser/refs.d.ts.map +1 -1
- package/dist/parser/refs.js +17 -12
- package/dist/parser/refs.js.map +1 -1
- package/dist/parser/shadow.d.ts +1 -1
- package/dist/parser/shadow.d.ts.map +1 -1
- package/dist/parser/shadow.js +241 -76
- package/dist/parser/shadow.js.map +1 -1
- package/dist/parser/skill-render.d.ts.map +1 -1
- package/dist/parser/skill-render.js +6 -3
- package/dist/parser/skill-render.js.map +1 -1
- package/dist/parser/validate.d.ts.map +1 -1
- package/dist/parser/validate.js +70 -108
- package/dist/parser/validate.js.map +1 -1
- package/dist/parser/yaml.d.ts +24 -5
- package/dist/parser/yaml.d.ts.map +1 -1
- package/dist/parser/yaml.js +228 -66
- package/dist/parser/yaml.js.map +1 -1
- package/dist/schema/meta.d.ts +442 -119
- package/dist/schema/meta.d.ts.map +1 -1
- package/dist/schema/meta.js +55 -0
- package/dist/schema/meta.js.map +1 -1
- package/dist/schema/plan.d.ts +22 -22
- package/dist/schema/spec.d.ts +39 -39
- package/dist/schema/task.d.ts +43 -32
- package/dist/schema/task.d.ts.map +1 -1
- package/dist/schema/task.js +5 -0
- package/dist/schema/task.js.map +1 -1
- package/dist/sessions/store.d.ts +112 -0
- package/dist/sessions/store.d.ts.map +1 -1
- package/dist/sessions/store.js +414 -22
- package/dist/sessions/store.js.map +1 -1
- package/dist/sessions/types.d.ts +75 -17
- package/dist/sessions/types.d.ts.map +1 -1
- package/dist/sessions/types.js +51 -1
- package/dist/sessions/types.js.map +1 -1
- package/dist/triage/actions.d.ts +1 -0
- package/dist/triage/actions.d.ts.map +1 -1
- package/dist/triage/actions.js +34 -7
- package/dist/triage/actions.js.map +1 -1
- package/dist/utils/commit.js +1 -1
- package/dist/utils/commit.js.map +1 -1
- package/dist/web-ui/_app/env.js +1 -0
- package/dist/web-ui/_app/immutable/assets/0.BxCxvrZR.css +1 -0
- package/dist/web-ui/_app/immutable/assets/select-trigger.CV-KWLNP.css +1 -0
- package/dist/web-ui/_app/immutable/chunks/B-CZR0q8.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/B1IR5Su5.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/BCkp8Hs8.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/B_Cvvtc4.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/BtFaGGII.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/Bu8JVsCH.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/C87u-CNA.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/CrFkBTYp.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/D1ArdqNb.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/D28BF5MJ.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/D6RtLpzL.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/D7FHSgx2.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/DBXrsxZQ.js +2 -0
- package/dist/web-ui/_app/immutable/chunks/Da_hHMuA.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/Do6LchSF.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/DoNPtcAw.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/DtUbXRZz.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/DyFPRlLl.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/DzAP8lRM.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/DzVXElzN.js +2 -0
- package/dist/web-ui/_app/immutable/chunks/aoPBFken.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/i-XnOIX0.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/laxtrUO3.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/q1nIWgqB.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/sTLbk5Nm.js +1 -0
- package/dist/web-ui/_app/immutable/chunks/vwKgQu5P.js +5 -0
- package/dist/web-ui/_app/immutable/entry/app.BCwMcqnT.js +2 -0
- package/dist/web-ui/_app/immutable/entry/start.wKCQH-tt.js +1 -0
- package/dist/web-ui/_app/immutable/nodes/0.CjGVMG74.js +1 -0
- package/dist/web-ui/_app/immutable/nodes/1.B6_AIPan.js +1 -0
- package/dist/web-ui/_app/immutable/nodes/2.q4oCS7Ws.js +1 -0
- package/dist/web-ui/_app/immutable/nodes/3.rTKZf9o2.js +1 -0
- package/dist/web-ui/_app/immutable/nodes/4.DVIDRu1d.js +1 -0
- package/dist/web-ui/_app/immutable/nodes/5.8PtPXIOd.js +1 -0
- package/dist/web-ui/_app/immutable/nodes/6.ZZrTemy_.js +1 -0
- package/dist/web-ui/_app/immutable/nodes/7.IP-gxCxi.js +1 -0
- package/dist/web-ui/_app/version.json +1 -0
- package/dist/web-ui/index.html +36 -0
- package/dist/web-ui/robots.txt +3 -0
- package/package.json +3 -2
- package/plugin/.claude-plugin/marketplace.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/kspec/skills/create-workflow/SKILL.md +1 -1
- package/plugin/plugins/kspec/skills/{observations → observe}/SKILL.md +1 -1
- package/plugin/plugins/kspec/skills/plan/SKILL.md +1 -1
- package/plugin/plugins/kspec/skills/task-work/SKILL.md +26 -3
- package/plugin/plugins/kspec/skills/triage-inbox/SKILL.md +1 -1
- package/plugin/plugins/kspec/skills/writing-specs/SKILL.md +1 -1
- package/templates/agents-sections/01-quick-start.md +1 -0
- package/templates/agents-sections/06-ralph-loop.md +64 -11
- package/templates/skills/create-workflow/SKILL.md +1 -1
- package/templates/skills/manifest.yaml +1 -1
- package/templates/skills/plan/SKILL.md +1 -1
- package/templates/skills/task-work/SKILL.md +26 -3
- package/templates/skills/triage-inbox/SKILL.md +1 -1
- package/templates/skills/writing-specs/SKILL.md +1 -1
- package/dist/ralph/cli-renderer.d.ts +0 -27
- package/dist/ralph/cli-renderer.d.ts.map +0 -1
- package/dist/ralph/cli-renderer.js +0 -250
- package/dist/ralph/cli-renderer.js.map +0 -1
- package/dist/ralph/events.d.ts +0 -65
- package/dist/ralph/events.d.ts.map +0 -1
- package/dist/ralph/events.js +0 -600
- package/dist/ralph/events.js.map +0 -1
- package/dist/ralph/index.d.ts +0 -11
- package/dist/ralph/index.d.ts.map +0 -1
- package/dist/ralph/index.js +0 -16
- package/dist/ralph/index.js.map +0 -1
- package/dist/ralph/loop-errors.d.ts +0 -83
- package/dist/ralph/loop-errors.d.ts.map +0 -1
- package/dist/ralph/loop-errors.js +0 -150
- package/dist/ralph/loop-errors.js.map +0 -1
- package/dist/ralph/subagent.d.ts +0 -96
- package/dist/ralph/subagent.d.ts.map +0 -1
- package/dist/ralph/subagent.js +0 -195
- package/dist/ralph/subagent.js.map +0 -1
- package/dist/ralph/wrap-up.d.ts +0 -127
- package/dist/ralph/wrap-up.d.ts.map +0 -1
- package/dist/ralph/wrap-up.js +0 -271
- package/dist/ralph/wrap-up.js.map +0 -1
- /package/templates/skills/{observations → observe}/SKILL.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { markMutating } from "../command-annotations.js";
|
|
3
|
-
import { checkSlugUniqueness, createNote, createTask, createTodo, deleteTask, getAuthor, initContext, loadAllItems, loadAllTasks, ReferenceIndex, saveTask, scanTestCoverage, syncSpecImplementationStatus, } from "../../parser/index.js";
|
|
3
|
+
import { checkSlugUniqueness, createNote, createTask, createTodo, deleteTask, getAuthor, initContext, loadAllItems, loadAllTasks, mutateTaskAtomically, ReferenceIndex, saveTask, scanTestCoverage, syncSpecImplementationStatus, } from "../../parser/index.js";
|
|
4
4
|
import { commitIfShadow } from "../../parser/shadow.js";
|
|
5
5
|
import { normalizeRefInput } from "../../schema/index.js";
|
|
6
6
|
import { alignmentCheck, errors } from "../../strings/index.js";
|
|
@@ -9,10 +9,59 @@ import { executeBatchOperation, formatBatchOutput } from "../batch.js";
|
|
|
9
9
|
import { EXIT_CODES } from "../exit-codes.js";
|
|
10
10
|
import { parseTagsArray } from "../parse-utils.js";
|
|
11
11
|
import { annotateNotesWithSuperseded, error, formatTaskDetails, info, isJsonMode, output, success, warn, } from "../output.js";
|
|
12
|
-
import {
|
|
12
|
+
import { parsePriority, validateEnumOption, validateSpecRef, } from "../validators.js";
|
|
13
13
|
import { addListOptions, listTasksAction } from "./tasks.js";
|
|
14
14
|
import { findClosestCommand } from "../suggest.js";
|
|
15
15
|
import { checkBudget, incrementBudget, isEndLoopRequested } from "../../sessions/store.js";
|
|
16
|
+
import { PidFileManager } from "../pid-utils.js";
|
|
17
|
+
/**
|
|
18
|
+
* Post a task state change event to the daemon dispatch engine.
|
|
19
|
+
* Fails silently — dispatch requires a running daemon; if absent, this is a no-op.
|
|
20
|
+
* AC: @daemon-agent-dispatch ac-2, ac-7
|
|
21
|
+
* AC: @agent-dispatch-engine ac-18
|
|
22
|
+
*/
|
|
23
|
+
async function postDispatchEvent(opts) {
|
|
24
|
+
// AC: @agent-dispatch-engine ac-18 - Suppress self-triggering when running
|
|
25
|
+
// inside a dispatched agent invocation. The file watcher will independently
|
|
26
|
+
// detect the change, so the CLI event would be redundant and causes stale
|
|
27
|
+
// queue entries to accumulate.
|
|
28
|
+
// NOTE: This relies on the daemon's file watcher being active. If the watcher
|
|
29
|
+
// is temporarily down, dispatched agents' task mutations won't produce dispatch
|
|
30
|
+
// events until the watcher recovers and diffs the changed state.
|
|
31
|
+
if (process.env.KSPEC_SESSION_ID)
|
|
32
|
+
return;
|
|
33
|
+
const pidManager = new PidFileManager();
|
|
34
|
+
if (!pidManager.isDaemonRunning())
|
|
35
|
+
return;
|
|
36
|
+
let port;
|
|
37
|
+
try {
|
|
38
|
+
port = pidManager.readPort();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return; // Daemon running but port unreadable — silent fail
|
|
42
|
+
}
|
|
43
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
44
|
+
if (opts.projectPath) {
|
|
45
|
+
headers['X-Kspec-Dir'] = opts.projectPath;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
await fetch(`http://localhost:${port}/api/agent/events`, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers,
|
|
51
|
+
body: JSON.stringify({
|
|
52
|
+
task_id: opts.taskId,
|
|
53
|
+
task_ref: opts.taskRef,
|
|
54
|
+
from_status: opts.fromStatus,
|
|
55
|
+
to_status: opts.toStatus,
|
|
56
|
+
timestamp: Date.now(),
|
|
57
|
+
}),
|
|
58
|
+
signal: AbortSignal.timeout(1000), // 1s timeout — fire-and-forget
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Silent fail — daemon unreachable or dispatch engine not running
|
|
63
|
+
}
|
|
64
|
+
}
|
|
16
65
|
/**
|
|
17
66
|
* Find a task by reference with detailed error reporting.
|
|
18
67
|
* Returns the task or exits with appropriate error.
|
|
@@ -97,20 +146,11 @@ async function setTaskFields(foundTask, ctx, tasks, items, _allMetaItems, index,
|
|
|
97
146
|
};
|
|
98
147
|
}
|
|
99
148
|
}
|
|
100
|
-
// Build updated task with only provided options
|
|
101
|
-
const updatedTask = { ...foundTask };
|
|
102
149
|
const changes = [];
|
|
103
|
-
|
|
104
|
-
updatedTask.title = options.title;
|
|
105
|
-
changes.push("title");
|
|
106
|
-
}
|
|
150
|
+
let noChangesMessage;
|
|
107
151
|
if (options.specRef !== undefined) {
|
|
108
152
|
// Handle 'null' string to clear spec_ref
|
|
109
|
-
if (options.specRef
|
|
110
|
-
updatedTask.spec_ref = null;
|
|
111
|
-
changes.push("spec_ref: cleared");
|
|
112
|
-
}
|
|
113
|
-
else {
|
|
153
|
+
if (options.specRef !== "null") {
|
|
114
154
|
// Validate the spec ref exists and is a spec item
|
|
115
155
|
const specResult = index.resolve(options.specRef);
|
|
116
156
|
if (!specResult.ok) {
|
|
@@ -127,17 +167,11 @@ async function setTaskFields(foundTask, ctx, tasks, items, _allMetaItems, index,
|
|
|
127
167
|
error: errors.reference.specRefIsTask(options.specRef),
|
|
128
168
|
};
|
|
129
169
|
}
|
|
130
|
-
updatedTask.spec_ref = normalizeRefInput(options.specRef);
|
|
131
|
-
changes.push("spec_ref");
|
|
132
170
|
}
|
|
133
171
|
}
|
|
134
172
|
if (options.metaRef !== undefined) {
|
|
135
173
|
// Handle 'null' string to clear meta_ref
|
|
136
|
-
if (options.metaRef
|
|
137
|
-
updatedTask.meta_ref = null;
|
|
138
|
-
changes.push("meta_ref: cleared");
|
|
139
|
-
}
|
|
140
|
-
else {
|
|
174
|
+
if (options.metaRef !== "null") {
|
|
141
175
|
// Validate the meta ref exists and is a meta item
|
|
142
176
|
const metaRefResult = index.resolve(options.metaRef);
|
|
143
177
|
if (!metaRefResult.ok) {
|
|
@@ -155,17 +189,11 @@ async function setTaskFields(foundTask, ctx, tasks, items, _allMetaItems, index,
|
|
|
155
189
|
error: errors.reference.metaRefPointsToSpec(options.metaRef),
|
|
156
190
|
};
|
|
157
191
|
}
|
|
158
|
-
updatedTask.meta_ref = normalizeRefInput(options.metaRef);
|
|
159
|
-
changes.push("meta_ref");
|
|
160
192
|
}
|
|
161
193
|
}
|
|
162
194
|
if (options.planRef !== undefined) {
|
|
163
195
|
// Handle 'null' string to clear plan_ref
|
|
164
|
-
if (options.planRef
|
|
165
|
-
updatedTask.plan_ref = null;
|
|
166
|
-
changes.push("plan_ref: cleared");
|
|
167
|
-
}
|
|
168
|
-
else {
|
|
196
|
+
if (options.planRef !== "null") {
|
|
169
197
|
// First check if it's a task or spec item (wrong type)
|
|
170
198
|
const cleanRef = options.planRef.startsWith("@")
|
|
171
199
|
? options.planRef.slice(1)
|
|
@@ -191,36 +219,26 @@ async function setTaskFields(foundTask, ctx, tasks, items, _allMetaItems, index,
|
|
|
191
219
|
error: `Plan reference not found: ${options.planRef}`,
|
|
192
220
|
};
|
|
193
221
|
}
|
|
194
|
-
updatedTask.plan_ref = normalizeRefInput(options.planRef);
|
|
195
|
-
changes.push("plan_ref");
|
|
196
222
|
}
|
|
197
223
|
}
|
|
198
|
-
if
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
name: "Priority",
|
|
203
|
-
});
|
|
204
|
-
if (!priorityResult.ok) {
|
|
205
|
-
return { success: false, error: priorityResult.error };
|
|
224
|
+
// Validate review URL if provided and not clearing
|
|
225
|
+
if (options.reviewUrl !== undefined && options.reviewUrl !== "null") {
|
|
226
|
+
try {
|
|
227
|
+
new URL(options.reviewUrl);
|
|
206
228
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
210
|
-
if (options.slug) {
|
|
211
|
-
if (!updatedTask.slugs.includes(options.slug)) {
|
|
212
|
-
updatedTask.slugs = [...updatedTask.slugs, options.slug];
|
|
213
|
-
changes.push("slug");
|
|
229
|
+
catch {
|
|
230
|
+
return { success: false, error: `Invalid review URL: ${options.reviewUrl}` };
|
|
214
231
|
}
|
|
215
232
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
if (
|
|
220
|
-
|
|
221
|
-
changes.push("tags");
|
|
233
|
+
let parsedPriority;
|
|
234
|
+
if (options.priority) {
|
|
235
|
+
const priorityResult = parsePriority(options.priority);
|
|
236
|
+
if (!priorityResult.ok) {
|
|
237
|
+
return { success: false, error: priorityResult.error };
|
|
222
238
|
}
|
|
239
|
+
parsedPriority = priorityResult.value;
|
|
223
240
|
}
|
|
241
|
+
const parsedTags = options.tag ? parseTagsArray(options.tag) : [];
|
|
224
242
|
if (options.dependsOn) {
|
|
225
243
|
// Validate all dependency refs
|
|
226
244
|
for (const depRef of options.dependsOn) {
|
|
@@ -240,34 +258,12 @@ async function setTaskFields(foundTask, ctx, tasks, items, _allMetaItems, index,
|
|
|
240
258
|
};
|
|
241
259
|
}
|
|
242
260
|
}
|
|
243
|
-
updatedTask.depends_on = options.dependsOn.map(normalizeRefInput);
|
|
244
|
-
changes.push("depends_on");
|
|
245
|
-
}
|
|
246
|
-
// AC: @spec-task-clear-deps ac-1, ac-2 - Clear all dependencies
|
|
247
|
-
if (options.clearDeps) {
|
|
248
|
-
if (foundTask.depends_on.length === 0) {
|
|
249
|
-
// AC: @spec-task-clear-deps ac-2 - No changes needed
|
|
250
|
-
return {
|
|
251
|
-
success: true,
|
|
252
|
-
message: "No changes: task has no dependencies to clear",
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
updatedTask.depends_on = [];
|
|
256
|
-
changes.push("depends_on");
|
|
257
|
-
// Add note documenting the change
|
|
258
|
-
// AC: @task-set ac-author
|
|
259
|
-
const note = createNote(`Dependencies cleared (was: ${foundTask.depends_on.join(", ")})`, getAuthor(ctx.config?.identity?.author));
|
|
260
|
-
updatedTask.notes = [...updatedTask.notes, note];
|
|
261
261
|
}
|
|
262
262
|
// AC: @task-automation-eligibility ac-5, ac-11, ac-12, ac-18
|
|
263
263
|
// Handle automation status changes
|
|
264
264
|
// Note: --no-automation sets options.automation to false, so check that first
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
delete updatedTask.automation;
|
|
268
|
-
changes.push("automation");
|
|
269
|
-
}
|
|
270
|
-
else if (options.automation !== undefined) {
|
|
265
|
+
let validatedAutomation;
|
|
266
|
+
if (options.automation !== undefined && options.automation !== false) {
|
|
271
267
|
const automationResult = validateEnumOption(options.automation, ["eligible", "needs_review", "manual_only"], "automation status");
|
|
272
268
|
if (!automationResult.ok) {
|
|
273
269
|
return { success: false, error: automationResult.error };
|
|
@@ -279,15 +275,119 @@ async function setTaskFields(foundTask, ctx, tasks, items, _allMetaItems, index,
|
|
|
279
275
|
error: "Setting automation to needs_review requires --reason flag explaining why",
|
|
280
276
|
};
|
|
281
277
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
278
|
+
validatedAutomation = automationResult.value;
|
|
279
|
+
}
|
|
280
|
+
let updatedTask = foundTask;
|
|
281
|
+
updatedTask = await mutateTaskAtomically(ctx, foundTask, (latestTask) => {
|
|
282
|
+
const nextTask = { ...latestTask };
|
|
283
|
+
const mutationChanges = [];
|
|
284
|
+
if (options.title) {
|
|
285
|
+
nextTask.title = options.title;
|
|
286
|
+
mutationChanges.push("title");
|
|
287
|
+
}
|
|
288
|
+
if (options.description !== undefined) {
|
|
289
|
+
if (options.description === "null" || options.description.trim() === "") {
|
|
290
|
+
delete nextTask.description;
|
|
291
|
+
mutationChanges.push("description: cleared");
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
nextTask.description = options.description;
|
|
295
|
+
mutationChanges.push("description");
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (options.specRef !== undefined) {
|
|
299
|
+
if (options.specRef === "null") {
|
|
300
|
+
nextTask.spec_ref = null;
|
|
301
|
+
mutationChanges.push("spec_ref: cleared");
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
nextTask.spec_ref = normalizeRefInput(options.specRef);
|
|
305
|
+
mutationChanges.push("spec_ref");
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (options.metaRef !== undefined) {
|
|
309
|
+
if (options.metaRef === "null") {
|
|
310
|
+
nextTask.meta_ref = null;
|
|
311
|
+
mutationChanges.push("meta_ref: cleared");
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
nextTask.meta_ref = normalizeRefInput(options.metaRef);
|
|
315
|
+
mutationChanges.push("meta_ref");
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (options.planRef !== undefined) {
|
|
319
|
+
if (options.planRef === "null") {
|
|
320
|
+
nextTask.plan_ref = null;
|
|
321
|
+
mutationChanges.push("plan_ref: cleared");
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
nextTask.plan_ref = normalizeRefInput(options.planRef);
|
|
325
|
+
mutationChanges.push("plan_ref");
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (options.reviewUrl !== undefined) {
|
|
329
|
+
if (options.reviewUrl === "null") {
|
|
330
|
+
delete nextTask.review_url;
|
|
331
|
+
mutationChanges.push("review_url: cleared");
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
nextTask.review_url = options.reviewUrl;
|
|
335
|
+
mutationChanges.push("review_url");
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (parsedPriority !== undefined) {
|
|
339
|
+
nextTask.priority = parsedPriority;
|
|
340
|
+
mutationChanges.push("priority");
|
|
341
|
+
}
|
|
342
|
+
if (options.slug && !nextTask.slugs.includes(options.slug)) {
|
|
343
|
+
nextTask.slugs = [...nextTask.slugs, options.slug];
|
|
344
|
+
mutationChanges.push("slug");
|
|
345
|
+
}
|
|
346
|
+
if (parsedTags.length > 0) {
|
|
347
|
+
const newTags = parsedTags.filter((tag) => !nextTask.tags.includes(tag));
|
|
348
|
+
if (newTags.length > 0) {
|
|
349
|
+
nextTask.tags = [...nextTask.tags, ...newTags];
|
|
350
|
+
mutationChanges.push("tags");
|
|
351
|
+
}
|
|
290
352
|
}
|
|
353
|
+
if (options.dependsOn) {
|
|
354
|
+
nextTask.depends_on = options.dependsOn.map(normalizeRefInput);
|
|
355
|
+
mutationChanges.push("depends_on");
|
|
356
|
+
}
|
|
357
|
+
if (options.clearDeps) {
|
|
358
|
+
if (latestTask.depends_on.length === 0) {
|
|
359
|
+
// AC: @spec-task-clear-deps ac-2 - No changes needed
|
|
360
|
+
noChangesMessage = "No changes: task has no dependencies to clear";
|
|
361
|
+
return latestTask;
|
|
362
|
+
}
|
|
363
|
+
nextTask.depends_on = [];
|
|
364
|
+
mutationChanges.push("depends_on");
|
|
365
|
+
// AC: @task-set ac-author
|
|
366
|
+
const note = createNote(`Dependencies cleared (was: ${latestTask.depends_on.join(", ")})`, getAuthor(ctx.config?.identity?.author));
|
|
367
|
+
nextTask.notes = [...nextTask.notes, note];
|
|
368
|
+
}
|
|
369
|
+
if (options.automation === false) {
|
|
370
|
+
delete nextTask.automation;
|
|
371
|
+
mutationChanges.push("automation");
|
|
372
|
+
}
|
|
373
|
+
else if (validatedAutomation) {
|
|
374
|
+
nextTask.automation = validatedAutomation;
|
|
375
|
+
mutationChanges.push("automation");
|
|
376
|
+
if (options.reason) {
|
|
377
|
+
const note = createNote(`Automation status set to ${validatedAutomation}: ${options.reason}`, getAuthor(ctx.config?.identity?.author));
|
|
378
|
+
nextTask.notes = [...nextTask.notes, note];
|
|
379
|
+
mutationChanges.push("note");
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
changes.splice(0, changes.length, ...mutationChanges);
|
|
383
|
+
return nextTask;
|
|
384
|
+
});
|
|
385
|
+
if (noChangesMessage) {
|
|
386
|
+
return {
|
|
387
|
+
success: true,
|
|
388
|
+
message: noChangesMessage,
|
|
389
|
+
data: { task: updatedTask },
|
|
390
|
+
};
|
|
291
391
|
}
|
|
292
392
|
// AC: @spec-task-set-batch ac-4 - Warn on no changes, don't fail
|
|
293
393
|
if (changes.length === 0) {
|
|
@@ -297,7 +397,6 @@ async function setTaskFields(foundTask, ctx, tasks, items, _allMetaItems, index,
|
|
|
297
397
|
data: { task: updatedTask },
|
|
298
398
|
};
|
|
299
399
|
}
|
|
300
|
-
await saveTask(ctx, updatedTask);
|
|
301
400
|
await commitIfShadow(ctx.shadow, "task-set", foundTask.slugs[0] || index.shortUlid(foundTask._ulid), changes.join(", "));
|
|
302
401
|
return {
|
|
303
402
|
success: true,
|
|
@@ -447,7 +546,7 @@ export function registerTaskCommands(program) {
|
|
|
447
546
|
.option("--spec-ref <ref>", "Reference to spec item")
|
|
448
547
|
.option("--meta-ref <ref>", "Reference to meta item (workflow, agent, or convention)")
|
|
449
548
|
.option("--plan-ref <ref>", "Reference to plan this task is derived from")
|
|
450
|
-
.option("--priority <n>", "Priority (1-5)", "3")
|
|
549
|
+
.option("--priority <n>", "Priority (1-5 or P1-P5)", "3")
|
|
451
550
|
.option("--slug <slug>", "Human-friendly slug")
|
|
452
551
|
.option("--tag <tag...>", "Tags")
|
|
453
552
|
.option("--depends-on <refs...>", "Set task dependencies")
|
|
@@ -555,11 +654,7 @@ Examples:
|
|
|
555
654
|
}
|
|
556
655
|
}
|
|
557
656
|
// Validate priority
|
|
558
|
-
const priorityResult =
|
|
559
|
-
min: 1,
|
|
560
|
-
max: 5,
|
|
561
|
-
name: "Priority",
|
|
562
|
-
});
|
|
657
|
+
const priorityResult = parsePriority(options.priority);
|
|
563
658
|
if (!priorityResult.ok) {
|
|
564
659
|
error(priorityResult.error);
|
|
565
660
|
process.exit(EXIT_CODES.VALIDATION_FAILED);
|
|
@@ -600,10 +695,12 @@ Examples:
|
|
|
600
695
|
.description("Update task fields")
|
|
601
696
|
.option("--refs <refs...>", "Update multiple tasks (AC: @spec-task-set-batch ac-1)")
|
|
602
697
|
.option("--title <title>", "Update task title")
|
|
698
|
+
.option("--description <description>", "Update task description (use 'null' to clear)")
|
|
603
699
|
.option("--spec-ref <ref>", "Link to spec item (use 'null' to clear)")
|
|
604
700
|
.option("--meta-ref <ref>", "Link to meta item (use 'null' to clear)")
|
|
605
701
|
.option("--plan-ref <ref>", "Link to plan (use 'null' to clear)")
|
|
606
|
-
.option("--
|
|
702
|
+
.option("--review-url <url>", "Set review URL (use 'null' to clear)")
|
|
703
|
+
.option("--priority <n>", "Set priority (1-5 or P1-P5)")
|
|
607
704
|
.option("--slug <slug>", "Add a slug alias")
|
|
608
705
|
.option("--tag <tag...>", "Add tags")
|
|
609
706
|
.option("--depends-on <refs...>", "Set dependencies (replaces existing)")
|
|
@@ -615,6 +712,7 @@ Examples:
|
|
|
615
712
|
.addHelpText("after", `
|
|
616
713
|
Examples:
|
|
617
714
|
$ kspec task set @task-slug --priority 2
|
|
715
|
+
$ kspec task set @task-slug --description "Updated context"
|
|
618
716
|
$ kspec task set @task-slug --depends-on @dep1 @dep2
|
|
619
717
|
$ kspec task set @task-slug --tag cli urgent
|
|
620
718
|
$ kspec task set --refs @task1 @task2 --priority 3`)
|
|
@@ -728,12 +826,23 @@ Examples:
|
|
|
728
826
|
jsonData = options.data;
|
|
729
827
|
}
|
|
730
828
|
else {
|
|
829
|
+
const isTTY = process.env.KSPEC_TEST_TTY === "1" ||
|
|
830
|
+
process.env.KSPEC_TEST_TTY === "true" ||
|
|
831
|
+
process.stdin.isTTY;
|
|
832
|
+
if (isTTY) {
|
|
833
|
+
error(errors.validation.noPatchData);
|
|
834
|
+
process.exit(EXIT_CODES.VALIDATION_FAILED);
|
|
835
|
+
}
|
|
731
836
|
// Read from stdin
|
|
732
837
|
const chunks = [];
|
|
733
838
|
for await (const chunk of process.stdin) {
|
|
734
839
|
chunks.push(chunk);
|
|
735
840
|
}
|
|
736
841
|
jsonData = Buffer.concat(chunks).toString("utf-8");
|
|
842
|
+
if (!jsonData.trim()) {
|
|
843
|
+
error(errors.validation.noPatchData);
|
|
844
|
+
process.exit(EXIT_CODES.VALIDATION_FAILED);
|
|
845
|
+
}
|
|
737
846
|
}
|
|
738
847
|
// Parse JSON
|
|
739
848
|
let patchData;
|
|
@@ -768,20 +877,22 @@ Examples:
|
|
|
768
877
|
process.exit(EXIT_CODES.ERROR);
|
|
769
878
|
}
|
|
770
879
|
}
|
|
771
|
-
// Build updated task
|
|
772
|
-
const updatedTask = { ...foundTask, ...validatedPatch };
|
|
773
880
|
// Track changes for output
|
|
774
881
|
const changes = Object.keys(validatedPatch);
|
|
775
882
|
if (options.dryRun) {
|
|
883
|
+
const dryRunTask = { ...foundTask, ...validatedPatch };
|
|
776
884
|
info("Dry run - no changes will be written");
|
|
777
885
|
info(`Would update: ${changes.join(", ")}`);
|
|
778
|
-
output({ changes, updated:
|
|
886
|
+
output({ changes, updated: dryRunTask }, () => {
|
|
779
887
|
console.log(`\nChanges: ${changes.join(", ")}\n`);
|
|
780
|
-
return formatTaskDetails(
|
|
888
|
+
return formatTaskDetails(dryRunTask, index);
|
|
781
889
|
});
|
|
782
890
|
return;
|
|
783
891
|
}
|
|
784
|
-
await
|
|
892
|
+
const updatedTask = await mutateTaskAtomically(ctx, foundTask, (latestTask) => ({
|
|
893
|
+
...latestTask,
|
|
894
|
+
...validatedPatch,
|
|
895
|
+
}));
|
|
785
896
|
await commitIfShadow(ctx.shadow, "task-patch", foundTask.slugs[0] || index.shortUlid(foundTask._ulid), changes.join(", "));
|
|
786
897
|
success(`Patched task: ${index.shortUlid(updatedTask._ulid)} (${changes.join(", ")})`, { task: updatedTask });
|
|
787
898
|
}
|
|
@@ -827,30 +938,47 @@ Examples:
|
|
|
827
938
|
process.exit(EXIT_CODES.VALIDATION_FAILED);
|
|
828
939
|
}
|
|
829
940
|
}
|
|
830
|
-
// AC: @task-budget-enforcement ac-block-start, ac-no-session, ac-no-budget
|
|
831
|
-
// Check budget before starting — only when session is set
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
941
|
+
// AC: @task-budget-enforcement ac-block-start, ac-no-session, ac-no-budget, ac-needs-work-no-increment
|
|
942
|
+
// Check budget before starting — only when session is set.
|
|
943
|
+
// Skip budget check for needs_work→in_progress (fix cycles): the task
|
|
944
|
+
// already consumed a budget slot when originally started; blocking it
|
|
945
|
+
// would prevent completing already-assigned work.
|
|
946
|
+
if (foundTask.status !== "needs_work") {
|
|
947
|
+
const budgetCheck = await checkBudget(ctx.specDir, sessionId || undefined);
|
|
948
|
+
if (!budgetCheck.allowed) {
|
|
949
|
+
error(budgetCheck.reason);
|
|
950
|
+
process.exit(EXIT_CODES.VALIDATION_FAILED);
|
|
951
|
+
}
|
|
836
952
|
}
|
|
837
953
|
// Update status
|
|
838
954
|
// AC: @session-scoped-task-claiming ac-stamp, ac-no-env
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
status
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
955
|
+
let transitionFromStatus = foundTask.status;
|
|
956
|
+
const updatedTask = await mutateTaskAtomically(ctx, foundTask, (latestTask) => {
|
|
957
|
+
transitionFromStatus = latestTask.status;
|
|
958
|
+
return {
|
|
959
|
+
...latestTask,
|
|
960
|
+
status: "in_progress",
|
|
961
|
+
started_at: new Date().toISOString(),
|
|
962
|
+
...(sessionId ? { session_id: sessionId } : {}),
|
|
963
|
+
};
|
|
964
|
+
});
|
|
846
965
|
await commitIfShadow(ctx.shadow, "task-start", foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
|
|
847
|
-
// AC: @task-budget-enforcement ac-increment, ac-resume-no-increment
|
|
848
|
-
// Increment budget counter after successful start
|
|
849
|
-
// Resume case
|
|
850
|
-
//
|
|
851
|
-
|
|
966
|
+
// AC: @task-budget-enforcement ac-increment, ac-resume-no-increment, ac-needs-work-no-increment
|
|
967
|
+
// Increment budget counter after successful start, but only for
|
|
968
|
+
// genuine pending→in_progress transitions. Resume case returns early
|
|
969
|
+
// above. needs_work→in_progress is a fix cycle (task already consumed
|
|
970
|
+
// a budget slot when originally started) — don't double-count it.
|
|
971
|
+
if (sessionId && transitionFromStatus === "pending") {
|
|
852
972
|
await incrementBudget(ctx.specDir, sessionId);
|
|
853
973
|
}
|
|
974
|
+
// AC: @daemon-agent-dispatch ac-2, ac-7 - Notify daemon of state change (fire-and-forget)
|
|
975
|
+
postDispatchEvent({
|
|
976
|
+
taskId: updatedTask._ulid,
|
|
977
|
+
taskRef: `@${updatedTask.slugs[0] || updatedTask._ulid}`,
|
|
978
|
+
fromStatus: transitionFromStatus,
|
|
979
|
+
toStatus: updatedTask.status,
|
|
980
|
+
projectPath: ctx.rootDir,
|
|
981
|
+
});
|
|
854
982
|
success(`Started task: ${index.shortUlid(updatedTask._ulid)}`, {
|
|
855
983
|
task: updatedTask,
|
|
856
984
|
});
|
|
@@ -937,89 +1065,82 @@ Examples:
|
|
|
937
1065
|
},
|
|
938
1066
|
executeOperation: async (foundTask, { ctx, tasks, items, index, options }) => {
|
|
939
1067
|
try {
|
|
940
|
-
// AC: @spec-completion-enforcement ac-6
|
|
941
|
-
if (foundTask.status === "completed") {
|
|
942
|
-
return {
|
|
943
|
-
success: false,
|
|
944
|
-
error: errors.status.completeAlreadyCompleted,
|
|
945
|
-
};
|
|
946
|
-
}
|
|
947
|
-
// AC: @task-commands ac-1 - Allow --force to bypass all state checks
|
|
948
1068
|
const forcingCompletion = options.force;
|
|
949
|
-
// AC: @spec-completion-enforcement ac-7 - Allow skip-review bypass
|
|
950
|
-
if (!options.skipReview && !forcingCompletion) {
|
|
951
|
-
// AC: @spec-completion-enforcement ac-2
|
|
952
|
-
if (foundTask.status === "in_progress") {
|
|
953
|
-
return {
|
|
954
|
-
success: false,
|
|
955
|
-
error: errors.status.completeRequiresReview,
|
|
956
|
-
};
|
|
957
|
-
}
|
|
958
|
-
// AC: @spec-completion-enforcement ac-3
|
|
959
|
-
if (foundTask.status === "pending") {
|
|
960
|
-
return {
|
|
961
|
-
success: false,
|
|
962
|
-
error: errors.status.completeRequiresStart,
|
|
963
|
-
};
|
|
964
|
-
}
|
|
965
|
-
// AC: @spec-completion-enforcement ac-4
|
|
966
|
-
if (foundTask.status === "blocked") {
|
|
967
|
-
return {
|
|
968
|
-
success: false,
|
|
969
|
-
error: errors.status.completeBlockedTask,
|
|
970
|
-
};
|
|
971
|
-
}
|
|
972
|
-
// AC: @spec-completion-enforcement ac-5
|
|
973
|
-
if (foundTask.status === "cancelled") {
|
|
974
|
-
return {
|
|
975
|
-
success: false,
|
|
976
|
-
error: errors.status.completeCancelledTask,
|
|
977
|
-
};
|
|
978
|
-
}
|
|
979
|
-
// AC: @spec-completion-enforcement ac-1 - Only pending_review allowed
|
|
980
|
-
if (foundTask.status !== "pending_review") {
|
|
981
|
-
return {
|
|
982
|
-
success: false,
|
|
983
|
-
error: errors.status.cannotComplete(foundTask.status),
|
|
984
|
-
};
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
1069
|
const now = new Date().toISOString();
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
let taskNotes = foundTask.notes;
|
|
991
|
-
if (options.skipReview && options.reason) {
|
|
992
|
-
const skipNote = createNote(`Completed with --skip-review: ${options.reason}`, getAuthor(ctx.config?.identity?.author));
|
|
993
|
-
taskNotes = [...taskNotes, skipNote];
|
|
994
|
-
}
|
|
995
|
-
// AC: @task-commands ac-1 - Document force completion from non-standard state
|
|
996
|
-
const forcedFromNonStandard = forcingCompletion &&
|
|
997
|
-
foundTask.status !== "pending_review";
|
|
1070
|
+
let transitionFromStatus = foundTask.status;
|
|
1071
|
+
let forcedFromNonStandard = false;
|
|
998
1072
|
let forceStateDetail;
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1073
|
+
const updatedTask = await mutateTaskAtomically(ctx, foundTask, (latestTask) => {
|
|
1074
|
+
transitionFromStatus = latestTask.status;
|
|
1075
|
+
// AC: @spec-completion-enforcement ac-6
|
|
1076
|
+
if (latestTask.status === "completed") {
|
|
1077
|
+
throw new Error(errors.status.completeAlreadyCompleted);
|
|
1004
1078
|
}
|
|
1005
|
-
|
|
1006
|
-
if (options.
|
|
1007
|
-
|
|
1079
|
+
// AC: @spec-completion-enforcement ac-7 - Allow skip-review bypass
|
|
1080
|
+
if (!options.skipReview && !forcingCompletion) {
|
|
1081
|
+
// AC: @spec-completion-enforcement ac-2
|
|
1082
|
+
if (latestTask.status === "in_progress") {
|
|
1083
|
+
throw new Error(errors.status.completeRequiresReview);
|
|
1084
|
+
}
|
|
1085
|
+
// AC: @spec-completion-enforcement ac-3
|
|
1086
|
+
if (latestTask.status === "pending") {
|
|
1087
|
+
throw new Error(errors.status.completeRequiresStart);
|
|
1088
|
+
}
|
|
1089
|
+
// AC: @spec-completion-enforcement ac-4
|
|
1090
|
+
if (latestTask.status === "blocked") {
|
|
1091
|
+
throw new Error(errors.status.completeBlockedTask);
|
|
1092
|
+
}
|
|
1093
|
+
// AC: @spec-completion-enforcement ac-5
|
|
1094
|
+
if (latestTask.status === "cancelled") {
|
|
1095
|
+
throw new Error(errors.status.completeCancelledTask);
|
|
1096
|
+
}
|
|
1097
|
+
// AC: @spec-completion-enforcement ac-1 - Only pending_review allowed
|
|
1098
|
+
if (latestTask.status !== "pending_review") {
|
|
1099
|
+
throw new Error(errors.status.cannotComplete(latestTask.status));
|
|
1100
|
+
}
|
|
1008
1101
|
}
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1102
|
+
// AC: @spec-completion-enforcement ac-7 - Document skip-review reason
|
|
1103
|
+
// AC: @spec-completion-enforcement ac-author
|
|
1104
|
+
let taskNotes = latestTask.notes;
|
|
1105
|
+
if (options.skipReview && options.reason) {
|
|
1106
|
+
const skipNote = createNote(`Completed with --skip-review: ${options.reason}`, getAuthor(ctx.config?.identity?.author));
|
|
1107
|
+
taskNotes = [...taskNotes, skipNote];
|
|
1108
|
+
}
|
|
1109
|
+
// AC: @task-commands ac-1 - Document force completion from non-standard state
|
|
1110
|
+
forcedFromNonStandard =
|
|
1111
|
+
forcingCompletion &&
|
|
1112
|
+
latestTask.status !== "pending_review";
|
|
1113
|
+
if (forcedFromNonStandard) {
|
|
1114
|
+
forceStateDetail = `from ${latestTask.status} state`;
|
|
1115
|
+
if (latestTask.status === "blocked") {
|
|
1116
|
+
const blockedBy = latestTask.blocked_by.join("; ");
|
|
1117
|
+
forceStateDetail += `. Was blocked by: ${blockedBy || "(dependency-blocked)"}`;
|
|
1118
|
+
}
|
|
1119
|
+
let forceMessage = `Completed with --force ${forceStateDetail}`;
|
|
1120
|
+
if (options.reason) {
|
|
1121
|
+
forceMessage += `. Reason: ${options.reason}`;
|
|
1122
|
+
}
|
|
1123
|
+
const forceNote = createNote(forceMessage, getAuthor(ctx.config?.identity?.author));
|
|
1124
|
+
taskNotes = [...taskNotes, forceNote];
|
|
1125
|
+
}
|
|
1126
|
+
return {
|
|
1127
|
+
...latestTask,
|
|
1128
|
+
status: "completed",
|
|
1129
|
+
completed_at: now,
|
|
1130
|
+
closed_reason: options.reason || null,
|
|
1131
|
+
started_at: latestTask.started_at || now,
|
|
1132
|
+
notes: taskNotes,
|
|
1133
|
+
};
|
|
1134
|
+
});
|
|
1022
1135
|
await commitIfShadow(ctx.shadow, "task-complete", foundTask.slugs[0] || index.shortUlid(foundTask._ulid), options.reason);
|
|
1136
|
+
// AC: @daemon-agent-dispatch ac-2, ac-7 - Notify daemon of state change (fire-and-forget)
|
|
1137
|
+
postDispatchEvent({
|
|
1138
|
+
taskId: updatedTask._ulid,
|
|
1139
|
+
taskRef: `@${updatedTask.slugs[0] || updatedTask._ulid}`,
|
|
1140
|
+
fromStatus: transitionFromStatus,
|
|
1141
|
+
toStatus: updatedTask.status,
|
|
1142
|
+
projectPath: ctx.rootDir,
|
|
1143
|
+
});
|
|
1023
1144
|
// Sync spec implementation status (unless --no-sync)
|
|
1024
1145
|
if (options.sync !== false && foundTask.spec_ref) {
|
|
1025
1146
|
const updatedTasks = tasks.map((t) => t._ulid === updatedTask._ulid ? { ...t, ...updatedTask } : t);
|
|
@@ -1086,26 +1207,54 @@ Examples:
|
|
|
1086
1207
|
});
|
|
1087
1208
|
// kspec task submit <ref>
|
|
1088
1209
|
// Transitions in_progress → pending_review (code done, awaiting merge)
|
|
1210
|
+
// AC: @task-submit ac-submit-1, ac-submit-2, ac-submit-3
|
|
1089
1211
|
markMutating(task.command("submit <ref>"))
|
|
1090
1212
|
.description("Submit task for review (transitions to pending_review)")
|
|
1091
|
-
.
|
|
1213
|
+
.option("--review-url <url>", "PR or review URL")
|
|
1214
|
+
.action(async (ref, options) => {
|
|
1092
1215
|
try {
|
|
1216
|
+
// AC: @task-submit ac-submit-3 - Validate URL before any state change
|
|
1217
|
+
if (options.reviewUrl !== undefined) {
|
|
1218
|
+
try {
|
|
1219
|
+
new URL(options.reviewUrl);
|
|
1220
|
+
}
|
|
1221
|
+
catch {
|
|
1222
|
+
error(`Invalid review URL: ${options.reviewUrl}`);
|
|
1223
|
+
process.exit(EXIT_CODES.VALIDATION_FAILED);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1093
1226
|
const ctx = await initContext();
|
|
1094
1227
|
const tasks = await loadAllTasks(ctx);
|
|
1095
1228
|
const items = await loadAllItems(ctx);
|
|
1096
1229
|
const index = new ReferenceIndex(tasks, items);
|
|
1097
1230
|
const foundTask = resolveTaskRef(ref, tasks, index);
|
|
1098
|
-
|
|
1099
|
-
|
|
1231
|
+
let transitionFromStatus = foundTask.status;
|
|
1232
|
+
const updatedTask = await mutateTaskAtomically(ctx, foundTask, (latestTask) => {
|
|
1233
|
+
transitionFromStatus = latestTask.status;
|
|
1234
|
+
if (latestTask.status !== "in_progress") {
|
|
1235
|
+
return latestTask;
|
|
1236
|
+
}
|
|
1237
|
+
return {
|
|
1238
|
+
...latestTask,
|
|
1239
|
+
status: "pending_review",
|
|
1240
|
+
submitted_at: new Date().toISOString(),
|
|
1241
|
+
// AC: @task-submit ac-submit-2
|
|
1242
|
+
...(options.reviewUrl !== undefined && { review_url: options.reviewUrl }),
|
|
1243
|
+
};
|
|
1244
|
+
});
|
|
1245
|
+
if (transitionFromStatus !== "in_progress") {
|
|
1246
|
+
error(`Cannot submit task with status: ${transitionFromStatus}. Task must be in_progress.`);
|
|
1100
1247
|
process.exit(EXIT_CODES.VALIDATION_FAILED);
|
|
1101
1248
|
}
|
|
1102
|
-
const updatedTask = {
|
|
1103
|
-
...foundTask,
|
|
1104
|
-
status: "pending_review",
|
|
1105
|
-
submitted_at: new Date().toISOString(),
|
|
1106
|
-
};
|
|
1107
|
-
await saveTask(ctx, updatedTask);
|
|
1108
1249
|
await commitIfShadow(ctx.shadow, "task-submit", foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
|
|
1250
|
+
// AC: @daemon-agent-dispatch ac-2, ac-7 - Notify daemon of state change (fire-and-forget)
|
|
1251
|
+
postDispatchEvent({
|
|
1252
|
+
taskId: updatedTask._ulid,
|
|
1253
|
+
taskRef: `@${updatedTask.slugs[0] || updatedTask._ulid}`,
|
|
1254
|
+
fromStatus: transitionFromStatus,
|
|
1255
|
+
toStatus: updatedTask.status,
|
|
1256
|
+
projectPath: ctx.rootDir,
|
|
1257
|
+
});
|
|
1109
1258
|
success(`Submitted task for review: ${index.shortUlid(updatedTask._ulid)}`, { task: updatedTask });
|
|
1110
1259
|
}
|
|
1111
1260
|
catch (err) {
|
|
@@ -1125,23 +1274,38 @@ Examples:
|
|
|
1125
1274
|
const items = await loadAllItems(ctx);
|
|
1126
1275
|
const index = new ReferenceIndex(tasks, items);
|
|
1127
1276
|
const foundTask = resolveTaskRef(ref, tasks, index);
|
|
1128
|
-
|
|
1129
|
-
|
|
1277
|
+
let transitionFromStatus = foundTask.status;
|
|
1278
|
+
let cycleNumber = 0;
|
|
1279
|
+
const updatedTask = await mutateTaskAtomically(ctx, foundTask, (latestTask) => {
|
|
1280
|
+
transitionFromStatus = latestTask.status;
|
|
1281
|
+
if (latestTask.status !== "pending_review") {
|
|
1282
|
+
return latestTask;
|
|
1283
|
+
}
|
|
1284
|
+
// Track fix cycle count from existing kickback notes
|
|
1285
|
+
const existingKickbacks = latestTask.notes.filter((note) => note.content.includes("[FIX_CYCLE:")).length;
|
|
1286
|
+
cycleNumber = existingKickbacks + 1;
|
|
1287
|
+
const note = createNote(`[FIX_CYCLE: ${cycleNumber}] Review findings: ${options.reason}`, getAuthor(ctx.config?.identity?.author));
|
|
1288
|
+
// AC: @session-scoped-task-claiming ac-claim-clear
|
|
1289
|
+
return {
|
|
1290
|
+
...latestTask,
|
|
1291
|
+
status: "needs_work",
|
|
1292
|
+
session_id: null,
|
|
1293
|
+
notes: [...latestTask.notes, note],
|
|
1294
|
+
};
|
|
1295
|
+
});
|
|
1296
|
+
if (transitionFromStatus !== "pending_review") {
|
|
1297
|
+
error(errors.status.cannotNeedsWork(transitionFromStatus));
|
|
1130
1298
|
process.exit(EXIT_CODES.VALIDATION_FAILED);
|
|
1131
1299
|
}
|
|
1132
|
-
// Track fix cycle count from existing kickback notes
|
|
1133
|
-
const existingKickbacks = foundTask.notes.filter((n) => n.content.includes("[FIX_CYCLE:")).length;
|
|
1134
|
-
const cycleNumber = existingKickbacks + 1;
|
|
1135
|
-
const note = createNote(`[FIX_CYCLE: ${cycleNumber}] Review findings: ${options.reason}`, getAuthor(ctx.config?.identity?.author));
|
|
1136
|
-
// AC: @session-scoped-task-claiming ac-claim-clear
|
|
1137
|
-
const updatedTask = {
|
|
1138
|
-
...foundTask,
|
|
1139
|
-
status: "needs_work",
|
|
1140
|
-
session_id: null,
|
|
1141
|
-
notes: [...foundTask.notes, note],
|
|
1142
|
-
};
|
|
1143
|
-
await saveTask(ctx, updatedTask);
|
|
1144
1300
|
await commitIfShadow(ctx.shadow, "task-needs-work", foundTask.slugs[0] || index.shortUlid(foundTask._ulid), `cycle ${cycleNumber}`);
|
|
1301
|
+
// AC: @daemon-agent-dispatch ac-2, ac-7 - Notify daemon of state change (fire-and-forget)
|
|
1302
|
+
postDispatchEvent({
|
|
1303
|
+
taskId: updatedTask._ulid,
|
|
1304
|
+
taskRef: `@${updatedTask.slugs[0] || updatedTask._ulid}`,
|
|
1305
|
+
fromStatus: transitionFromStatus,
|
|
1306
|
+
toStatus: updatedTask.status,
|
|
1307
|
+
projectPath: ctx.rootDir,
|
|
1308
|
+
});
|
|
1145
1309
|
success(`Kicked back task: ${index.shortUlid(updatedTask._ulid)} (fix cycle ${cycleNumber})`, { task: updatedTask });
|
|
1146
1310
|
}
|
|
1147
1311
|
catch (err) {
|
|
@@ -1160,18 +1324,33 @@ Examples:
|
|
|
1160
1324
|
const items = await loadAllItems(ctx);
|
|
1161
1325
|
const index = new ReferenceIndex(tasks, items);
|
|
1162
1326
|
const foundTask = resolveTaskRef(ref, tasks, index);
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1327
|
+
let transitionFromStatus = foundTask.status;
|
|
1328
|
+
const updatedTask = await mutateTaskAtomically(ctx, foundTask, (latestTask) => {
|
|
1329
|
+
transitionFromStatus = latestTask.status;
|
|
1330
|
+
if (latestTask.status === "completed" ||
|
|
1331
|
+
latestTask.status === "cancelled") {
|
|
1332
|
+
return latestTask;
|
|
1333
|
+
}
|
|
1334
|
+
return {
|
|
1335
|
+
...latestTask,
|
|
1336
|
+
status: "blocked",
|
|
1337
|
+
blocked_by: [...latestTask.blocked_by, options.reason],
|
|
1338
|
+
};
|
|
1339
|
+
});
|
|
1340
|
+
if (transitionFromStatus === "completed" ||
|
|
1341
|
+
transitionFromStatus === "cancelled") {
|
|
1342
|
+
error(errors.status.cannotBlock(transitionFromStatus));
|
|
1166
1343
|
process.exit(EXIT_CODES.VALIDATION_FAILED);
|
|
1167
1344
|
}
|
|
1168
|
-
const updatedTask = {
|
|
1169
|
-
...foundTask,
|
|
1170
|
-
status: "blocked",
|
|
1171
|
-
blocked_by: [...foundTask.blocked_by, options.reason],
|
|
1172
|
-
};
|
|
1173
|
-
await saveTask(ctx, updatedTask);
|
|
1174
1345
|
await commitIfShadow(ctx.shadow, "task-block", foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
|
|
1346
|
+
// AC: @daemon-agent-dispatch ac-2, ac-7 - Notify daemon of state change (fire-and-forget)
|
|
1347
|
+
postDispatchEvent({
|
|
1348
|
+
taskId: updatedTask._ulid,
|
|
1349
|
+
taskRef: `@${updatedTask.slugs[0] || updatedTask._ulid}`,
|
|
1350
|
+
fromStatus: transitionFromStatus,
|
|
1351
|
+
toStatus: updatedTask.status,
|
|
1352
|
+
projectPath: ctx.rootDir,
|
|
1353
|
+
});
|
|
1175
1354
|
success(`Blocked task: ${index.shortUlid(updatedTask._ulid)}`, {
|
|
1176
1355
|
task: updatedTask,
|
|
1177
1356
|
});
|
|
@@ -1191,19 +1370,33 @@ Examples:
|
|
|
1191
1370
|
const items = await loadAllItems(ctx);
|
|
1192
1371
|
const index = new ReferenceIndex(tasks, items);
|
|
1193
1372
|
const foundTask = resolveTaskRef(ref, tasks, index);
|
|
1194
|
-
|
|
1373
|
+
let transitionFromStatus = foundTask.status;
|
|
1374
|
+
const updatedTask = await mutateTaskAtomically(ctx, foundTask, (latestTask) => {
|
|
1375
|
+
transitionFromStatus = latestTask.status;
|
|
1376
|
+
if (latestTask.status !== "blocked") {
|
|
1377
|
+
return latestTask;
|
|
1378
|
+
}
|
|
1379
|
+
// AC: @session-scoped-task-claiming ac-claim-clear
|
|
1380
|
+
return {
|
|
1381
|
+
...latestTask,
|
|
1382
|
+
status: "pending",
|
|
1383
|
+
blocked_by: [],
|
|
1384
|
+
session_id: null,
|
|
1385
|
+
};
|
|
1386
|
+
});
|
|
1387
|
+
if (transitionFromStatus !== "blocked") {
|
|
1195
1388
|
warn("Task is not blocked");
|
|
1196
1389
|
return;
|
|
1197
1390
|
}
|
|
1198
|
-
// AC: @session-scoped-task-claiming ac-claim-clear
|
|
1199
|
-
const updatedTask = {
|
|
1200
|
-
...foundTask,
|
|
1201
|
-
status: "pending",
|
|
1202
|
-
blocked_by: [],
|
|
1203
|
-
session_id: null,
|
|
1204
|
-
};
|
|
1205
|
-
await saveTask(ctx, updatedTask);
|
|
1206
1391
|
await commitIfShadow(ctx.shadow, "task-unblock", foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
|
|
1392
|
+
// AC: @daemon-agent-dispatch ac-2, ac-7 - Notify daemon of state change (fire-and-forget)
|
|
1393
|
+
postDispatchEvent({
|
|
1394
|
+
taskId: updatedTask._ulid,
|
|
1395
|
+
taskRef: `@${updatedTask.slugs[0] || updatedTask._ulid}`,
|
|
1396
|
+
fromStatus: transitionFromStatus,
|
|
1397
|
+
toStatus: updatedTask.status,
|
|
1398
|
+
projectPath: ctx.rootDir,
|
|
1399
|
+
});
|
|
1207
1400
|
success(`Unblocked task: ${index.shortUlid(updatedTask._ulid)}`, {
|
|
1208
1401
|
task: updatedTask,
|
|
1209
1402
|
});
|
|
@@ -1288,54 +1481,60 @@ Examples:
|
|
|
1288
1481
|
const items = await loadAllItems(ctx);
|
|
1289
1482
|
const index = new ReferenceIndex(tasks, items);
|
|
1290
1483
|
const foundTask = resolveTaskRef(ref, tasks, index);
|
|
1291
|
-
|
|
1292
|
-
|
|
1484
|
+
let previousStatus = foundTask.status;
|
|
1485
|
+
const clearedFields = [];
|
|
1486
|
+
const updatedTask = await mutateTaskAtomically(ctx, foundTask, (latestTask) => {
|
|
1487
|
+
previousStatus = latestTask.status;
|
|
1488
|
+
// AC: @spec-task-reset ac-2 - Error if already pending
|
|
1489
|
+
if (latestTask.status === "pending") {
|
|
1490
|
+
return latestTask;
|
|
1491
|
+
}
|
|
1492
|
+
// AC: @spec-task-reset ac-1 - Reset to pending, clear completion-related fields
|
|
1493
|
+
const nextTask = {
|
|
1494
|
+
...latestTask,
|
|
1495
|
+
status: "pending",
|
|
1496
|
+
};
|
|
1497
|
+
clearedFields.splice(0, clearedFields.length);
|
|
1498
|
+
// Clear timestamps and reasons based on previous status
|
|
1499
|
+
if (latestTask.completed_at !== undefined &&
|
|
1500
|
+
latestTask.completed_at !== null) {
|
|
1501
|
+
nextTask.completed_at = null;
|
|
1502
|
+
clearedFields.push("completed_at");
|
|
1503
|
+
}
|
|
1504
|
+
if (latestTask.started_at !== undefined &&
|
|
1505
|
+
latestTask.started_at !== null) {
|
|
1506
|
+
nextTask.started_at = null;
|
|
1507
|
+
clearedFields.push("started_at");
|
|
1508
|
+
}
|
|
1509
|
+
if (latestTask.closed_reason !== undefined &&
|
|
1510
|
+
latestTask.closed_reason !== null) {
|
|
1511
|
+
nextTask.closed_reason = null;
|
|
1512
|
+
clearedFields.push("closed_reason");
|
|
1513
|
+
}
|
|
1514
|
+
if (latestTask.blocked_by.length > 0) {
|
|
1515
|
+
nextTask.blocked_by = [];
|
|
1516
|
+
clearedFields.push("blocked_by");
|
|
1517
|
+
}
|
|
1518
|
+
// AC: @session-scoped-task-claiming ac-claim-clear
|
|
1519
|
+
if (latestTask.session_id) {
|
|
1520
|
+
nextTask.session_id = null;
|
|
1521
|
+
clearedFields.push("session_id");
|
|
1522
|
+
}
|
|
1523
|
+
// AC: @spec-task-reset ac-4 - Add note documenting the reset
|
|
1524
|
+
// AC: @spec-task-reset ac-author
|
|
1525
|
+
const hadCancelReason = latestTask.closed_reason && latestTask.status === "cancelled";
|
|
1526
|
+
const cancelReasonText = hadCancelReason
|
|
1527
|
+
? ` (was cancelled: ${latestTask.closed_reason})`
|
|
1528
|
+
: "";
|
|
1529
|
+
const noteContent = `Reset from ${latestTask.status} to pending${cancelReasonText}`;
|
|
1530
|
+
const note = createNote(noteContent, getAuthor(ctx.config?.identity?.author));
|
|
1531
|
+
nextTask.notes = [...nextTask.notes, note];
|
|
1532
|
+
return nextTask;
|
|
1533
|
+
});
|
|
1534
|
+
if (previousStatus === "pending") {
|
|
1293
1535
|
error("Task is already pending");
|
|
1294
1536
|
process.exit(EXIT_CODES.VALIDATION_FAILED);
|
|
1295
1537
|
}
|
|
1296
|
-
// Track previous status and reason for note (AC-4)
|
|
1297
|
-
const previousStatus = foundTask.status;
|
|
1298
|
-
const hadCancelReason = foundTask.closed_reason && foundTask.status === "cancelled";
|
|
1299
|
-
const cancelReasonText = hadCancelReason
|
|
1300
|
-
? ` (was cancelled: ${foundTask.closed_reason})`
|
|
1301
|
-
: "";
|
|
1302
|
-
// AC: @spec-task-reset ac-1 - Reset to pending, clear completion-related fields
|
|
1303
|
-
const clearedFields = [];
|
|
1304
|
-
const updatedTask = {
|
|
1305
|
-
...foundTask,
|
|
1306
|
-
status: "pending",
|
|
1307
|
-
};
|
|
1308
|
-
// Clear timestamps and reasons based on previous status
|
|
1309
|
-
if (foundTask.completed_at !== undefined &&
|
|
1310
|
-
foundTask.completed_at !== null) {
|
|
1311
|
-
updatedTask.completed_at = null;
|
|
1312
|
-
clearedFields.push("completed_at");
|
|
1313
|
-
}
|
|
1314
|
-
if (foundTask.started_at !== undefined &&
|
|
1315
|
-
foundTask.started_at !== null) {
|
|
1316
|
-
updatedTask.started_at = null;
|
|
1317
|
-
clearedFields.push("started_at");
|
|
1318
|
-
}
|
|
1319
|
-
if (foundTask.closed_reason !== undefined &&
|
|
1320
|
-
foundTask.closed_reason !== null) {
|
|
1321
|
-
updatedTask.closed_reason = null;
|
|
1322
|
-
clearedFields.push("closed_reason");
|
|
1323
|
-
}
|
|
1324
|
-
if (foundTask.blocked_by.length > 0) {
|
|
1325
|
-
updatedTask.blocked_by = [];
|
|
1326
|
-
clearedFields.push("blocked_by");
|
|
1327
|
-
}
|
|
1328
|
-
// AC: @session-scoped-task-claiming ac-claim-clear
|
|
1329
|
-
if (foundTask.session_id) {
|
|
1330
|
-
updatedTask.session_id = null;
|
|
1331
|
-
clearedFields.push("session_id");
|
|
1332
|
-
}
|
|
1333
|
-
// AC: @spec-task-reset ac-4 - Add note documenting the reset
|
|
1334
|
-
// AC: @spec-task-reset ac-author
|
|
1335
|
-
const noteContent = `Reset from ${previousStatus} to pending${cancelReasonText}`;
|
|
1336
|
-
const note = createNote(noteContent, getAuthor(ctx.config?.identity?.author));
|
|
1337
|
-
updatedTask.notes = [...updatedTask.notes, note];
|
|
1338
|
-
await saveTask(ctx, updatedTask);
|
|
1339
1538
|
// AC: @spec-task-reset ac-3 - Shadow commit with message task-reset
|
|
1340
1539
|
await commitIfShadow(ctx.shadow, "task-reset", foundTask.slugs[0] || index.shortUlid(foundTask._ulid), `from ${previousStatus}`);
|
|
1341
1540
|
// AC: @spec-task-reset ac-6 - JSON output includes previous_status, new_status, cleared_fields
|
|
@@ -1456,11 +1655,10 @@ Examples:
|
|
|
1456
1655
|
const index = new ReferenceIndex(tasks, items);
|
|
1457
1656
|
const foundTask = resolveTaskRef(ref, tasks, index);
|
|
1458
1657
|
const note = createNote(message, options.author, options.supersedes);
|
|
1459
|
-
const updatedTask = {
|
|
1460
|
-
...
|
|
1461
|
-
notes: [...
|
|
1462
|
-
};
|
|
1463
|
-
await saveTask(ctx, updatedTask);
|
|
1658
|
+
const updatedTask = await mutateTaskAtomically(ctx, foundTask, (latestTask) => ({
|
|
1659
|
+
...latestTask,
|
|
1660
|
+
notes: [...latestTask.notes, note],
|
|
1661
|
+
}));
|
|
1464
1662
|
await commitIfShadow(ctx.shadow, "task-note", foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
|
|
1465
1663
|
success(`Added note to task: ${index.shortUlid(updatedTask._ulid)}`, {
|
|
1466
1664
|
note,
|
|
@@ -1558,8 +1756,11 @@ Examples:
|
|
|
1558
1756
|
possibleRefs.push(`@${specItem.slugs[0]} ${ac.id}`);
|
|
1559
1757
|
possibleRefs.push(`@${specItem.slugs[0]}`);
|
|
1560
1758
|
}
|
|
1561
|
-
|
|
1562
|
-
|
|
1759
|
+
for (let prefixLength = 8; prefixLength <= specItem._ulid.length; prefixLength++) {
|
|
1760
|
+
const prefix = specItem._ulid.slice(0, prefixLength);
|
|
1761
|
+
possibleRefs.push(`@${prefix} ${ac.id}`);
|
|
1762
|
+
possibleRefs.push(`@${prefix}`);
|
|
1763
|
+
}
|
|
1563
1764
|
const isCovered = possibleRefs.some((ref) => coveredACs.has(ref));
|
|
1564
1765
|
if (isCovered) {
|
|
1565
1766
|
covered.push(ac.id);
|
|
@@ -1698,16 +1899,18 @@ Examples:
|
|
|
1698
1899
|
const items = await loadAllItems(ctx);
|
|
1699
1900
|
const index = new ReferenceIndex(tasks, items);
|
|
1700
1901
|
const foundTask = resolveTaskRef(ref, tasks, index);
|
|
1701
|
-
|
|
1702
|
-
const
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1902
|
+
let todo = createTodo(1, text, options.author);
|
|
1903
|
+
const updatedTask = await mutateTaskAtomically(ctx, foundTask, (latestTask) => {
|
|
1904
|
+
// Calculate next ID (max existing + 1, or 1 if none)
|
|
1905
|
+
const nextId = latestTask.todos.length > 0
|
|
1906
|
+
? Math.max(...latestTask.todos.map((entry) => entry.id)) + 1
|
|
1907
|
+
: 1;
|
|
1908
|
+
todo = createTodo(nextId, text, options.author);
|
|
1909
|
+
return {
|
|
1910
|
+
...latestTask,
|
|
1911
|
+
todos: [...latestTask.todos, todo],
|
|
1912
|
+
};
|
|
1913
|
+
});
|
|
1711
1914
|
await commitIfShadow(ctx.shadow, "task-note", foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
|
|
1712
1915
|
success(`Added todo #${todo.id} to task: ${index.shortUlid(updatedTask._ulid)}`, { todo });
|
|
1713
1916
|
}
|
|
@@ -1731,29 +1934,42 @@ Examples:
|
|
|
1731
1934
|
error(errors.todo.invalidId(idStr));
|
|
1732
1935
|
process.exit(EXIT_CODES.USAGE_ERROR);
|
|
1733
1936
|
}
|
|
1734
|
-
|
|
1735
|
-
|
|
1937
|
+
let todoState;
|
|
1938
|
+
let updatedTodo;
|
|
1939
|
+
await mutateTaskAtomically(ctx, foundTask, (latestTask) => {
|
|
1940
|
+
const todoIndex = latestTask.todos.findIndex((todo) => todo.id === id);
|
|
1941
|
+
if (todoIndex === -1) {
|
|
1942
|
+
todoState = "not_found";
|
|
1943
|
+
return latestTask;
|
|
1944
|
+
}
|
|
1945
|
+
if (latestTask.todos[todoIndex].done) {
|
|
1946
|
+
todoState = "already_done";
|
|
1947
|
+
updatedTodo = latestTask.todos[todoIndex];
|
|
1948
|
+
return latestTask;
|
|
1949
|
+
}
|
|
1950
|
+
const updatedTodos = [...latestTask.todos];
|
|
1951
|
+
updatedTodos[todoIndex] = {
|
|
1952
|
+
...updatedTodos[todoIndex],
|
|
1953
|
+
done: true,
|
|
1954
|
+
done_at: new Date().toISOString(),
|
|
1955
|
+
};
|
|
1956
|
+
updatedTodo = updatedTodos[todoIndex];
|
|
1957
|
+
return {
|
|
1958
|
+
...latestTask,
|
|
1959
|
+
todos: updatedTodos,
|
|
1960
|
+
};
|
|
1961
|
+
});
|
|
1962
|
+
if (todoState === "not_found") {
|
|
1736
1963
|
error(errors.todo.notFound(id));
|
|
1737
1964
|
process.exit(EXIT_CODES.NOT_FOUND);
|
|
1738
1965
|
}
|
|
1739
|
-
if (
|
|
1966
|
+
if (todoState === "already_done") {
|
|
1740
1967
|
warn(`Todo #${id} is already done`);
|
|
1741
1968
|
return;
|
|
1742
1969
|
}
|
|
1743
|
-
const updatedTodos = [...foundTask.todos];
|
|
1744
|
-
updatedTodos[todoIndex] = {
|
|
1745
|
-
...updatedTodos[todoIndex],
|
|
1746
|
-
done: true,
|
|
1747
|
-
done_at: new Date().toISOString(),
|
|
1748
|
-
};
|
|
1749
|
-
const updatedTask = {
|
|
1750
|
-
...foundTask,
|
|
1751
|
-
todos: updatedTodos,
|
|
1752
|
-
};
|
|
1753
|
-
await saveTask(ctx, updatedTask);
|
|
1754
1970
|
await commitIfShadow(ctx.shadow, "task-note", foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
|
|
1755
1971
|
success(`Marked todo #${id} as done`, {
|
|
1756
|
-
todo:
|
|
1972
|
+
todo: updatedTodo,
|
|
1757
1973
|
});
|
|
1758
1974
|
}
|
|
1759
1975
|
catch (err) {
|
|
@@ -1776,29 +1992,42 @@ Examples:
|
|
|
1776
1992
|
error(errors.todo.invalidId(idStr));
|
|
1777
1993
|
process.exit(EXIT_CODES.USAGE_ERROR);
|
|
1778
1994
|
}
|
|
1779
|
-
|
|
1780
|
-
|
|
1995
|
+
let todoState;
|
|
1996
|
+
let updatedTodo;
|
|
1997
|
+
await mutateTaskAtomically(ctx, foundTask, (latestTask) => {
|
|
1998
|
+
const todoIndex = latestTask.todos.findIndex((todo) => todo.id === id);
|
|
1999
|
+
if (todoIndex === -1) {
|
|
2000
|
+
todoState = "not_found";
|
|
2001
|
+
return latestTask;
|
|
2002
|
+
}
|
|
2003
|
+
if (!latestTask.todos[todoIndex].done) {
|
|
2004
|
+
todoState = "already_not_done";
|
|
2005
|
+
updatedTodo = latestTask.todos[todoIndex];
|
|
2006
|
+
return latestTask;
|
|
2007
|
+
}
|
|
2008
|
+
const updatedTodos = [...latestTask.todos];
|
|
2009
|
+
updatedTodos[todoIndex] = {
|
|
2010
|
+
...updatedTodos[todoIndex],
|
|
2011
|
+
done: false,
|
|
2012
|
+
done_at: undefined,
|
|
2013
|
+
};
|
|
2014
|
+
updatedTodo = updatedTodos[todoIndex];
|
|
2015
|
+
return {
|
|
2016
|
+
...latestTask,
|
|
2017
|
+
todos: updatedTodos,
|
|
2018
|
+
};
|
|
2019
|
+
});
|
|
2020
|
+
if (todoState === "not_found") {
|
|
1781
2021
|
error(errors.todo.notFound(id));
|
|
1782
2022
|
process.exit(EXIT_CODES.NOT_FOUND);
|
|
1783
2023
|
}
|
|
1784
|
-
if (
|
|
2024
|
+
if (todoState === "already_not_done") {
|
|
1785
2025
|
warn(`Todo #${id} is not done`);
|
|
1786
2026
|
return;
|
|
1787
2027
|
}
|
|
1788
|
-
const updatedTodos = [...foundTask.todos];
|
|
1789
|
-
updatedTodos[todoIndex] = {
|
|
1790
|
-
...updatedTodos[todoIndex],
|
|
1791
|
-
done: false,
|
|
1792
|
-
done_at: undefined,
|
|
1793
|
-
};
|
|
1794
|
-
const updatedTask = {
|
|
1795
|
-
...foundTask,
|
|
1796
|
-
todos: updatedTodos,
|
|
1797
|
-
};
|
|
1798
|
-
await saveTask(ctx, updatedTask);
|
|
1799
2028
|
await commitIfShadow(ctx.shadow, "task-note", foundTask.slugs[0] || index.shortUlid(foundTask._ulid));
|
|
1800
2029
|
success(`Marked todo #${id} as not done`, {
|
|
1801
|
-
todo:
|
|
2030
|
+
todo: updatedTodo,
|
|
1802
2031
|
});
|
|
1803
2032
|
}
|
|
1804
2033
|
catch (err) {
|