@thinkingsage/kanon 0.8.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/CHANGELOG.md +410 -0
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/bridge/mcp-server.cjs +14171 -0
- package/package.json +98 -0
- package/src/adapters/capabilities.ts +178 -0
- package/src/adapters/claude-code.ts +110 -0
- package/src/adapters/cline.ts +98 -0
- package/src/adapters/codex.ts +173 -0
- package/src/adapters/copilot.ts +106 -0
- package/src/adapters/cursor.ts +97 -0
- package/src/adapters/degradation.ts +95 -0
- package/src/adapters/index.ts +324 -0
- package/src/adapters/kiro-frontmatter.ts +139 -0
- package/src/adapters/kiro-inclusion.ts +86 -0
- package/src/adapters/kiro.ts +412 -0
- package/src/adapters/qdeveloper.ts +115 -0
- package/src/adapters/types.ts +81 -0
- package/src/adapters/windsurf.ts +96 -0
- package/src/admin.ts +283 -0
- package/src/asset-conventions.ts +118 -0
- package/src/attribution-backfill.ts +319 -0
- package/src/attribution-report.ts +95 -0
- package/src/attribution.ts +239 -0
- package/src/backends/github.ts +194 -0
- package/src/backends/http.ts +122 -0
- package/src/backends/index.ts +39 -0
- package/src/backends/local.ts +47 -0
- package/src/backends/s3.ts +157 -0
- package/src/backends/types.ts +59 -0
- package/src/base-cache.ts +270 -0
- package/src/browse-ui.ts +3754 -0
- package/src/browse.ts +1038 -0
- package/src/build.ts +1108 -0
- package/src/catalog.ts +204 -0
- package/src/cli-deprecated.ts +29 -0
- package/src/cli.ts +773 -0
- package/src/collection-admin.ts +287 -0
- package/src/collection-builder.ts +464 -0
- package/src/collections.ts +116 -0
- package/src/compatibility.ts +105 -0
- package/src/config.ts +743 -0
- package/src/eval/rubrics/kiro-progressive-steering.ts +841 -0
- package/src/eval.ts +1169 -0
- package/src/file-writer.ts +61 -0
- package/src/format-registry.ts +141 -0
- package/src/guild/auto-updater.ts +163 -0
- package/src/guild/backend-resolver.ts +49 -0
- package/src/guild/cli.ts +592 -0
- package/src/guild/collection-expander.ts +47 -0
- package/src/guild/global-cache.ts +247 -0
- package/src/guild/hook-generator.ts +100 -0
- package/src/guild/manifest.ts +154 -0
- package/src/guild/path-utils.ts +12 -0
- package/src/guild/sync.ts +622 -0
- package/src/guild/version-resolver.ts +42 -0
- package/src/help/metadata.ts +445 -0
- package/src/help/renderer.ts +265 -0
- package/src/help/typo-suggester.ts +25 -0
- package/src/hooks/expression.ts +493 -0
- package/src/hooks/pipeline.ts +141 -0
- package/src/import.ts +773 -0
- package/src/importers/claude-code.ts +134 -0
- package/src/importers/cline.ts +103 -0
- package/src/importers/codex.ts +140 -0
- package/src/importers/copilot.ts +103 -0
- package/src/importers/cursor.ts +105 -0
- package/src/importers/index.ts +390 -0
- package/src/importers/kiro.ts +110 -0
- package/src/importers/qdeveloper.ts +103 -0
- package/src/importers/types.ts +54 -0
- package/src/importers/windsurf.ts +104 -0
- package/src/install.ts +1005 -0
- package/src/manifest-admin.ts +306 -0
- package/src/mcp-bridge.ts +240 -0
- package/src/mutation/delta.ts +50 -0
- package/src/mutation/history.ts +66 -0
- package/src/mutation/operators.ts +524 -0
- package/src/mutation/runner.ts +332 -0
- package/src/new.ts +106 -0
- package/src/outcomes/collision.ts +127 -0
- package/src/outcomes/normalize.ts +208 -0
- package/src/outcomes/registry.ts +173 -0
- package/src/parser.ts +446 -0
- package/src/provenance-backfill-cli.ts +319 -0
- package/src/provenance-backfill.ts +520 -0
- package/src/publish.ts +354 -0
- package/src/reconcile-orchestrator.ts +502 -0
- package/src/reconcile-report-renderer.ts +176 -0
- package/src/resolve-body.ts +15 -0
- package/src/rosetta/builtins/compatibility-profiles.ts +297 -0
- package/src/rosetta/builtins/contracts.ts +1033 -0
- package/src/rosetta/builtins/pretty-printers/claude-code-native.ts +122 -0
- package/src/rosetta/builtins/pretty-printers/cline-native.ts +50 -0
- package/src/rosetta/builtins/pretty-printers/codex-native.ts +127 -0
- package/src/rosetta/builtins/pretty-printers/copilot-native.ts +50 -0
- package/src/rosetta/builtins/pretty-printers/cursor-native.ts +50 -0
- package/src/rosetta/builtins/pretty-printers/index.ts +81 -0
- package/src/rosetta/builtins/pretty-printers/kiro-native.ts +166 -0
- package/src/rosetta/builtins/pretty-printers/kiro-power.ts +108 -0
- package/src/rosetta/builtins/pretty-printers/kiro-skill.ts +88 -0
- package/src/rosetta/builtins/pretty-printers/qdeveloper-native.ts +51 -0
- package/src/rosetta/builtins/pretty-printers/superpowers.ts +97 -0
- package/src/rosetta/builtins/pretty-printers/windsurf-native.ts +50 -0
- package/src/rosetta/builtins/sources/claude-code-native.ts +348 -0
- package/src/rosetta/builtins/sources/cline-native.ts +176 -0
- package/src/rosetta/builtins/sources/codex-native.ts +343 -0
- package/src/rosetta/builtins/sources/copilot-native.ts +178 -0
- package/src/rosetta/builtins/sources/cursor-native.ts +176 -0
- package/src/rosetta/builtins/sources/index.ts +95 -0
- package/src/rosetta/builtins/sources/kiro-native.ts +462 -0
- package/src/rosetta/builtins/sources/kiro-power.ts +285 -0
- package/src/rosetta/builtins/sources/kiro-skill.ts +230 -0
- package/src/rosetta/builtins/sources/qdeveloper-native.ts +181 -0
- package/src/rosetta/builtins/sources/superpowers.ts +240 -0
- package/src/rosetta/builtins/sources/windsurf-native.ts +176 -0
- package/src/rosetta/builtins/targets/claude-code.ts +181 -0
- package/src/rosetta/builtins/targets/cline.ts +87 -0
- package/src/rosetta/builtins/targets/codex.ts +226 -0
- package/src/rosetta/builtins/targets/copilot.ts +103 -0
- package/src/rosetta/builtins/targets/cursor.ts +87 -0
- package/src/rosetta/builtins/targets/index.ts +60 -0
- package/src/rosetta/builtins/targets/kiro.ts +278 -0
- package/src/rosetta/builtins/targets/qdeveloper.ts +103 -0
- package/src/rosetta/builtins/targets/windsurf.ts +87 -0
- package/src/rosetta/canonical.ts +729 -0
- package/src/rosetta/compatibility.ts +432 -0
- package/src/rosetta/contracts.ts +329 -0
- package/src/rosetta/detector.ts +724 -0
- package/src/rosetta/diagnostics.ts +630 -0
- package/src/rosetta/engine-bootstrap.ts +103 -0
- package/src/rosetta/engine.ts +744 -0
- package/src/rosetta/index.ts +381 -0
- package/src/rosetta/inspection.ts +530 -0
- package/src/rosetta/plan.ts +448 -0
- package/src/rosetta/provenance-digest.ts +369 -0
- package/src/rosetta/reconcile.ts +812 -0
- package/src/rosetta/redaction.ts +467 -0
- package/src/rosetta/registry.ts +712 -0
- package/src/rosetta/renderers.ts +571 -0
- package/src/rosetta/request-guard.ts +335 -0
- package/src/rosetta/resolution.ts +419 -0
- package/src/rosetta/source-accounting.ts +233 -0
- package/src/rosetta/templates.ts +129 -0
- package/src/rosetta-cli.ts +717 -0
- package/src/rosetta-docs-generator.ts +793 -0
- package/src/rosetta-profiles-cli.ts +367 -0
- package/src/schemas.ts +1712 -0
- package/src/spec-coordination.ts +1141 -0
- package/src/temper.ts +747 -0
- package/src/template-bundle-loader.ts +312 -0
- package/src/template-engine.ts +53 -0
- package/src/translation-application-policy.ts +496 -0
- package/src/translation-orchestrator.ts +1013 -0
- package/src/translation-plan-applier.ts +473 -0
- package/src/tutorial.ts +305 -0
- package/src/validate.ts +1093 -0
- package/src/versioning.ts +553 -0
- package/src/wizard.ts +660 -0
- package/src/workspace.ts +237 -0
- package/templates/eval-contexts/claude-code.md.njk +6 -0
- package/templates/eval-contexts/cline.md.njk +6 -0
- package/templates/eval-contexts/copilot.md.njk +6 -0
- package/templates/eval-contexts/cursor.md.njk +6 -0
- package/templates/eval-contexts/kiro.md.njk +10 -0
- package/templates/eval-contexts/qdeveloper.md.njk +6 -0
- package/templates/eval-contexts/windsurf.md.njk +6 -0
- package/templates/harness-adapters/_base/attribution-footer.md.njk +17 -0
- package/templates/harness-adapters/_base/base.md.njk +16 -0
- package/templates/harness-adapters/claude-code/claude.md.njk +1 -0
- package/templates/harness-adapters/claude-code/mcp.json.njk +1 -0
- package/templates/harness-adapters/claude-code/settings.json.njk +1 -0
- package/templates/harness-adapters/claude-code/skill-library-index.md.njk +13 -0
- package/templates/harness-adapters/claude-code/skill.md.njk +19 -0
- package/templates/harness-adapters/cline/hook.sh.njk +4 -0
- package/templates/harness-adapters/cline/mcp.json.njk +1 -0
- package/templates/harness-adapters/cline/rule.md.njk +1 -0
- package/templates/harness-adapters/codex/agents-md.md.njk +6 -0
- package/templates/harness-adapters/codex/agents-pointer.md.njk +16 -0
- package/templates/harness-adapters/codex/skill.md.njk +27 -0
- package/templates/harness-adapters/copilot/agents.md.njk +1 -0
- package/templates/harness-adapters/copilot/instructions.md.njk +1 -0
- package/templates/harness-adapters/copilot/scoped.md.njk +6 -0
- package/templates/harness-adapters/cursor/mcp.json.njk +1 -0
- package/templates/harness-adapters/cursor/rule.md.njk +6 -0
- package/templates/harness-adapters/kiro/hook.json.njk +1 -0
- package/templates/harness-adapters/kiro/mcp.json.njk +1 -0
- package/templates/harness-adapters/kiro/power-steering.md.njk +3 -0
- package/templates/harness-adapters/kiro/power.md.njk +12 -0
- package/templates/harness-adapters/kiro/steering.md.njk +16 -0
- package/templates/harness-adapters/qdeveloper/agent.md.njk +1 -0
- package/templates/harness-adapters/qdeveloper/mcp.json.njk +1 -0
- package/templates/harness-adapters/qdeveloper/rule.md.njk +1 -0
- package/templates/harness-adapters/windsurf/mcp.json.njk +1 -0
- package/templates/harness-adapters/windsurf/rule.md.njk +1 -0
- package/templates/harness-adapters/windsurf/workflow.md.njk +1 -0
- package/templates/knowledge/hooks.yaml.njk +4 -0
- package/templates/knowledge/knowledge.md.njk +53 -0
- package/templates/knowledge/mcp-servers.yaml.njk +2 -0
|
@@ -0,0 +1,1141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-agent coordination for Kiro Specs.
|
|
3
|
+
*
|
|
4
|
+
* Kiro Specs (`.kiro/specs/<name>/`) hold three markdown files plus a
|
|
5
|
+
* Kiro-managed `.config.kiro` sidecar. Kiro's documented `tasks.md` format
|
|
6
|
+
* only defines two checkbox markers — `- [ ]` (open) and `- [x]` (done). This
|
|
7
|
+
* module additionally recognizes `- [~]` as a de facto "checked out / in
|
|
8
|
+
* progress" marker: it isn't part of Kiro's documented format, but it shows
|
|
9
|
+
* up widely in real tasks.md files under .kiro/specs/ written by AI coding
|
|
10
|
+
* agents as a convention of their own. Kiro's own IDE behavior toward an
|
|
11
|
+
* unrecognized marker (e.g. on Sync/Refine) is unconfirmed — treat `[~]` as
|
|
12
|
+
* this skill's convention for coordinating in-progress state, not a
|
|
13
|
+
* guaranteed-safe native Kiro feature. Ownership (which agent holds a claim,
|
|
14
|
+
* lease expiry, handoff notes) still has nowhere to live inline, so that
|
|
15
|
+
* state lives in a separate `COORDINATION.md` sidecar that Kiro leaves
|
|
16
|
+
* untouched.
|
|
17
|
+
*
|
|
18
|
+
* The spec's folder NAME is the identity key — `.config.kiro` `specId` values
|
|
19
|
+
* are not unique (copied/branched specs reuse them), so never key coordination
|
|
20
|
+
* on `specId`.
|
|
21
|
+
*
|
|
22
|
+
* This module is deliberately pure where it can be: parse/serialize/mutate are
|
|
23
|
+
* side-effect-free string functions; only the `*Command` actions and the small
|
|
24
|
+
* read/write helpers touch the filesystem.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { exists, readdir, readFile, writeFile } from "node:fs/promises";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
import chalk from "chalk";
|
|
30
|
+
|
|
31
|
+
export const COORDINATION_FILENAME = "COORDINATION.md";
|
|
32
|
+
export const TASKS_FILENAME = "tasks.md";
|
|
33
|
+
export const CONFIG_FILENAME = ".config.kiro";
|
|
34
|
+
|
|
35
|
+
/** Lifecycle of a task from a coordination standpoint. */
|
|
36
|
+
export type TaskCoordStatus =
|
|
37
|
+
| "open"
|
|
38
|
+
| "claimed"
|
|
39
|
+
| "in-progress"
|
|
40
|
+
| "done"
|
|
41
|
+
| "blocked";
|
|
42
|
+
|
|
43
|
+
export const TASK_COORD_STATUSES: readonly TaskCoordStatus[] = [
|
|
44
|
+
"open",
|
|
45
|
+
"claimed",
|
|
46
|
+
"in-progress",
|
|
47
|
+
"done",
|
|
48
|
+
"blocked",
|
|
49
|
+
] as const;
|
|
50
|
+
|
|
51
|
+
/** One row of the coordination table: ownership of a single task id. */
|
|
52
|
+
export interface ClaimEntry {
|
|
53
|
+
/** Task id as it appears in tasks.md, e.g. "2.1". */
|
|
54
|
+
taskId: string;
|
|
55
|
+
/** Agent name that owns the task, or null when unowned. */
|
|
56
|
+
owner: string | null;
|
|
57
|
+
status: TaskCoordStatus;
|
|
58
|
+
/** ISO-8601 timestamp of the last change, or null. */
|
|
59
|
+
updated: string | null;
|
|
60
|
+
/**
|
|
61
|
+
* Task ids this task depends on. It is not claimable until every dep is
|
|
62
|
+
* `done`. Parsed from the Deps column and/or `_Depends: 1, 2.3_` in tasks.md.
|
|
63
|
+
*/
|
|
64
|
+
deps: string[];
|
|
65
|
+
/**
|
|
66
|
+
* ISO-8601 timestamp until which the current owner's active claim is
|
|
67
|
+
* considered live. Past this, the claim is stale and may be reclaimed.
|
|
68
|
+
* Null when there is no active lease (unowned or terminal status).
|
|
69
|
+
*/
|
|
70
|
+
leaseUntil: string | null;
|
|
71
|
+
/** Optional free-text note (kept short; escaped for table cells). */
|
|
72
|
+
note?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Default lease duration, in minutes, for a fresh claim. */
|
|
76
|
+
export const DEFAULT_LEASE_MINUTES = 30;
|
|
77
|
+
|
|
78
|
+
/** A dated handoff line: one agent passing context to the next. */
|
|
79
|
+
export interface HandoffEntry {
|
|
80
|
+
/** ISO-8601 timestamp or date. */
|
|
81
|
+
when: string;
|
|
82
|
+
from: string;
|
|
83
|
+
to: string;
|
|
84
|
+
message: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Parsed representation of a COORDINATION.md file. */
|
|
88
|
+
export interface CoordinationDoc {
|
|
89
|
+
spec: string;
|
|
90
|
+
claims: ClaimEntry[];
|
|
91
|
+
handoffs: HandoffEntry[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A task's lifecycle marker as written in tasks.md: `[ ]`, `[~]`, `[x]`. */
|
|
95
|
+
export type TaskMarkerStatus = "open" | "in-progress" | "done";
|
|
96
|
+
|
|
97
|
+
/** A checkbox task parsed from tasks.md. */
|
|
98
|
+
export interface TaskLine {
|
|
99
|
+
id: string;
|
|
100
|
+
/** Derived from the checkbox marker: ` `→open, `~`→in-progress, `x`/`X`→done. */
|
|
101
|
+
status: TaskMarkerStatus;
|
|
102
|
+
text: string;
|
|
103
|
+
/**
|
|
104
|
+
* Dependency task ids declared in tasks.md via a `_Depends: 1, 2.3_` marker
|
|
105
|
+
* on the task or one of its indented sub-lines (mirrors `_Requirements:_`).
|
|
106
|
+
*/
|
|
107
|
+
deps: string[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// --- .config.kiro -----------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
export interface SpecConfig {
|
|
113
|
+
specId?: string;
|
|
114
|
+
workflowType?: string;
|
|
115
|
+
specType?: string;
|
|
116
|
+
[key: string]: unknown;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Parse a `.config.kiro` JSON sidecar; tolerant of missing/invalid input. */
|
|
120
|
+
export function parseSpecConfig(raw: string): SpecConfig {
|
|
121
|
+
try {
|
|
122
|
+
const parsed = JSON.parse(raw);
|
|
123
|
+
return parsed && typeof parsed === "object" ? (parsed as SpecConfig) : {};
|
|
124
|
+
} catch {
|
|
125
|
+
return {};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Generate a spec config with a real UUID v4 (never a fabricated string). */
|
|
130
|
+
export function makeSpecConfig(opts: {
|
|
131
|
+
workflowType?: string;
|
|
132
|
+
specType?: string;
|
|
133
|
+
}): SpecConfig {
|
|
134
|
+
return {
|
|
135
|
+
specId: crypto.randomUUID(),
|
|
136
|
+
workflowType: opts.workflowType ?? "requirements-first",
|
|
137
|
+
specType: opts.specType ?? "feature",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// --- tasks.md checkboxes ----------------------------------------------------
|
|
142
|
+
|
|
143
|
+
// Matches lines like "- [ ] 2.1 Do the thing", " - [~] 3 In progress",
|
|
144
|
+
// or " - [x] 3 Wire it up". Capture groups: 1=indent, 2=marker, 3=id, 4=text.
|
|
145
|
+
const TASK_LINE_RE =
|
|
146
|
+
/^(\s*)-\s\[( |x|X|~)\]\s+([0-9]+(?:\.[0-9]+)*)\.?\s+(.*)$/;
|
|
147
|
+
|
|
148
|
+
// Matches a `_Depends: 1, 2.3_` marker anywhere on a line (task or sub-line).
|
|
149
|
+
const DEPENDS_RE = /_Depends:\s*([0-9., ]+?)_/i;
|
|
150
|
+
|
|
151
|
+
function parseDepIds(s: string): string[] {
|
|
152
|
+
return s
|
|
153
|
+
.split(",")
|
|
154
|
+
.map((x) => x.trim())
|
|
155
|
+
.filter((x) => /^[0-9]+(?:\.[0-9]+)*$/.test(x));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Map a raw tasks.md checkbox marker to its lifecycle status. */
|
|
159
|
+
function markerToStatus(marker: string): TaskMarkerStatus {
|
|
160
|
+
if (marker.toLowerCase() === "x") return "done";
|
|
161
|
+
if (marker === "~") return "in-progress";
|
|
162
|
+
return "open";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Map a lifecycle status back to the tasks.md checkbox marker character. */
|
|
166
|
+
function statusToMarker(status: TaskMarkerStatus): string {
|
|
167
|
+
if (status === "done") return "x";
|
|
168
|
+
if (status === "in-progress") return "~";
|
|
169
|
+
return " ";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Extract all checkbox tasks (with ids) from a tasks.md body. Dependency
|
|
174
|
+
* markers (`_Depends: …_`) on a task line or its indented sub-lines are
|
|
175
|
+
* attached to the most recent task.
|
|
176
|
+
*/
|
|
177
|
+
export function parseTaskLines(tasksMd: string): TaskLine[] {
|
|
178
|
+
const out: TaskLine[] = [];
|
|
179
|
+
let current: TaskLine | null = null;
|
|
180
|
+
for (const line of tasksMd.split("\n")) {
|
|
181
|
+
const m = line.match(TASK_LINE_RE);
|
|
182
|
+
if (m) {
|
|
183
|
+
const depsOnLine = line.match(DEPENDS_RE);
|
|
184
|
+
current = {
|
|
185
|
+
id: m[3],
|
|
186
|
+
status: markerToStatus(m[2]),
|
|
187
|
+
text: m[4].trim(),
|
|
188
|
+
deps: depsOnLine ? parseDepIds(depsOnLine[1]) : [],
|
|
189
|
+
};
|
|
190
|
+
out.push(current);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
// Non-task line: may carry a _Depends:_ marker for the current task.
|
|
194
|
+
if (current) {
|
|
195
|
+
const dep = line.match(DEPENDS_RE);
|
|
196
|
+
if (dep) {
|
|
197
|
+
for (const id of parseDepIds(dep[1])) {
|
|
198
|
+
if (!current.deps.includes(id)) current.deps.push(id);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Set a single task's checkbox marker in tasks.md by id, returning the new
|
|
208
|
+
* body. Only the `[ ]`/`[~]`/`[x]` marker changes; all other text is
|
|
209
|
+
* preserved verbatim. Throws if the task id is not found.
|
|
210
|
+
*/
|
|
211
|
+
export function setTaskMarker(
|
|
212
|
+
tasksMd: string,
|
|
213
|
+
taskId: string,
|
|
214
|
+
status: TaskMarkerStatus,
|
|
215
|
+
): string {
|
|
216
|
+
let found = false;
|
|
217
|
+
const lines = tasksMd.split("\n").map((line) => {
|
|
218
|
+
const m = line.match(TASK_LINE_RE);
|
|
219
|
+
if (!m || m[3] !== taskId) return line;
|
|
220
|
+
found = true;
|
|
221
|
+
const marker = statusToMarker(status);
|
|
222
|
+
// Rebuild only the marker; keep original indentation and trailing text.
|
|
223
|
+
return line.replace(/^(\s*-\s\[)( |x|X|~)(\])/, `$1${marker}$3`);
|
|
224
|
+
});
|
|
225
|
+
if (!found) {
|
|
226
|
+
throw new Error(`Task "${taskId}" not found in ${TASKS_FILENAME}`);
|
|
227
|
+
}
|
|
228
|
+
return lines.join("\n");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Toggle a single task's checkbox between `[ ]` and `[x]` in tasks.md.
|
|
233
|
+
* Thin wrapper over {@link setTaskMarker} kept for the simple done/not-done
|
|
234
|
+
* case; use `setTaskMarker` directly to set `[~]` (in-progress).
|
|
235
|
+
*/
|
|
236
|
+
export function setTaskChecked(
|
|
237
|
+
tasksMd: string,
|
|
238
|
+
taskId: string,
|
|
239
|
+
checked: boolean,
|
|
240
|
+
): string {
|
|
241
|
+
return setTaskMarker(tasksMd, taskId, checked ? "done" : "open");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// --- COORDINATION.md parse / serialize --------------------------------------
|
|
245
|
+
|
|
246
|
+
function escapeCell(s: string): string {
|
|
247
|
+
return s
|
|
248
|
+
.replace(/\\/g, "\\\\")
|
|
249
|
+
.replace(/\|/g, "\\|")
|
|
250
|
+
.replace(/\r?\n/g, " ")
|
|
251
|
+
.trim();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function unescapeCell(s: string): string {
|
|
255
|
+
return s.replace(/\\\|/g, "|").trim();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const EM_DASH = "—";
|
|
259
|
+
|
|
260
|
+
/** Serialize a coordination doc to canonical COORDINATION.md markdown. */
|
|
261
|
+
export function serializeCoordination(doc: CoordinationDoc): string {
|
|
262
|
+
const lines: string[] = [];
|
|
263
|
+
lines.push(`# Coordination — ${doc.spec}`);
|
|
264
|
+
lines.push("");
|
|
265
|
+
lines.push(
|
|
266
|
+
"<!-- Managed by `kanon spec`. Ownership/claims for multi-agent work.",
|
|
267
|
+
);
|
|
268
|
+
lines.push(
|
|
269
|
+
" Kiro ignores this file; tasks.md checkboxes remain the completion source of truth. -->",
|
|
270
|
+
);
|
|
271
|
+
lines.push("");
|
|
272
|
+
lines.push("## Task ownership");
|
|
273
|
+
lines.push("");
|
|
274
|
+
lines.push("| Task | Owner | Status | Deps | Lease until | Updated | Note |");
|
|
275
|
+
lines.push("|------|-------|--------|------|-------------|---------|------|");
|
|
276
|
+
const sorted = [...doc.claims].sort((a, b) =>
|
|
277
|
+
compareTaskIds(a.taskId, b.taskId),
|
|
278
|
+
);
|
|
279
|
+
for (const c of sorted) {
|
|
280
|
+
const deps = c.deps.length > 0 ? c.deps.join(", ") : EM_DASH;
|
|
281
|
+
lines.push(
|
|
282
|
+
`| ${c.taskId} | ${c.owner ?? EM_DASH} | ${c.status} | ${deps} | ${
|
|
283
|
+
c.leaseUntil ?? EM_DASH
|
|
284
|
+
} | ${c.updated ?? EM_DASH} | ${c.note ? escapeCell(c.note) : EM_DASH} |`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
lines.push("");
|
|
288
|
+
lines.push("## Handoffs");
|
|
289
|
+
lines.push("");
|
|
290
|
+
if (doc.handoffs.length === 0) {
|
|
291
|
+
lines.push("_None yet._");
|
|
292
|
+
} else {
|
|
293
|
+
for (const h of doc.handoffs) {
|
|
294
|
+
lines.push(`- ${h.when} ${h.from} → ${h.to}: ${h.message}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
lines.push("");
|
|
298
|
+
return lines.join("\n");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Numeric-aware comparison of dotted task ids ("2.10" sorts after "2.2"). */
|
|
302
|
+
export function compareTaskIds(a: string, b: string): number {
|
|
303
|
+
const pa = a.split(".").map(Number);
|
|
304
|
+
const pb = b.split(".").map(Number);
|
|
305
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
306
|
+
const da = pa[i] ?? 0;
|
|
307
|
+
const db = pb[i] ?? 0;
|
|
308
|
+
if (da !== db) return da - db;
|
|
309
|
+
}
|
|
310
|
+
return 0;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Split a markdown table row into trimmed cells, honoring `\|` escapes. */
|
|
314
|
+
function splitTableRow(line: string): string[] {
|
|
315
|
+
const cells: string[] = [];
|
|
316
|
+
let cur = "";
|
|
317
|
+
for (let i = 0; i < line.length; i++) {
|
|
318
|
+
const ch = line[i];
|
|
319
|
+
if (ch === "\\" && line[i + 1] === "|") {
|
|
320
|
+
cur += "\\|";
|
|
321
|
+
i++;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (ch === "|") {
|
|
325
|
+
cells.push(cur);
|
|
326
|
+
cur = "";
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
cur += ch;
|
|
330
|
+
}
|
|
331
|
+
cells.push(cur);
|
|
332
|
+
// Drop the empty leading/trailing cells produced by the border pipes.
|
|
333
|
+
return cells.slice(1, -1).map((c) => c.trim());
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const HANDOFF_RE = /^-\s+(\S+)\s+(.+?)\s+→\s+(.+?):\s+(.*)$/;
|
|
337
|
+
|
|
338
|
+
/** Parse a COORDINATION.md body; tolerant of hand-edits and missing sections. */
|
|
339
|
+
export function parseCoordination(md: string): CoordinationDoc {
|
|
340
|
+
const specMatch = md.match(/^#\s+Coordination\s+—\s+(.+)$/m);
|
|
341
|
+
const spec = specMatch ? specMatch[1].trim() : "";
|
|
342
|
+
const claims: ClaimEntry[] = [];
|
|
343
|
+
const handoffs: HandoffEntry[] = [];
|
|
344
|
+
|
|
345
|
+
const lines = md.split("\n");
|
|
346
|
+
let section: "claims" | "handoffs" | null = null;
|
|
347
|
+
for (const line of lines) {
|
|
348
|
+
if (/^##\s+Task ownership/i.test(line)) {
|
|
349
|
+
section = "claims";
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (/^##\s+Handoffs/i.test(line)) {
|
|
353
|
+
section = "handoffs";
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (section === "claims" && line.trim().startsWith("|")) {
|
|
357
|
+
const cells = splitTableRow(line);
|
|
358
|
+
if (cells.length < 4) continue;
|
|
359
|
+
// Skip header and divider rows.
|
|
360
|
+
if (cells[0].toLowerCase() === "task") continue;
|
|
361
|
+
if (/^-+$/.test(cells[0])) continue;
|
|
362
|
+
const status = (TASK_COORD_STATUSES as readonly string[]).includes(
|
|
363
|
+
cells[2],
|
|
364
|
+
)
|
|
365
|
+
? (cells[2] as TaskCoordStatus)
|
|
366
|
+
: "open";
|
|
367
|
+
const cell = (i: number): string | undefined => cells[i];
|
|
368
|
+
const nullable = (v: string | undefined): string | null =>
|
|
369
|
+
v === undefined || v === EM_DASH || v === "" ? null : v;
|
|
370
|
+
// New 7-column layout: Task | Owner | Status | Deps | Lease | Updated | Note
|
|
371
|
+
// Old 5-column layout: Task | Owner | Status | Updated | Note
|
|
372
|
+
const isNew = cells.length >= 6;
|
|
373
|
+
claims.push({
|
|
374
|
+
taskId: cells[0],
|
|
375
|
+
owner: nullable(cells[1]),
|
|
376
|
+
status,
|
|
377
|
+
deps: isNew ? parseDepIds(cell(3) ?? "") : [],
|
|
378
|
+
leaseUntil: isNew ? nullable(cell(4)) : null,
|
|
379
|
+
updated: isNew ? nullable(cell(5)) : nullable(cell(3)),
|
|
380
|
+
note: (() => {
|
|
381
|
+
const raw = isNew ? cell(6) : cell(4);
|
|
382
|
+
return raw && raw !== EM_DASH ? unescapeCell(raw) : undefined;
|
|
383
|
+
})(),
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
if (section === "handoffs") {
|
|
387
|
+
const m = line.match(HANDOFF_RE);
|
|
388
|
+
if (m) {
|
|
389
|
+
handoffs.push({ when: m[1], from: m[2], to: m[3], message: m[4] });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return { spec, claims, handoffs };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Build a fresh coordination doc seeded from tasks.md task ids. */
|
|
397
|
+
export function initCoordination(
|
|
398
|
+
spec: string,
|
|
399
|
+
tasks: TaskLine[],
|
|
400
|
+
): CoordinationDoc {
|
|
401
|
+
return {
|
|
402
|
+
spec,
|
|
403
|
+
claims: tasks.map((t) => ({
|
|
404
|
+
taskId: t.id,
|
|
405
|
+
owner: null,
|
|
406
|
+
status: t.status,
|
|
407
|
+
deps: [...t.deps],
|
|
408
|
+
leaseUntil: null,
|
|
409
|
+
updated: null,
|
|
410
|
+
})),
|
|
411
|
+
handoffs: [],
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// --- pure mutations ---------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
function upsertClaim(
|
|
418
|
+
doc: CoordinationDoc,
|
|
419
|
+
taskId: string,
|
|
420
|
+
patch: Partial<Omit<ClaimEntry, "taskId">>,
|
|
421
|
+
now: string,
|
|
422
|
+
): CoordinationDoc {
|
|
423
|
+
const claims = [...doc.claims];
|
|
424
|
+
const idx = claims.findIndex((c) => c.taskId === taskId);
|
|
425
|
+
const base: ClaimEntry =
|
|
426
|
+
idx >= 0
|
|
427
|
+
? claims[idx]
|
|
428
|
+
: {
|
|
429
|
+
taskId,
|
|
430
|
+
owner: null,
|
|
431
|
+
status: "open",
|
|
432
|
+
deps: [],
|
|
433
|
+
leaseUntil: null,
|
|
434
|
+
updated: null,
|
|
435
|
+
};
|
|
436
|
+
const next: ClaimEntry = { ...base, ...patch, updated: now };
|
|
437
|
+
if (idx >= 0) claims[idx] = next;
|
|
438
|
+
else claims.push(next);
|
|
439
|
+
return { ...doc, claims };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export interface ClaimResult {
|
|
443
|
+
doc: CoordinationDoc;
|
|
444
|
+
/** Set when the task was already owned by a *different* agent. */
|
|
445
|
+
conflict?: {
|
|
446
|
+
owner: string;
|
|
447
|
+
status: TaskCoordStatus;
|
|
448
|
+
/** Whether the blocking claim's lease had expired (informational). */
|
|
449
|
+
leaseExpired: boolean;
|
|
450
|
+
};
|
|
451
|
+
/** Set when the task's dependencies are not all done. */
|
|
452
|
+
blocked?: { unmet: string[] };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export interface ClaimOptions {
|
|
456
|
+
force?: boolean;
|
|
457
|
+
/** Lease duration in minutes (default DEFAULT_LEASE_MINUTES). */
|
|
458
|
+
leaseMinutes?: number;
|
|
459
|
+
/** Skip the dependency gate (used only for explicit manual claims). */
|
|
460
|
+
ignoreDeps?: boolean;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Claim a task for an agent, setting a fresh lease. Returns a conflict (without
|
|
465
|
+
* mutating) when the task is actively owned by another agent whose lease has
|
|
466
|
+
* not expired, unless `force`. Returns `blocked` when dependencies are unmet,
|
|
467
|
+
* unless `ignoreDeps` or `force`.
|
|
468
|
+
*/
|
|
469
|
+
export function claimTask(
|
|
470
|
+
doc: CoordinationDoc,
|
|
471
|
+
taskId: string,
|
|
472
|
+
agent: string,
|
|
473
|
+
now: string,
|
|
474
|
+
opts: ClaimOptions = {},
|
|
475
|
+
): ClaimResult {
|
|
476
|
+
const existing = doc.claims.find((c) => c.taskId === taskId);
|
|
477
|
+
|
|
478
|
+
// Ownership gate — a live claim by another agent blocks unless forced.
|
|
479
|
+
if (
|
|
480
|
+
existing?.owner &&
|
|
481
|
+
existing.owner !== agent &&
|
|
482
|
+
existing.status !== "done" &&
|
|
483
|
+
!opts.force
|
|
484
|
+
) {
|
|
485
|
+
const leaseExpired = isLeaseExpired(existing.leaseUntil, now);
|
|
486
|
+
if (!leaseExpired) {
|
|
487
|
+
return {
|
|
488
|
+
doc,
|
|
489
|
+
conflict: {
|
|
490
|
+
owner: existing.owner,
|
|
491
|
+
status: existing.status,
|
|
492
|
+
leaseExpired,
|
|
493
|
+
},
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
// else: lease expired → fall through and reclaim
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Dependency gate.
|
|
500
|
+
if (
|
|
501
|
+
!opts.force &&
|
|
502
|
+
!opts.ignoreDeps &&
|
|
503
|
+
existing &&
|
|
504
|
+
!depsSatisfied(doc, taskId)
|
|
505
|
+
) {
|
|
506
|
+
return { doc, blocked: { unmet: unmetDeps(doc, taskId) } };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const leaseUntil = leaseFrom(now, opts.leaseMinutes ?? DEFAULT_LEASE_MINUTES);
|
|
510
|
+
return {
|
|
511
|
+
doc: upsertClaim(
|
|
512
|
+
doc,
|
|
513
|
+
taskId,
|
|
514
|
+
{ owner: agent, status: "claimed", leaseUntil },
|
|
515
|
+
now,
|
|
516
|
+
),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Release a task back to unowned/open, clearing any lease. */
|
|
521
|
+
export function releaseTask(
|
|
522
|
+
doc: CoordinationDoc,
|
|
523
|
+
taskId: string,
|
|
524
|
+
now: string,
|
|
525
|
+
): CoordinationDoc {
|
|
526
|
+
return upsertClaim(
|
|
527
|
+
doc,
|
|
528
|
+
taskId,
|
|
529
|
+
{ owner: null, status: "open", leaseUntil: null },
|
|
530
|
+
now,
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Set a task's coordination status. Reaching a terminal state (`done`) clears
|
|
536
|
+
* the lease; moving to `in-progress` or `claimed` refreshes it when requested.
|
|
537
|
+
*/
|
|
538
|
+
export function setTaskStatus(
|
|
539
|
+
doc: CoordinationDoc,
|
|
540
|
+
taskId: string,
|
|
541
|
+
status: TaskCoordStatus,
|
|
542
|
+
now: string,
|
|
543
|
+
opts: { leaseMinutes?: number } = {},
|
|
544
|
+
): CoordinationDoc {
|
|
545
|
+
const patch: Partial<Omit<ClaimEntry, "taskId">> = { status };
|
|
546
|
+
if (status === "done" || status === "blocked") {
|
|
547
|
+
patch.leaseUntil = null;
|
|
548
|
+
} else if (opts.leaseMinutes !== undefined) {
|
|
549
|
+
patch.leaseUntil = leaseFrom(now, opts.leaseMinutes);
|
|
550
|
+
}
|
|
551
|
+
return upsertClaim(doc, taskId, patch, now);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Append a handoff note. */
|
|
555
|
+
export function addHandoff(
|
|
556
|
+
doc: CoordinationDoc,
|
|
557
|
+
from: string,
|
|
558
|
+
to: string,
|
|
559
|
+
message: string,
|
|
560
|
+
now: string,
|
|
561
|
+
): CoordinationDoc {
|
|
562
|
+
return {
|
|
563
|
+
...doc,
|
|
564
|
+
handoffs: [...doc.handoffs, { when: now, from, to, message }],
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Reconcile coordination against tasks.md: add rows for new task ids, mark
|
|
570
|
+
* rows done where the checkbox is `[x]`, promote a bare `open` row to
|
|
571
|
+
* `in-progress` where the checkbox is `[~]` (e.g. a human hand-editing
|
|
572
|
+
* tasks.md outside `kanon spec claim`), and refresh dependency lists from
|
|
573
|
+
* tasks.md `_Depends:_` markers. Never un-marks (downgrades) or deletes —
|
|
574
|
+
* `[x]` always wins, and `[~]` never overrides an existing claim or `done`.
|
|
575
|
+
*/
|
|
576
|
+
export function reconcile(
|
|
577
|
+
doc: CoordinationDoc,
|
|
578
|
+
tasks: TaskLine[],
|
|
579
|
+
now: string,
|
|
580
|
+
): CoordinationDoc {
|
|
581
|
+
let next = doc;
|
|
582
|
+
const known = new Set(doc.claims.map((c) => c.taskId));
|
|
583
|
+
for (const t of tasks) {
|
|
584
|
+
if (!known.has(t.id)) {
|
|
585
|
+
next = upsertClaim(
|
|
586
|
+
next,
|
|
587
|
+
t.id,
|
|
588
|
+
{ status: t.status, deps: [...t.deps] },
|
|
589
|
+
now,
|
|
590
|
+
);
|
|
591
|
+
} else {
|
|
592
|
+
const cur = next.claims.find((c) => c.taskId === t.id);
|
|
593
|
+
if (!cur) continue;
|
|
594
|
+
const patch: Partial<Omit<ClaimEntry, "taskId">> = {};
|
|
595
|
+
// Adopt any deps declared in tasks.md that aren't already tracked.
|
|
596
|
+
const mergedDeps = Array.from(new Set([...cur.deps, ...t.deps]));
|
|
597
|
+
if (mergedDeps.length !== cur.deps.length) patch.deps = mergedDeps;
|
|
598
|
+
if (t.status === "done" && cur.status !== "done") {
|
|
599
|
+
patch.status = "done";
|
|
600
|
+
} else if (t.status === "in-progress" && cur.status === "open") {
|
|
601
|
+
patch.status = "in-progress";
|
|
602
|
+
}
|
|
603
|
+
if (Object.keys(patch).length > 0) {
|
|
604
|
+
next = upsertClaim(next, t.id, patch, now);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return next;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// --- dependencies, leases, and task selection -------------------------------
|
|
612
|
+
|
|
613
|
+
/** A task is "complete" if its coordination status is done. */
|
|
614
|
+
function isDone(doc: CoordinationDoc, taskId: string): boolean {
|
|
615
|
+
return doc.claims.find((c) => c.taskId === taskId)?.status === "done";
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** True when every dependency of the task is done (or it has none). */
|
|
619
|
+
export function depsSatisfied(doc: CoordinationDoc, taskId: string): boolean {
|
|
620
|
+
const entry = doc.claims.find((c) => c.taskId === taskId);
|
|
621
|
+
if (!entry) return true;
|
|
622
|
+
return entry.deps.every((d) => isDone(doc, d));
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Dependency ids that are not yet done. */
|
|
626
|
+
export function unmetDeps(doc: CoordinationDoc, taskId: string): string[] {
|
|
627
|
+
const entry = doc.claims.find((c) => c.taskId === taskId);
|
|
628
|
+
if (!entry) return [];
|
|
629
|
+
return entry.deps.filter((d) => !isDone(doc, d));
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** True when a lease timestamp has passed relative to `now`. */
|
|
633
|
+
export function isLeaseExpired(
|
|
634
|
+
leaseUntil: string | null,
|
|
635
|
+
now: string,
|
|
636
|
+
): boolean {
|
|
637
|
+
if (!leaseUntil) return true;
|
|
638
|
+
const until = Date.parse(leaseUntil);
|
|
639
|
+
if (Number.isNaN(until)) return true;
|
|
640
|
+
return Date.parse(now) > until;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* True when a task is effectively available to a *different* agent: unowned, or
|
|
645
|
+
* terminal-but-not-done is impossible, or its owner's lease has expired.
|
|
646
|
+
* A `done` task is never available. The task's own agent always "has" it.
|
|
647
|
+
*/
|
|
648
|
+
export function isClaimable(
|
|
649
|
+
entry: ClaimEntry,
|
|
650
|
+
agent: string,
|
|
651
|
+
now: string,
|
|
652
|
+
): boolean {
|
|
653
|
+
if (entry.status === "done") return false;
|
|
654
|
+
if (!entry.owner) return true;
|
|
655
|
+
if (entry.owner === agent) return true;
|
|
656
|
+
// Owned by someone else — only claimable if their lease expired.
|
|
657
|
+
return isLeaseExpired(entry.leaseUntil, now);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** Add `minutes` to an ISO timestamp, returning a new ISO timestamp. */
|
|
661
|
+
export function leaseFrom(now: string, minutes: number): string {
|
|
662
|
+
return new Date(Date.parse(now) + minutes * 60_000).toISOString();
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Pick the next actionable task for an agent: the lowest-id task that is not
|
|
667
|
+
* done, has all dependencies satisfied, and is claimable (unowned, owned by
|
|
668
|
+
* this agent, or whose lease has expired). Returns null when nothing is ready.
|
|
669
|
+
*/
|
|
670
|
+
export function selectNextTask(
|
|
671
|
+
doc: CoordinationDoc,
|
|
672
|
+
agent: string,
|
|
673
|
+
now: string,
|
|
674
|
+
): ClaimEntry | null {
|
|
675
|
+
const candidates = doc.claims
|
|
676
|
+
.filter((c) => c.status !== "done")
|
|
677
|
+
.filter((c) => isClaimable(c, agent, now))
|
|
678
|
+
.filter((c) => depsSatisfied(doc, c.taskId))
|
|
679
|
+
.sort((a, b) => compareTaskIds(a.taskId, b.taskId));
|
|
680
|
+
return candidates[0] ?? null;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// --- filesystem helpers -----------------------------------------------------
|
|
684
|
+
|
|
685
|
+
export function specsRoot(cwd = process.cwd()): string {
|
|
686
|
+
return join(cwd, ".kiro", "specs");
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
export function specDir(spec: string, cwd = process.cwd()): string {
|
|
690
|
+
return join(specsRoot(cwd), spec);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
export async function listSpecs(cwd = process.cwd()): Promise<string[]> {
|
|
694
|
+
const root = specsRoot(cwd);
|
|
695
|
+
if (!(await exists(root))) return [];
|
|
696
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
697
|
+
return entries
|
|
698
|
+
.filter((e) => e.isDirectory())
|
|
699
|
+
.map((e) => e.name)
|
|
700
|
+
.sort();
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async function readIfExists(path: string): Promise<string | null> {
|
|
704
|
+
if (!(await exists(path))) return null;
|
|
705
|
+
return readFile(path, "utf-8");
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** Load COORDINATION.md for a spec, seeding from tasks.md if absent. */
|
|
709
|
+
export async function loadCoordination(
|
|
710
|
+
spec: string,
|
|
711
|
+
cwd = process.cwd(),
|
|
712
|
+
): Promise<CoordinationDoc> {
|
|
713
|
+
const dir = specDir(spec, cwd);
|
|
714
|
+
const coordRaw = await readIfExists(join(dir, COORDINATION_FILENAME));
|
|
715
|
+
if (coordRaw !== null) return parseCoordination(coordRaw);
|
|
716
|
+
const tasksRaw = (await readIfExists(join(dir, TASKS_FILENAME))) ?? "";
|
|
717
|
+
return initCoordination(spec, parseTaskLines(tasksRaw));
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
export async function saveCoordination(
|
|
721
|
+
doc: CoordinationDoc,
|
|
722
|
+
cwd = process.cwd(),
|
|
723
|
+
): Promise<void> {
|
|
724
|
+
const dir = specDir(doc.spec, cwd);
|
|
725
|
+
await writeFile(
|
|
726
|
+
join(dir, COORDINATION_FILENAME),
|
|
727
|
+
serializeCoordination(doc),
|
|
728
|
+
"utf-8",
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** Set a task's tasks.md checkbox marker (`[ ]`/`[~]`/`[x]`) on disk. */
|
|
733
|
+
async function writeTaskMarker(
|
|
734
|
+
spec: string,
|
|
735
|
+
taskId: string,
|
|
736
|
+
status: TaskMarkerStatus,
|
|
737
|
+
cwd = process.cwd(),
|
|
738
|
+
): Promise<void> {
|
|
739
|
+
const tasksPath = join(specDir(spec, cwd), TASKS_FILENAME);
|
|
740
|
+
const tasksRaw = await readIfExists(tasksPath);
|
|
741
|
+
if (tasksRaw === null) return;
|
|
742
|
+
await writeFile(tasksPath, setTaskMarker(tasksRaw, taskId, status), "utf-8");
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
async function loadTasks(
|
|
746
|
+
spec: string,
|
|
747
|
+
cwd = process.cwd(),
|
|
748
|
+
): Promise<TaskLine[]> {
|
|
749
|
+
const raw = await readIfExists(join(specDir(spec, cwd), TASKS_FILENAME));
|
|
750
|
+
return raw ? parseTaskLines(raw) : [];
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function nowIso(): string {
|
|
754
|
+
return new Date().toISOString();
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function fail(msg: string): never {
|
|
758
|
+
console.error(chalk.red(`Error: ${msg}`));
|
|
759
|
+
process.exit(1);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async function ensureSpec(spec: string | undefined): Promise<string> {
|
|
763
|
+
if (!spec)
|
|
764
|
+
fail("Provide a spec name, e.g. `kanon spec status user-authentication`");
|
|
765
|
+
const dir = specDir(spec);
|
|
766
|
+
if (!(await exists(dir))) {
|
|
767
|
+
const available = await listSpecs();
|
|
768
|
+
fail(
|
|
769
|
+
`Spec "${spec}" not found under .kiro/specs/.` +
|
|
770
|
+
(available.length
|
|
771
|
+
? `\n Available: ${available.join(", ")}`
|
|
772
|
+
: "\n No specs found in this workspace."),
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
return spec;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// --- command actions --------------------------------------------------------
|
|
779
|
+
|
|
780
|
+
export interface SpecOutputOptions {
|
|
781
|
+
json?: boolean;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** `kanon spec list` — list specs and their config + progress. */
|
|
785
|
+
export async function specListCommand(
|
|
786
|
+
options: SpecOutputOptions = {},
|
|
787
|
+
): Promise<void> {
|
|
788
|
+
const specs = await listSpecs();
|
|
789
|
+
const rows: Array<{
|
|
790
|
+
spec: string;
|
|
791
|
+
specType: string | null;
|
|
792
|
+
workflowType: string | null;
|
|
793
|
+
done: number;
|
|
794
|
+
total: number;
|
|
795
|
+
}> = [];
|
|
796
|
+
for (const spec of specs) {
|
|
797
|
+
const cfgRaw = await readIfExists(join(specDir(spec), CONFIG_FILENAME));
|
|
798
|
+
const cfg = cfgRaw ? parseSpecConfig(cfgRaw) : {};
|
|
799
|
+
const tasks = await loadTasks(spec);
|
|
800
|
+
rows.push({
|
|
801
|
+
spec,
|
|
802
|
+
specType: (cfg.specType as string) ?? null,
|
|
803
|
+
workflowType: (cfg.workflowType as string) ?? null,
|
|
804
|
+
done: tasks.filter((t) => t.status === "done").length,
|
|
805
|
+
total: tasks.length,
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (options.json) {
|
|
810
|
+
console.log(JSON.stringify({ specs: rows }, null, 2));
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
if (rows.length === 0) {
|
|
814
|
+
console.log(chalk.dim("No specs found under .kiro/specs/."));
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
for (const r of rows) {
|
|
818
|
+
console.log(
|
|
819
|
+
`${chalk.bold(r.spec)} ${chalk.dim(
|
|
820
|
+
`[${r.specType ?? "?"}/${r.workflowType ?? "?"}]`,
|
|
821
|
+
)} ${chalk.cyan(`${r.done}/${r.total} tasks`)}`,
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** `kanon spec status <spec>` — show ownership table + progress. */
|
|
827
|
+
export async function specStatusCommand(
|
|
828
|
+
spec?: string,
|
|
829
|
+
options: SpecOutputOptions = {},
|
|
830
|
+
): Promise<void> {
|
|
831
|
+
const name = await ensureSpec(spec);
|
|
832
|
+
const now = nowIso();
|
|
833
|
+
const tasks = await loadTasks(name);
|
|
834
|
+
const doc = reconcile(await loadCoordination(name), tasks, now);
|
|
835
|
+
const done = tasks.filter((t) => t.status === "done").length;
|
|
836
|
+
const sorted = [...doc.claims].sort((a, b) =>
|
|
837
|
+
compareTaskIds(a.taskId, b.taskId),
|
|
838
|
+
);
|
|
839
|
+
|
|
840
|
+
if (options.json) {
|
|
841
|
+
console.log(
|
|
842
|
+
JSON.stringify(
|
|
843
|
+
{
|
|
844
|
+
spec: name,
|
|
845
|
+
progress: { done, total: tasks.length },
|
|
846
|
+
tasks: sorted.map((c) => ({
|
|
847
|
+
id: c.taskId,
|
|
848
|
+
owner: c.owner,
|
|
849
|
+
status: c.status,
|
|
850
|
+
deps: c.deps,
|
|
851
|
+
unmetDeps: unmetDeps(doc, c.taskId),
|
|
852
|
+
leaseUntil: c.leaseUntil,
|
|
853
|
+
leaseExpired: c.owner ? isLeaseExpired(c.leaseUntil, now) : null,
|
|
854
|
+
updated: c.updated,
|
|
855
|
+
note: c.note ?? null,
|
|
856
|
+
})),
|
|
857
|
+
handoffs: doc.handoffs,
|
|
858
|
+
},
|
|
859
|
+
null,
|
|
860
|
+
2,
|
|
861
|
+
),
|
|
862
|
+
);
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
console.log(chalk.bold(`Spec: ${name}`));
|
|
867
|
+
console.log(chalk.cyan(`Progress: ${done}/${tasks.length} tasks complete`));
|
|
868
|
+
console.log();
|
|
869
|
+
console.log(
|
|
870
|
+
chalk.bold("Task Owner Status Deps Notes"),
|
|
871
|
+
);
|
|
872
|
+
for (const c of sorted) {
|
|
873
|
+
const unmet = unmetDeps(doc, c.taskId);
|
|
874
|
+
const depCol =
|
|
875
|
+
c.deps.length === 0
|
|
876
|
+
? "—"
|
|
877
|
+
: unmet.length > 0
|
|
878
|
+
? `blocked:${unmet.join(",")}`
|
|
879
|
+
: "ready";
|
|
880
|
+
const stale =
|
|
881
|
+
c.owner && c.status !== "done" && isLeaseExpired(c.leaseUntil, now)
|
|
882
|
+
? chalk.yellow(" (lease expired)")
|
|
883
|
+
: "";
|
|
884
|
+
console.log(
|
|
885
|
+
`${c.taskId.padEnd(5)} ${(c.owner ?? "—").padEnd(16)} ${c.status.padEnd(
|
|
886
|
+
13,
|
|
887
|
+
)} ${depCol.padEnd(12)} ${c.note ?? ""}${stale}`,
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
if (doc.handoffs.length > 0) {
|
|
891
|
+
console.log();
|
|
892
|
+
console.log(chalk.bold("Handoffs:"));
|
|
893
|
+
for (const h of doc.handoffs) {
|
|
894
|
+
console.log(` ${h.when} ${h.from} → ${h.to}: ${h.message}`);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
export interface SpecClaimOptions {
|
|
900
|
+
agent?: string;
|
|
901
|
+
force?: boolean;
|
|
902
|
+
lease?: string;
|
|
903
|
+
ignoreDeps?: boolean;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function parseLeaseMinutes(lease: string | undefined): number | undefined {
|
|
907
|
+
if (lease === undefined) return undefined;
|
|
908
|
+
const n = Number(lease);
|
|
909
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
910
|
+
fail(`--lease must be a positive number of minutes (got "${lease}")`);
|
|
911
|
+
}
|
|
912
|
+
return n;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** `kanon spec claim <spec> <taskId> --agent <name>`. */
|
|
916
|
+
export async function specClaimCommand(
|
|
917
|
+
spec: string | undefined,
|
|
918
|
+
taskId: string | undefined,
|
|
919
|
+
options: SpecClaimOptions,
|
|
920
|
+
): Promise<void> {
|
|
921
|
+
const name = await ensureSpec(spec);
|
|
922
|
+
if (!taskId)
|
|
923
|
+
fail("Provide a task id, e.g. `kanon spec claim my-spec 2.1 --agent code`");
|
|
924
|
+
if (!options.agent)
|
|
925
|
+
fail("Provide --agent <name> to identify the claiming agent");
|
|
926
|
+
|
|
927
|
+
const tasks = await loadTasks(name);
|
|
928
|
+
if (!tasks.some((t) => t.id === taskId)) {
|
|
929
|
+
fail(`Task "${taskId}" not found in ${name}/${TASKS_FILENAME}`);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Reconcile first so deps declared in tasks.md are known before we gate on them.
|
|
933
|
+
const doc = reconcile(await loadCoordination(name), tasks, nowIso());
|
|
934
|
+
const {
|
|
935
|
+
doc: next,
|
|
936
|
+
conflict,
|
|
937
|
+
blocked,
|
|
938
|
+
} = claimTask(doc, taskId, options.agent, nowIso(), {
|
|
939
|
+
force: options.force,
|
|
940
|
+
leaseMinutes: parseLeaseMinutes(options.lease),
|
|
941
|
+
ignoreDeps: options.ignoreDeps,
|
|
942
|
+
});
|
|
943
|
+
if (conflict) {
|
|
944
|
+
fail(
|
|
945
|
+
`Task "${taskId}" is already claimed by "${conflict.owner}" (${conflict.status}).` +
|
|
946
|
+
`\n Use --force to take it over, or pick another task.`,
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
if (blocked) {
|
|
950
|
+
fail(
|
|
951
|
+
`Task "${taskId}" is blocked — waiting on: ${blocked.unmet.join(", ")}.` +
|
|
952
|
+
`\n Finish those first, or use --ignore-deps to override.`,
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
await saveCoordination(next, process.cwd());
|
|
956
|
+
await writeTaskMarker(name, taskId, "in-progress");
|
|
957
|
+
console.log(
|
|
958
|
+
chalk.green(`✓ ${options.agent} claimed task ${taskId} in ${name}`),
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
export interface SpecNextOptions {
|
|
963
|
+
agent?: string;
|
|
964
|
+
lease?: string;
|
|
965
|
+
/** Only report the next task; do not claim it. */
|
|
966
|
+
dryRun?: boolean;
|
|
967
|
+
json?: boolean;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* `kanon spec next <spec> --agent <name>` — atomically select and claim the
|
|
972
|
+
* next actionable task (lowest id, deps satisfied, claimable), then print it.
|
|
973
|
+
* This is the one-call path an agent uses to pull work.
|
|
974
|
+
*/
|
|
975
|
+
export async function specNextCommand(
|
|
976
|
+
spec: string | undefined,
|
|
977
|
+
options: SpecNextOptions,
|
|
978
|
+
): Promise<void> {
|
|
979
|
+
const name = await ensureSpec(spec);
|
|
980
|
+
if (!options.agent)
|
|
981
|
+
fail("Provide --agent <name> to identify the requesting agent");
|
|
982
|
+
|
|
983
|
+
const now = nowIso();
|
|
984
|
+
const tasks = await loadTasks(name);
|
|
985
|
+
// Reconcile so new tasks and tasks.md deps are reflected before selecting.
|
|
986
|
+
const doc = reconcile(await loadCoordination(name), tasks, now);
|
|
987
|
+
const pick = selectNextTask(doc, options.agent, now);
|
|
988
|
+
|
|
989
|
+
if (!pick) {
|
|
990
|
+
const remaining = doc.claims.filter((c) => c.status !== "done");
|
|
991
|
+
if (options.json) {
|
|
992
|
+
console.log(JSON.stringify({ spec: name, task: null }, null, 2));
|
|
993
|
+
} else if (remaining.length === 0) {
|
|
994
|
+
console.log(chalk.green(`✓ ${name}: all tasks complete.`));
|
|
995
|
+
} else {
|
|
996
|
+
console.log(
|
|
997
|
+
chalk.yellow(
|
|
998
|
+
`No actionable task in ${name} right now — ` +
|
|
999
|
+
`${remaining.length} remaining but each is blocked or claimed.`,
|
|
1000
|
+
),
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
const text = tasks.find((t) => t.id === pick.taskId)?.text ?? "";
|
|
1007
|
+
|
|
1008
|
+
if (options.dryRun) {
|
|
1009
|
+
if (options.json) {
|
|
1010
|
+
console.log(
|
|
1011
|
+
JSON.stringify(
|
|
1012
|
+
{ spec: name, task: { id: pick.taskId, text, claimed: false } },
|
|
1013
|
+
null,
|
|
1014
|
+
2,
|
|
1015
|
+
),
|
|
1016
|
+
);
|
|
1017
|
+
} else {
|
|
1018
|
+
console.log(
|
|
1019
|
+
`Next task in ${name}: ${chalk.bold(pick.taskId)} ${text} ${chalk.dim(
|
|
1020
|
+
"(dry run — not claimed)",
|
|
1021
|
+
)}`,
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const { doc: next } = claimTask(doc, pick.taskId, options.agent, now, {
|
|
1028
|
+
leaseMinutes: parseLeaseMinutes(options.lease),
|
|
1029
|
+
});
|
|
1030
|
+
await saveCoordination(next, process.cwd());
|
|
1031
|
+
await writeTaskMarker(name, pick.taskId, "in-progress");
|
|
1032
|
+
|
|
1033
|
+
if (options.json) {
|
|
1034
|
+
console.log(
|
|
1035
|
+
JSON.stringify(
|
|
1036
|
+
{ spec: name, task: { id: pick.taskId, text, claimed: true } },
|
|
1037
|
+
null,
|
|
1038
|
+
2,
|
|
1039
|
+
),
|
|
1040
|
+
);
|
|
1041
|
+
} else {
|
|
1042
|
+
console.log(
|
|
1043
|
+
chalk.green(
|
|
1044
|
+
`✓ ${options.agent} claimed next task ${pick.taskId} in ${name}: ${text}`,
|
|
1045
|
+
),
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/** `kanon spec release <spec> <taskId>`. */
|
|
1051
|
+
export async function specReleaseCommand(
|
|
1052
|
+
spec: string | undefined,
|
|
1053
|
+
taskId: string | undefined,
|
|
1054
|
+
): Promise<void> {
|
|
1055
|
+
const name = await ensureSpec(spec);
|
|
1056
|
+
if (!taskId) fail("Provide a task id to release");
|
|
1057
|
+
const doc = await loadCoordination(name);
|
|
1058
|
+
await saveCoordination(releaseTask(doc, taskId, nowIso()), process.cwd());
|
|
1059
|
+
await writeTaskMarker(name, taskId, "open");
|
|
1060
|
+
console.log(chalk.green(`✓ released task ${taskId} in ${name}`));
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
export interface SpecCompleteOptions {
|
|
1064
|
+
agent?: string;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* `kanon spec done <spec> <taskId>` — mark a task complete: checks the box in
|
|
1069
|
+
* tasks.md AND sets coordination status to done. This is the key write path
|
|
1070
|
+
* that keeps the two files consistent.
|
|
1071
|
+
*/
|
|
1072
|
+
export async function specDoneCommand(
|
|
1073
|
+
spec: string | undefined,
|
|
1074
|
+
taskId: string | undefined,
|
|
1075
|
+
_options: SpecCompleteOptions,
|
|
1076
|
+
): Promise<void> {
|
|
1077
|
+
const name = await ensureSpec(spec);
|
|
1078
|
+
if (!taskId) fail("Provide a task id to mark done");
|
|
1079
|
+
const dir = specDir(name);
|
|
1080
|
+
const tasksPath = join(dir, TASKS_FILENAME);
|
|
1081
|
+
const tasksRaw = await readIfExists(tasksPath);
|
|
1082
|
+
if (tasksRaw === null) fail(`${name}/${TASKS_FILENAME} not found`);
|
|
1083
|
+
|
|
1084
|
+
let updated: string;
|
|
1085
|
+
try {
|
|
1086
|
+
updated = setTaskChecked(tasksRaw as string, taskId, true);
|
|
1087
|
+
} catch (err) {
|
|
1088
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
1089
|
+
}
|
|
1090
|
+
await writeFile(tasksPath, updated, "utf-8");
|
|
1091
|
+
|
|
1092
|
+
const doc = await loadCoordination(name);
|
|
1093
|
+
await saveCoordination(
|
|
1094
|
+
setTaskStatus(doc, taskId, "done", nowIso()),
|
|
1095
|
+
process.cwd(),
|
|
1096
|
+
);
|
|
1097
|
+
console.log(chalk.green(`✓ marked task ${taskId} done in ${name}`));
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/** `kanon spec reconcile <spec>` — sync coordination rows to tasks.md. */
|
|
1101
|
+
export async function specReconcileCommand(spec?: string): Promise<void> {
|
|
1102
|
+
const name = await ensureSpec(spec);
|
|
1103
|
+
const tasks = await loadTasks(name);
|
|
1104
|
+
const doc = await loadCoordination(name);
|
|
1105
|
+
const before = doc.claims.length;
|
|
1106
|
+
const next = reconcile(doc, tasks, nowIso());
|
|
1107
|
+
await saveCoordination(next, process.cwd());
|
|
1108
|
+
const added = next.claims.length - before;
|
|
1109
|
+
console.log(
|
|
1110
|
+
chalk.green(
|
|
1111
|
+
`✓ reconciled ${name}: ${next.claims.length} tracked tasks` +
|
|
1112
|
+
(added > 0 ? ` (+${added} new)` : ""),
|
|
1113
|
+
),
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
export interface SpecHandoffOptions {
|
|
1118
|
+
from?: string;
|
|
1119
|
+
to?: string;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/** `kanon spec handoff <spec> <message> --from <a> --to <b>`. */
|
|
1123
|
+
export async function specHandoffCommand(
|
|
1124
|
+
spec: string | undefined,
|
|
1125
|
+
message: string | undefined,
|
|
1126
|
+
options: SpecHandoffOptions,
|
|
1127
|
+
): Promise<void> {
|
|
1128
|
+
const name = await ensureSpec(spec);
|
|
1129
|
+
if (!message) fail("Provide a handoff message");
|
|
1130
|
+
if (!options.from || !options.to) {
|
|
1131
|
+
fail("Provide --from <agent> and --to <agent>");
|
|
1132
|
+
}
|
|
1133
|
+
const doc = await loadCoordination(name);
|
|
1134
|
+
await saveCoordination(
|
|
1135
|
+
addHandoff(doc, options.from, options.to, message, nowIso()),
|
|
1136
|
+
process.cwd(),
|
|
1137
|
+
);
|
|
1138
|
+
console.log(
|
|
1139
|
+
chalk.green(`✓ recorded handoff ${options.from} → ${options.to}`),
|
|
1140
|
+
);
|
|
1141
|
+
}
|