@rse/ase 1.0.7 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dst/ase-agent-definition-api.d.ts +184 -0
- package/dst/ase-agent-definition-api.js +6 -0
- package/dst/ase-agent-definition-builtin/condenser.js +42 -0
- package/dst/ase-agent-definition-builtin/editor.js +45 -0
- package/dst/ase-agent-definition-builtin/estimator.js +98 -0
- package/dst/ase-agent-definition-builtin/implementer.js +121 -0
- package/dst/ase-agent-definition-builtin/integrator.js +56 -0
- package/dst/ase-agent-definition-builtin/qualifier.js +61 -0
- package/dst/ase-agent-definition-builtin/rebooter.js +41 -0
- package/dst/ase-agent-definition-builtin/researcher.js +77 -0
- package/dst/ase-agent-definition-core.js +827 -0
- package/dst/ase-agent-fleet.js +772 -0
- package/dst/ase-agent-process.js +232 -0
- package/dst/ase-agent-relay-client.js +509 -0
- package/dst/ase-agent-relay-core.js +213 -0
- package/dst/ase-agent-relay-server-bind.js +242 -0
- package/dst/ase-agent-relay-server-cli.js +50 -0
- package/dst/ase-agent-runtime-claim.js +189 -0
- package/dst/ase-agent-runtime-engine.js +1100 -0
- package/dst/ase-agent-runtime-exec.js +275 -0
- package/dst/ase-agent-runtime-phase.js +581 -0
- package/dst/ase-agent-runtime-request.js +272 -0
- package/dst/ase-agent-runtime-tool.js +207 -0
- package/dst/ase-agent-runtime.js +1316 -0
- package/dst/ase-agent.js +65 -0
- package/dst/ase-artifact.js +6 -5
- package/dst/ase-config-core.js +465 -0
- package/dst/ase-config-schema.js +357 -0
- package/dst/ase-config-scope.js +172 -0
- package/dst/ase-config.js +25 -585
- package/dst/ase-hook.js +228 -111
- package/dst/ase-lib-daemon-cli.js +130 -0
- package/dst/ase-lib-hash.js +20 -0
- package/dst/{ase-log.js → ase-lib-log.js} +40 -7
- package/dst/ase-lib-server-bind.js +115 -0
- package/dst/ase-lib-server-cli.js +474 -0
- package/dst/{ase-table.js → ase-lib-table.js} +1 -1
- package/dst/{ase-version.js → ase-lib-version.js} +8 -1
- package/dst/ase-man.js +96 -0
- package/dst/ase-mcp.js +166 -13
- package/dst/{ase-getopt.js → ase-service-getopt.js} +82 -80
- package/dst/{ase-markdown.js → ase-service-markdown.js} +18 -11
- package/dst/ase-service-otel.js +1246 -0
- package/dst/{ase-skills.js → ase-service-skills.js} +29 -64
- package/dst/ase-service-state.js +101 -0
- package/dst/ase-service.js +269 -81
- package/dst/ase-setup-common.js +141 -0
- package/dst/ase-setup-mcp.js +359 -0
- package/dst/ase-setup-settings.js +837 -0
- package/dst/ase-setup.js +113 -869
- package/dst/ase-spec.js +31 -19
- package/dst/ase-statusline.js +108 -43
- package/dst/ase-task-board-core.js +739 -0
- package/dst/ase-task-board-filter.js +127 -0
- package/dst/ase-task-board-flow.js +237 -0
- package/dst/ase-task-board-graph.js +497 -0
- package/dst/ase-task-board-stats.js +263 -0
- package/dst/ase-task-board-tui-control.js +775 -0
- package/dst/ase-task-board-tui-model.js +1008 -0
- package/dst/ase-task-board-tui-popup.js +626 -0
- package/dst/ase-task-board-tui-style.js +106 -0
- package/dst/ase-task-board-tui-view.js +847 -0
- package/dst/ase-task-board-tui.js +189 -0
- package/dst/ase-task-board-web-client.css +1 -0
- package/dst/ase-task-board-web-client.html +20 -0
- package/dst/ase-task-board-web-client.js +81 -0
- package/dst/ase-task-board-web-server.js +823 -0
- package/dst/ase-task-board.js +134 -0
- package/dst/ase-task-format.js +757 -0
- package/dst/ase-task-request.js +208 -0
- package/dst/ase-task-store-client.js +495 -0
- package/dst/ase-task-store-core.js +804 -0
- package/dst/ase-task-store-issues.js +237 -0
- package/dst/ase-task-store-plugin-api.js +6 -0
- package/dst/ase-task-store-plugin-ase.js +484 -0
- package/dst/ase-task-store-plugin-delegate.js +137 -0
- package/dst/ase-task-store-plugin-gitea.js +666 -0
- package/dst/ase-task-store-plugin-github.js +696 -0
- package/dst/ase-task-store-plugin-gitlab.js +668 -0
- package/dst/ase-task-store-server-bind.js +538 -0
- package/dst/ase-task-store-server-cli.js +154 -0
- package/dst/ase-task.js +1335 -357
- package/dst/{ase-compat.js → ase-util-compat.js} +2 -2
- package/dst/{ase-diagram.js → ase-util-diagram.js} +107 -32
- package/dst/{ase-meta.js → ase-util-meta.js} +2 -2
- package/dst/{ase-metric.js → ase-util-metric.js} +2 -2
- package/dst/{ase-mint.js → ase-util-mint.js} +19 -12
- package/dst/{ase-worktree.js → ase-util-worktree.js} +30 -19
- package/dst/ase-util.js +20 -0
- package/dst/ase.js +108 -32
- package/dst/man/ase-agent-adhoc.1 +515 -0
- package/dst/man/ase-agent-condenser.1 +193 -0
- package/dst/man/ase-agent-editor.1 +212 -0
- package/dst/man/ase-agent-estimator.1 +214 -0
- package/dst/man/ase-agent-fleet.1 +185 -0
- package/dst/man/ase-agent-implementer.1 +233 -0
- package/dst/man/ase-agent-integrator.1 +213 -0
- package/dst/man/ase-agent-qualifier.1 +214 -0
- package/dst/man/ase-agent-rebooter.1 +193 -0
- package/dst/man/ase-agent-relay.1 +128 -0
- package/dst/man/ase-agent-researcher.1 +217 -0
- package/dst/man/ase-agent.1 +264 -0
- package/dst/man/ase-artifact-list.1 +38 -0
- package/dst/man/ase-artifact-name.1 +39 -0
- package/dst/man/ase-artifact.1 +56 -0
- package/dst/man/ase-config-delete.1 +45 -0
- package/dst/man/ase-config-edit.1 +48 -0
- package/dst/man/ase-config-get.1 +46 -0
- package/dst/man/ase-config-init.1 +60 -0
- package/dst/man/ase-config-list.1 +42 -0
- package/dst/man/ase-config-set.1 +52 -0
- package/dst/man/ase-config.1 +231 -0
- package/dst/man/ase-hook-permission-request.1 +36 -0
- package/dst/man/ase-hook-pre-tool-use.1 +59 -0
- package/dst/man/ase-hook-session-end.1 +47 -0
- package/dst/man/ase-hook-session-persona.1 +37 -0
- package/dst/man/ase-hook-session-start.1 +81 -0
- package/dst/man/ase-hook-stop.1 +34 -0
- package/dst/man/ase-hook-user-prompt-submit.1 +34 -0
- package/dst/man/ase-hook.1 +51 -0
- package/dst/man/ase-man.1 +67 -0
- package/dst/man/ase-mcp.1 +61 -0
- package/dst/man/ase-service-log.1 +43 -0
- package/dst/man/ase-service-send.1 +47 -0
- package/dst/man/ase-service-start.1 +54 -0
- package/dst/man/ase-service-status.1 +46 -0
- package/dst/man/ase-service-stop.1 +37 -0
- package/dst/man/ase-service.1 +85 -0
- package/dst/man/ase-setup-disable.1 +44 -0
- package/dst/man/ase-setup-enable.1 +45 -0
- package/dst/man/ase-setup-install.1 +73 -0
- package/dst/man/ase-setup-mcp.1 +124 -0
- package/dst/man/ase-setup-status.1 +60 -0
- package/dst/man/ase-setup-statusline.1 +92 -0
- package/dst/man/ase-setup-telemetry.1 +92 -0
- package/dst/man/ase-setup-uninstall.1 +59 -0
- package/dst/man/ase-setup-update.1 +64 -0
- package/dst/man/ase-setup.1 +93 -0
- package/dst/man/ase-spec-export.1 +62 -0
- package/dst/man/ase-spec-lint.1 +40 -0
- package/dst/man/ase-spec-preview.1 +40 -0
- package/dst/man/ase-spec.1 +46 -0
- package/dst/man/ase-statusline.1 +189 -0
- package/dst/man/ase-task-assign.1 +71 -0
- package/dst/man/ase-task-board.1 +384 -0
- package/dst/man/ase-task-delete.1 +35 -0
- package/dst/man/ase-task-edit.1 +46 -0
- package/dst/man/ase-task-idscheme.1 +65 -0
- package/dst/man/ase-task-lifecycle.1 +57 -0
- package/dst/man/ase-task-list.1 +47 -0
- package/dst/man/ase-task-load.1 +36 -0
- package/dst/man/ase-task-newid.1 +61 -0
- package/dst/man/ase-task-purge.1 +37 -0
- package/dst/man/ase-task-rename.1 +44 -0
- package/dst/man/ase-task-request.1 +61 -0
- package/dst/man/ase-task-save.1 +48 -0
- package/dst/man/ase-task-status.1 +55 -0
- package/dst/man/ase-task-store.1 +194 -0
- package/dst/man/ase-task-view.1 +40 -0
- package/dst/man/ase-task.1 +160 -0
- package/dst/man/ase-util-compat.1 +25 -0
- package/dst/man/ase-util-diagram.1 +76 -0
- package/dst/man/ase-util-meta.1 +45 -0
- package/dst/man/ase-util-metric.1 +54 -0
- package/dst/man/ase-util-mint.1 +59 -0
- package/dst/man/ase-util-worktree.1 +72 -0
- package/dst/man/ase-util.1 +50 -0
- package/dst/man/ase.1 +124 -0
- package/package.json +81 -21
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.github/plugin/plugin.json +1 -1
- package/plugin/agents/ase-meta-diagram.md +12 -8
- package/plugin/agents/ase-repo-review.md +322 -0
- package/plugin/hooks/hooks-codex.json +5 -0
- package/plugin/hooks/hooks-copilot.json +5 -0
- package/plugin/hooks/hooks.json +8 -0
- package/plugin/meta/ase-common-approach.md +117 -0
- package/plugin/meta/ase-common-changeset.md +256 -0
- package/plugin/meta/ase-common-code.md +10 -173
- package/plugin/meta/ase-common-grill.md +317 -0
- package/plugin/meta/ase-common-resolve.md +305 -0
- package/plugin/meta/ase-common-task.md +83 -4
- package/plugin/meta/ase-constitution.md +0 -2
- package/plugin/meta/ase-format-specbook.md +208 -25
- package/plugin/meta/ase-format-specbook.yaml +242 -851
- package/plugin/meta/ase-format-task.md +669 -125
- package/plugin/meta/ase-persona.md +3 -4
- package/plugin/meta/ase-skill.md +9 -0
- package/plugin/mods/ase-mods.ts +409 -0
- package/plugin/mods/claude-code.d.ts +13417 -0
- package/plugin/skills/ase-arch-analyze/SKILL.md +1 -1
- package/plugin/skills/ase-arch-analyze/help.md +2 -2
- package/plugin/skills/ase-code-analyze/SKILL.md +59 -8
- package/plugin/skills/ase-code-analyze/help.md +29 -4
- package/plugin/skills/ase-code-edit/SKILL.md +107 -181
- package/plugin/skills/ase-code-edit/help.md +118 -31
- package/plugin/skills/ase-code-lint/SKILL.md +64 -13
- package/plugin/skills/ase-code-lint/help.md +32 -2
- package/plugin/skills/ase-docs-proofread/SKILL.md +4 -4
- package/plugin/skills/ase-docs-proofread/help.md +1 -1
- package/plugin/skills/ase-docs-refine/SKILL.md +4 -4
- package/plugin/skills/ase-docs-shorten/SKILL.md +4 -4
- package/plugin/skills/ase-help-intent/data.md +2164 -1087
- package/plugin/skills/ase-help-intent/help.md +1 -1
- package/plugin/skills/ase-help-skill/catalog.md +12 -9
- package/plugin/skills/ase-meta-brainstorm/SKILL.md +1 -1
- package/plugin/skills/ase-meta-config/SKILL.md +3 -4
- package/plugin/skills/ase-meta-config/help.md +31 -10
- package/plugin/skills/ase-meta-mint/SKILL.md +5 -5
- package/plugin/skills/ase-meta-mint/help.md +10 -5
- package/plugin/skills/ase-meta-why/help.md +1 -1
- package/plugin/skills/ase-meta-workflow/SKILL.md +7 -7
- package/plugin/skills/ase-meta-workflow/help.md +4 -4
- package/plugin/skills/ase-meta-workflow/sample.md +3 -3
- package/plugin/skills/ase-meta-workflow/workflow.txt +22 -18
- package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/SKILL.md +4 -3
- package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/help.md +5 -5
- package/plugin/skills/{ase-meta-commit → ase-repo-commit}/SKILL.md +6 -6
- package/plugin/skills/{ase-meta-commit → ase-repo-commit}/help.md +5 -5
- package/plugin/skills/{ase-meta-diff → ase-repo-diff}/SKILL.md +6 -6
- package/plugin/skills/{ase-meta-diff → ase-repo-diff}/help.md +8 -8
- package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/SKILL.md +19 -19
- package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/help.md +10 -12
- package/plugin/skills/ase-repo-merge/SKILL.md +346 -0
- package/plugin/skills/ase-repo-merge/help.md +107 -0
- package/plugin/skills/ase-repo-resolve/SKILL.md +158 -0
- package/plugin/skills/ase-repo-resolve/help.md +102 -0
- package/plugin/skills/ase-repo-review/SKILL.md +406 -0
- package/plugin/skills/ase-repo-review/help.md +181 -0
- package/plugin/skills/ase-spec-edit/SKILL.md +117 -183
- package/plugin/skills/ase-spec-edit/help.md +107 -26
- package/plugin/skills/ase-sync-reconcile/help.md +1 -1
- package/plugin/skills/ase-task-assign/SKILL.md +111 -0
- package/plugin/skills/ase-task-assign/help.md +119 -0
- package/plugin/skills/ase-task-condense/SKILL.md +21 -21
- package/plugin/skills/ase-task-condense/help.md +12 -10
- package/plugin/skills/ase-task-delete/SKILL.md +1 -1
- package/plugin/skills/ase-task-dissect/SKILL.md +71 -48
- package/plugin/skills/ase-task-dissect/help.md +10 -9
- package/plugin/skills/ase-task-edit/SKILL.md +379 -94
- package/plugin/skills/ase-task-edit/help.md +95 -22
- package/plugin/skills/ase-task-estimate/SKILL.md +492 -0
- package/plugin/skills/ase-task-estimate/help.md +158 -0
- package/plugin/skills/ase-task-grill/SKILL.md +246 -138
- package/plugin/skills/ase-task-grill/help.md +129 -26
- package/plugin/skills/ase-task-id/SKILL.md +8 -1
- package/plugin/skills/ase-task-id/help.md +6 -3
- package/plugin/skills/ase-task-implement/SKILL.md +535 -145
- package/plugin/skills/ase-task-implement/help.md +138 -47
- package/plugin/skills/ase-task-integrate/SKILL.md +549 -0
- package/plugin/skills/ase-task-integrate/help.md +187 -0
- package/plugin/skills/ase-task-list/SKILL.md +55 -23
- package/plugin/skills/ase-task-list/help.md +45 -21
- package/plugin/skills/ase-task-reboot/SKILL.md +84 -43
- package/plugin/skills/ase-task-reboot/help.md +28 -8
- package/plugin/skills/ase-task-rename/SKILL.md +10 -2
- package/plugin/skills/ase-task-research/SKILL.md +386 -0
- package/plugin/skills/ase-task-research/help.md +102 -0
- package/plugin/skills/ase-task-status/SKILL.md +137 -0
- package/plugin/skills/ase-task-status/help.md +80 -0
- package/plugin/skills/ase-task-view/SKILL.md +49 -24
- package/plugin/skills/ase-task-view/help.md +19 -9
- package/plugin/agents/ase-meta-review.md +0 -187
- package/plugin/skills/ase-code-craft/SKILL.md +0 -264
- package/plugin/skills/ase-code-craft/help.md +0 -122
- package/plugin/skills/ase-code-refactor/SKILL.md +0 -232
- package/plugin/skills/ase-code-refactor/help.md +0 -109
- package/plugin/skills/ase-code-resolve/SKILL.md +0 -289
- package/plugin/skills/ase-code-resolve/help.md +0 -116
- package/plugin/skills/ase-meta-review/SKILL.md +0 -210
- package/plugin/skills/ase-meta-review/help.md +0 -96
- package/plugin/skills/ase-task-preflight/SKILL.md +0 -166
- package/plugin/skills/ase-task-preflight/help.md +0 -82
- /package/dst/{ase-ignore.js → ase-lib-ignore.js} +0 -0
- /package/dst/{ase-stdio.js → ase-lib-stdio.js} +0 -0
- /package/dst/{ase-kv.js → ase-service-kv.js} +0 -0
- /package/dst/{ase-sleep.js → ase-service-sleep.js} +0 -0
- /package/dst/{ase-timestamp.js → ase-service-timestamp.js} +0 -0
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
/*
|
|
2
|
+
** Agentic Software Engineering (ASE)
|
|
3
|
+
** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
4
|
+
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
|
+
*/
|
|
6
|
+
import YAML from "yaml";
|
|
7
|
+
import { nanoid } from "./ase-lib-hash.js";
|
|
8
|
+
/* the pre-defined task lifecycle models (see "project.task.lifecycle") */
|
|
9
|
+
export const taskLifecycles = {
|
|
10
|
+
solo: {
|
|
11
|
+
name: "solo",
|
|
12
|
+
states: ["OPEN", "SHELVED", "CLOSED", "CANCELLED"],
|
|
13
|
+
initial: "OPEN",
|
|
14
|
+
finished: ["CLOSED", "CANCELLED"],
|
|
15
|
+
activity: [],
|
|
16
|
+
transitions: {
|
|
17
|
+
OPEN: ["SHELVED", "CLOSED", "CANCELLED"],
|
|
18
|
+
SHELVED: ["OPEN", "CANCELLED"],
|
|
19
|
+
CLOSED: [],
|
|
20
|
+
CANCELLED: []
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
team: {
|
|
24
|
+
name: "team",
|
|
25
|
+
states: ["PLANNING", "SHELVED", "IMPLEMENTING", "STALLED", "IMPLEMENTED", "CANCELLED"],
|
|
26
|
+
initial: "PLANNING",
|
|
27
|
+
finished: ["IMPLEMENTED", "CANCELLED"],
|
|
28
|
+
activity: ["IMPLEMENTING"],
|
|
29
|
+
transitions: {
|
|
30
|
+
PLANNING: ["SHELVED", "IMPLEMENTING", "CANCELLED"],
|
|
31
|
+
SHELVED: ["PLANNING", "CANCELLED"],
|
|
32
|
+
IMPLEMENTING: ["PLANNING", "STALLED", "IMPLEMENTED", "CANCELLED"],
|
|
33
|
+
STALLED: ["IMPLEMENTING", "CANCELLED"],
|
|
34
|
+
IMPLEMENTED: [],
|
|
35
|
+
CANCELLED: []
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
enterprise: {
|
|
39
|
+
name: "enterprise",
|
|
40
|
+
states: [
|
|
41
|
+
"DRAFTED", "SHELVED", "PLANNING", "PLANNED", "STALLED", "IMPLEMENTING",
|
|
42
|
+
"IMPLEMENTED", "DECLINED", "QUALIFYING", "QUALIFIED", "DEFERRED", "INTEGRATING",
|
|
43
|
+
"INTEGRATED", "CANCELLED"
|
|
44
|
+
],
|
|
45
|
+
initial: "DRAFTED",
|
|
46
|
+
finished: ["INTEGRATED", "CANCELLED"],
|
|
47
|
+
activity: ["PLANNING", "IMPLEMENTING", "QUALIFYING", "INTEGRATING"],
|
|
48
|
+
transitions: {
|
|
49
|
+
DRAFTED: ["SHELVED", "PLANNING", "CANCELLED"],
|
|
50
|
+
SHELVED: ["DRAFTED", "CANCELLED"],
|
|
51
|
+
PLANNING: ["DRAFTED", "SHELVED", "PLANNED", "CANCELLED"],
|
|
52
|
+
PLANNED: ["STALLED", "IMPLEMENTING", "CANCELLED"],
|
|
53
|
+
STALLED: ["PLANNED", "CANCELLED"],
|
|
54
|
+
IMPLEMENTING: ["DRAFTED", "PLANNED", "STALLED", "IMPLEMENTED", "CANCELLED"],
|
|
55
|
+
IMPLEMENTED: ["DECLINED", "QUALIFYING", "CANCELLED"],
|
|
56
|
+
DECLINED: ["IMPLEMENTED", "CANCELLED"],
|
|
57
|
+
QUALIFYING: ["DRAFTED", "PLANNED", "IMPLEMENTED", "DECLINED", "QUALIFIED", "CANCELLED"],
|
|
58
|
+
QUALIFIED: ["DEFERRED", "INTEGRATING", "CANCELLED"],
|
|
59
|
+
DEFERRED: ["QUALIFIED", "CANCELLED"],
|
|
60
|
+
INTEGRATING: ["DRAFTED", "PLANNED", "QUALIFIED", "DEFERRED", "INTEGRATED", "CANCELLED"],
|
|
61
|
+
INTEGRATED: [],
|
|
62
|
+
CANCELLED: []
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
/* the effective lifecycle status of a header: its "Status" key
|
|
67
|
+
(upper-cased), or the initial state of the model if absent */
|
|
68
|
+
export const taskStatus = (header, lifecycle) => {
|
|
69
|
+
const status = header.Status;
|
|
70
|
+
if (typeof status === "string" && status.trim() !== "")
|
|
71
|
+
return status.trim().toUpperCase();
|
|
72
|
+
return lifecycle.initial;
|
|
73
|
+
};
|
|
74
|
+
/* check a "Status" change against the task lifecycle model: returns
|
|
75
|
+
an error (else empty) if the changed status is not a state of the
|
|
76
|
+
model or not reachable from the old status via one or more transitions
|
|
77
|
+
(as one operation may perform several stages); an old status foreign
|
|
78
|
+
to the model may change to any state, to allow its correction */
|
|
79
|
+
export const checkStatus = (lifecycle, from, to) => {
|
|
80
|
+
if (to === from)
|
|
81
|
+
return "";
|
|
82
|
+
if (!lifecycle.states.includes(to))
|
|
83
|
+
return `status "${to}" is not a state of the "${lifecycle.name}" task lifecycle model ` +
|
|
84
|
+
`(expected one of: ${lifecycle.states.join(", ")})`;
|
|
85
|
+
if (!lifecycle.states.includes(from))
|
|
86
|
+
return "";
|
|
87
|
+
const seen = new Set([from]);
|
|
88
|
+
const queue = [from];
|
|
89
|
+
while (queue.length > 0) {
|
|
90
|
+
const state = queue.shift();
|
|
91
|
+
if (state === to)
|
|
92
|
+
return "";
|
|
93
|
+
for (const next of lifecycle.transitions[state] ?? [])
|
|
94
|
+
if (!seen.has(next)) {
|
|
95
|
+
seen.add(next);
|
|
96
|
+
queue.push(next);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return `status "${to}" is not reachable from status "${from}" in the "${lifecycle.name}" task lifecycle model`;
|
|
100
|
+
};
|
|
101
|
+
/* the states of the task lifecycle models, each mapped onto its candidate
|
|
102
|
+
states in another model (tried after the state itself), where absent
|
|
103
|
+
states fall back to the initial (resp. first finished) state only */
|
|
104
|
+
const statusCandidates = {
|
|
105
|
+
PLANNING: ["OPEN"],
|
|
106
|
+
PLANNED: ["PLANNING", "OPEN"],
|
|
107
|
+
IMPLEMENTING: ["OPEN"],
|
|
108
|
+
STALLED: ["OPEN"],
|
|
109
|
+
DECLINED: ["IMPLEMENTING", "OPEN"],
|
|
110
|
+
IMPLEMENTED: ["INTEGRATED", "CLOSED"],
|
|
111
|
+
QUALIFYING: ["IMPLEMENTED", "CLOSED"],
|
|
112
|
+
QUALIFIED: ["IMPLEMENTED", "CLOSED"],
|
|
113
|
+
DEFERRED: ["IMPLEMENTED", "CLOSED"],
|
|
114
|
+
INTEGRATING: ["IMPLEMENTED", "CLOSED"],
|
|
115
|
+
INTEGRATED: ["IMPLEMENTED", "CLOSED"],
|
|
116
|
+
CLOSED: ["INTEGRATED", "IMPLEMENTED"]
|
|
117
|
+
};
|
|
118
|
+
/* map a status from one task lifecycle model onto another: the first
|
|
119
|
+
candidate state present in the target model wins (for a finished
|
|
120
|
+
status only a finished one), else the initial (resp. first finished)
|
|
121
|
+
state; a status foreign to the source model is kept as-is */
|
|
122
|
+
export const mapStatus = (from, to, status) => {
|
|
123
|
+
if (!from.states.includes(status))
|
|
124
|
+
return status;
|
|
125
|
+
const finished = from.finished.includes(status);
|
|
126
|
+
const mapped = [status, ...(statusCandidates[status] ?? [])]
|
|
127
|
+
.find((state) => to.states.includes(state) && (!finished || to.finished.includes(state)));
|
|
128
|
+
return mapped ?? (finished ? to.finished[0] : to.initial);
|
|
129
|
+
};
|
|
130
|
+
/* adapt a status onto a task lifecycle model: a state of the model is kept,
|
|
131
|
+
a state foreign to it is mapped onto it via the first model knowing the
|
|
132
|
+
state (see "mapStatus"), and a status unknown to all models falls back
|
|
133
|
+
to the initial state of the model */
|
|
134
|
+
export const adaptStatus = (lifecycle, status) => {
|
|
135
|
+
if (lifecycle.states.includes(status))
|
|
136
|
+
return status;
|
|
137
|
+
const source = Object.values(taskLifecycles).find((model) => model.states.includes(status));
|
|
138
|
+
return source !== undefined ? mapStatus(source, lifecycle, status) : lifecycle.initial;
|
|
139
|
+
};
|
|
140
|
+
/* resolve an "include" and an "exclude" comma-separated lifecycle
|
|
141
|
+
state list into the effective state set a task plan has to be in
|
|
142
|
+
to be listed at all; the "none" sentinel and empty tokens are
|
|
143
|
+
silently dropped, the "finished" sentinel expands to the finished
|
|
144
|
+
states of the task lifecycle model, an empty "include" list means
|
|
145
|
+
all states, and the "exclude" list is applied after the "include" list */
|
|
146
|
+
export const resolveStates = (lifecycle, include, exclude) => {
|
|
147
|
+
const parse = (list) => list.split(",")
|
|
148
|
+
.map((token) => token.trim())
|
|
149
|
+
.filter((token) => token !== "" && token.toUpperCase() !== "NONE")
|
|
150
|
+
.flatMap((token) => {
|
|
151
|
+
const state = token.toUpperCase();
|
|
152
|
+
if (state === "FINISHED")
|
|
153
|
+
return lifecycle.finished;
|
|
154
|
+
if (!lifecycle.states.includes(state))
|
|
155
|
+
throw new Error(`invalid state "${token}" ` +
|
|
156
|
+
`(expected one of: ${lifecycle.states.join(", ")}, or "finished")`);
|
|
157
|
+
return [state];
|
|
158
|
+
});
|
|
159
|
+
const included = parse(include);
|
|
160
|
+
const excluded = parse(exclude);
|
|
161
|
+
const states = (included.length > 0 ? included : lifecycle.states)
|
|
162
|
+
.filter((state) => !excluded.includes(state));
|
|
163
|
+
if (states.length === 0)
|
|
164
|
+
throw new Error("\"include\" and \"exclude\" cancel out to an empty state set");
|
|
165
|
+
return states;
|
|
166
|
+
};
|
|
167
|
+
/* the id pattern of projects and sessions, and the one of tasks
|
|
168
|
+
(additionally allowing "#", as in ids like "#42", but no leading "-" or "_") */
|
|
169
|
+
export const ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
170
|
+
export const TASK_ID_RE = /^[A-Za-z0-9#][A-Za-z0-9#_-]*$/;
|
|
171
|
+
/* the reserved task id of the default task of a session */
|
|
172
|
+
export const DEFAULT_TASK_ID = "default";
|
|
173
|
+
/* the task id scheme callbacks, each mapping the optional argument
|
|
174
|
+
<arg> of "<kind>[:<arg>]" onto the scheme or an error message */
|
|
175
|
+
const idSchemes = {
|
|
176
|
+
slug: (arg) => {
|
|
177
|
+
if (arg !== undefined && !/^\d+$/.test(arg))
|
|
178
|
+
return "word count has to be a number";
|
|
179
|
+
const words = arg !== undefined ? Number.parseInt(arg, 10) : 2;
|
|
180
|
+
if (words < 1 || words > 16)
|
|
181
|
+
return "word count has to be 1-16";
|
|
182
|
+
return { kind: "slug", words, match: `^[a-z][a-z0-9]*(?:-[a-z0-9]+){0,${words - 1}}(?:-[0-9]+)?$` };
|
|
183
|
+
},
|
|
184
|
+
seq: (arg) => {
|
|
185
|
+
const m = /^((?:[A-Za-z#][A-Za-z#_-]*)?)%(?:0(\d+))?d([A-Za-z#_-]*)$/.exec(arg ?? "%d");
|
|
186
|
+
if (m === null)
|
|
187
|
+
return "template has to contain exactly one \"%d\" or \"%0<width>d\" " +
|
|
188
|
+
"and otherwise only the characters [A-Za-z#_-] (but not start with \"-\" or \"_\")";
|
|
189
|
+
const width = m[2] !== undefined ? Number.parseInt(m[2], 10) : 0;
|
|
190
|
+
if (width > 16)
|
|
191
|
+
return "width has to be 0-16";
|
|
192
|
+
/* prefix and suffix need no quoting, as [A-Za-z#_-] are no metacharacters */
|
|
193
|
+
const match = `^${m[1]}([0-9]${width > 0 ? `{${width},}` : "+"})${m[3]}$`;
|
|
194
|
+
return { kind: "seq", prefix: m[1], width, suffix: m[3], match };
|
|
195
|
+
},
|
|
196
|
+
any: (arg) => {
|
|
197
|
+
if (arg !== undefined)
|
|
198
|
+
return "scheme takes no argument";
|
|
199
|
+
return { kind: "any", match: TASK_ID_RE.source };
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
/* parse a task id scheme specification */
|
|
203
|
+
export const parseIdScheme = (spec) => {
|
|
204
|
+
const m = /^([a-z]+)(?::(.*))?$/.exec(spec);
|
|
205
|
+
const callback = m !== null && Object.hasOwn(idSchemes, m[1]) ? idSchemes[m[1]] : undefined;
|
|
206
|
+
if (m === null || callback === undefined)
|
|
207
|
+
throw new Error(`invalid task id scheme "${spec}" (expected "slug[:<words>]", "seq[:<template>]", or "any")`);
|
|
208
|
+
const scheme = callback(m[2]);
|
|
209
|
+
if (typeof scheme === "string")
|
|
210
|
+
throw new Error(`invalid task id scheme "${spec}" (${scheme})`);
|
|
211
|
+
return scheme;
|
|
212
|
+
};
|
|
213
|
+
/* check a task id scheme specification: returns an error (else empty) */
|
|
214
|
+
export const checkIdScheme = (spec) => {
|
|
215
|
+
try {
|
|
216
|
+
parseIdScheme(spec);
|
|
217
|
+
return "";
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
return err instanceof Error ? err.message : String(err);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
/* derive a slug from a title: diacritics stripped (and "ß" expanded), lower-cased,
|
|
224
|
+
and the first <words> alphanumeric words, starting with the first word
|
|
225
|
+
beginning with a letter, joined with "-" */
|
|
226
|
+
export const slugify = (title, words) => {
|
|
227
|
+
const list = title.normalize("NFKD").replace(/\p{M}/gu, "").replace(/ß/g, "ss").toLowerCase()
|
|
228
|
+
.split(/[^a-z0-9]+/).filter((word) => word !== "");
|
|
229
|
+
const i = list.findIndex((word) => /^[a-z]/.test(word));
|
|
230
|
+
return i < 0 ? "" : list.slice(i, i + words).join("-");
|
|
231
|
+
};
|
|
232
|
+
/* check whether a task id conforms to a task id scheme
|
|
233
|
+
(the reserved default task id always conforms) */
|
|
234
|
+
export const idConforms = (scheme, id) => id === DEFAULT_TASK_ID || new RegExp(scheme.match).test(id);
|
|
235
|
+
/* the warning (else empty) about a task id not conforming to a task id scheme */
|
|
236
|
+
export const idWarning = (spec, id) => idConforms(parseIdScheme(spec), id) ? "" :
|
|
237
|
+
`task id "${id}" does not conform to the task id scheme "${spec}" (see "project.task.idscheme")`;
|
|
238
|
+
/* the sequence number of a task id conforming to a "seq" task id scheme
|
|
239
|
+
(else 0, as for all ids under any other scheme or beyond the safe integer range) */
|
|
240
|
+
export const seqNumber = (scheme, id) => {
|
|
241
|
+
if (scheme.kind !== "seq")
|
|
242
|
+
return 0;
|
|
243
|
+
const n = Number.parseInt(new RegExp(scheme.match).exec(id)?.[1] ?? "0", 10);
|
|
244
|
+
return Number.isSafeInteger(n) ? n : 0;
|
|
245
|
+
};
|
|
246
|
+
/* determine the next free task id of a task id scheme, given all existing
|
|
247
|
+
task ids: for "seq" the highest number of all conforming ids and of the
|
|
248
|
+
high-water mark "seqmark" (of the removed ids) plus one, for "slug" the slug
|
|
249
|
+
of the title, and for "any" the proposed id (sanitized, else the slug of the
|
|
250
|
+
title), where a taken slug or id gets a numeric suffix */
|
|
251
|
+
export const nextTaskId = (scheme, ids, title = "", proposal = "", seqmark = 0) => {
|
|
252
|
+
const taken = new Set([...ids, DEFAULT_TASK_ID]);
|
|
253
|
+
let base;
|
|
254
|
+
if (scheme.kind === "seq") {
|
|
255
|
+
const n = ids.reduce((max, id) => Math.max(max, seqNumber(scheme, id)), seqmark) + 1;
|
|
256
|
+
return `${scheme.prefix}${String(n).padStart(scheme.width, "0")}${scheme.suffix}`;
|
|
257
|
+
}
|
|
258
|
+
else if (scheme.kind === "slug")
|
|
259
|
+
base = slugify(title, scheme.words);
|
|
260
|
+
else
|
|
261
|
+
base = proposal.trim().replace(/\s+/g, "-").replace(/[^A-Za-z0-9#_-]/g, "").replace(/^[_-]+/, "") || slugify(title, 2);
|
|
262
|
+
if (base === "")
|
|
263
|
+
throw new Error(`task id scheme "${scheme.kind}" requires a ` +
|
|
264
|
+
(scheme.kind === "any" ? "proposed id or " : "") + "title with at least one word beginning with a letter");
|
|
265
|
+
let id = base;
|
|
266
|
+
for (let n = 2; taken.has(id); n++)
|
|
267
|
+
id = `${base}-${n}`;
|
|
268
|
+
return id;
|
|
269
|
+
};
|
|
270
|
+
/* compare two task ids in natural order (numbers by their value) */
|
|
271
|
+
export const compareIds = (a, b) => a.localeCompare(b, "en", { numeric: true });
|
|
272
|
+
/* the task id of a task text: the value of the "Id:" key of its frontmatter,
|
|
273
|
+
or the empty string if absent */
|
|
274
|
+
export const taskTextId = (text) => {
|
|
275
|
+
const fm = /^---\r?\n([\s\S]*?\r?\n)---\r?\n/.exec(text);
|
|
276
|
+
return fm === null ? "" : (/^Id:[ \t]*(.*)$/m.exec(fm[1])?.[1].trim() ?? "");
|
|
277
|
+
};
|
|
278
|
+
/* the fixed value of the "Type" frontmatter key */
|
|
279
|
+
export const TASK_TYPE = "text/vnd.ase.task";
|
|
280
|
+
/* the frontmatter keys of the task plan format in their canonical
|
|
281
|
+
order, the array-typed ones among them, and the attachment keys
|
|
282
|
+
in their canonical order (see "ase-format-task.md") */
|
|
283
|
+
const frontKeys = ["Type", "Id", "Created", "Modified", "Group", "Phase", "After", "Status", "Assignee", "Kind", "Effort", "Tags", "Source", "Changeset", "Target"];
|
|
284
|
+
export const arrayKeys = ["After", "Tags"];
|
|
285
|
+
const attachKeys = ["Type", "Desc", "Created", "Modified", "Data", "File"];
|
|
286
|
+
/* the legacy task plan header lines, each mapped onto the
|
|
287
|
+
frontmatter key which superseded it */
|
|
288
|
+
const legacy = [
|
|
289
|
+
{ key: "Created", re: /^⎈[ \t]+Created:[ \t]*(.*)$/m },
|
|
290
|
+
{ key: "Modified", re: /^⚙[ \t]+Modified:[ \t]*(.*)$/m },
|
|
291
|
+
{ key: "Kind", re: /^☯[ \t]+Kind:[ \t]*(.*)$/m }
|
|
292
|
+
];
|
|
293
|
+
/* the legacy lifecycle states of the pre-lifecycle-model task plan
|
|
294
|
+
format, each mapped onto its candidate states in the current models
|
|
295
|
+
(first candidate present in the configured model wins, otherwise
|
|
296
|
+
the initial state of the model is used) */
|
|
297
|
+
const legacyStates = {
|
|
298
|
+
DRAFTED: [],
|
|
299
|
+
REJECTED: [],
|
|
300
|
+
APPROVED: ["PLANNED", "PLANNING", "OPEN"],
|
|
301
|
+
DEFERRED: ["SHELVED"],
|
|
302
|
+
STARTED: ["IMPLEMENTING", "OPEN"],
|
|
303
|
+
BLOCKED: ["STALLED", "OPEN"],
|
|
304
|
+
COMPLETED: ["INTEGRATED", "IMPLEMENTED", "CLOSED"],
|
|
305
|
+
CANCELLED: ["CANCELLED"]
|
|
306
|
+
};
|
|
307
|
+
/* migrate the legacy "Branch:" key (the branch the change set lands on, created
|
|
308
|
+
from HEAD on demand) into "Changeset:" (created from the source on demand),
|
|
309
|
+
where the legacy "current" (the checked-out branch) is the default "worktree":
|
|
310
|
+
returns the "Changeset:" value, or undefined if the key is to be dropped only */
|
|
311
|
+
export const migrateBranch = (branch) => branch !== "" && branch !== "current" ? `branch:${branch}` : undefined;
|
|
312
|
+
/* migrate the legacy "preflight" kind of an implementation draft attachment
|
|
313
|
+
into "draft" (in place) */
|
|
314
|
+
export const migrateAttachment = (att) => {
|
|
315
|
+
if (att.Type !== undefined && /^text\/x-diff\s*(?:;|$)/i.test(att.Type))
|
|
316
|
+
att.Type = att.Type.replace(/(;\s*kind\s*=\s*)("?)preflight\2(\s*(?:;|$))/i, "$1$2draft$2$3");
|
|
317
|
+
return att;
|
|
318
|
+
};
|
|
319
|
+
/* render a single key line with a column-aligned key
|
|
320
|
+
(a key too long for the column keeps at least one separating space) */
|
|
321
|
+
const keyLine = (key, value) => {
|
|
322
|
+
if (!/^[A-Za-z]+$/.test(key) || /[\r\n]/.test(value))
|
|
323
|
+
throw new Error(`invalid key line for key "${key}" (key has to be alphabetic and value single-line)`);
|
|
324
|
+
return value === "" ? key + ":" : (key + ":").padEnd(Math.max(11, key.length + 2)) + value;
|
|
325
|
+
};
|
|
326
|
+
/* the key line pattern of the frontmatter and attachment blocks
|
|
327
|
+
(value to be right-trimmed by the caller, avoiding regex backtracking) */
|
|
328
|
+
const keyLineRe = /^([A-Za-z]+):[ \t]*(.*)$/;
|
|
329
|
+
/* parse the frontmatter block of a plan text into its key/value pairs,
|
|
330
|
+
keeping any non-key lines for a verbatim pass-through, plus the
|
|
331
|
+
length of the block; returns null if the text carries no block */
|
|
332
|
+
const parseFront = (text) => {
|
|
333
|
+
const fm = /^---\r?\n([\s\S]*?\r?\n)---\r?\n/.exec(text);
|
|
334
|
+
if (fm === null)
|
|
335
|
+
return null;
|
|
336
|
+
const keys = new Map();
|
|
337
|
+
const other = [];
|
|
338
|
+
for (const line of fm[1].split(/\r?\n/)) {
|
|
339
|
+
const m = keyLineRe.exec(line);
|
|
340
|
+
if (m !== null)
|
|
341
|
+
keys.set(m[1], m[2].trimEnd());
|
|
342
|
+
else if (line !== "")
|
|
343
|
+
other.push(line);
|
|
344
|
+
}
|
|
345
|
+
return { keys, other, length: fm[0].length };
|
|
346
|
+
};
|
|
347
|
+
/* order keys canonically, with any unknown keys trailing */
|
|
348
|
+
const orderKeys = (canonical, keys) => [...canonical.filter((key) => keys.includes(key)), ...keys.filter((key) => !canonical.includes(key))];
|
|
349
|
+
/* re-assemble a frontmatter block with the keys in canonical order
|
|
350
|
+
(unknown keys and non-key lines trailing) and the values re-aligned */
|
|
351
|
+
const assembleFront = (keys, other) => {
|
|
352
|
+
const front = orderKeys(frontKeys, [...keys.keys()]).map((key) => keyLine(key, keys.get(key)));
|
|
353
|
+
return `---\n${[...front, ...other].join("\n")}\n---\n`;
|
|
354
|
+
};
|
|
355
|
+
/* normalize a legacy task plan text into the current Markdown frontmatter
|
|
356
|
+
shape, so every consumer sees a single plan shape only: a plan
|
|
357
|
+
carrying its metadata in the "# TASK <id>: <title>" heading and
|
|
358
|
+
the "⎈"/"⚙"/"☯" glyph header lines is first lifted into a
|
|
359
|
+
frontmatter block; then the frontmatter block is migrated by
|
|
360
|
+
inserting the mandatory "Type:" key, mapping a legacy "Status:" value
|
|
361
|
+
onto the configured lifecycle model, rewriting the legacy "Properties:"
|
|
362
|
+
key into "Tags:", rewriting the legacy "Branch:" key into "Changeset:",
|
|
363
|
+
and re-ordering and re-aligning the keys; any content without a
|
|
364
|
+
frontmatter block or task heading is passed through verbatim; absent
|
|
365
|
+
optional keys are never materialized, as they read as their default
|
|
366
|
+
value */
|
|
367
|
+
export const normalizeTaskText = (id, text, lifecycle) => {
|
|
368
|
+
if (text === "")
|
|
369
|
+
return text;
|
|
370
|
+
/* lift the heading and the glyph header lines into their frontmatter
|
|
371
|
+
keys, with the task id taken from the authoritative filename-derived id */
|
|
372
|
+
if (!/^---\r?\n/.test(text)) {
|
|
373
|
+
const heading = /^#[ \t]+TASK(?:[ \t]+[A-Za-z0-9#][A-Za-z0-9#_-]*)?[ \t]*:[ \t]*(.*)$/m.exec(text);
|
|
374
|
+
if (heading === null)
|
|
375
|
+
return text;
|
|
376
|
+
let body = text.replace(heading[0], "");
|
|
377
|
+
const front = [keyLine("Id", id)];
|
|
378
|
+
for (const entry of legacy) {
|
|
379
|
+
const m = entry.re.exec(body);
|
|
380
|
+
if (m === null)
|
|
381
|
+
continue;
|
|
382
|
+
front.push(keyLine(entry.key, m[1].trim()));
|
|
383
|
+
body = body.replace(m[0], "");
|
|
384
|
+
}
|
|
385
|
+
text = `---\n${front.join("\n")}\n---\n\n` +
|
|
386
|
+
`# TASK: ${heading[1].trim()}\n\n` +
|
|
387
|
+
body.replace(/^(?:[ \t]*\r?\n)+/, "");
|
|
388
|
+
}
|
|
389
|
+
/* parse the frontmatter block into its key/value pairs */
|
|
390
|
+
const fm = parseFront(text);
|
|
391
|
+
if (fm === null)
|
|
392
|
+
return text;
|
|
393
|
+
const keys = fm.keys;
|
|
394
|
+
/* a plan is legacy if it lacks the mandatory "Type:" key (or still
|
|
395
|
+
carries the "Properties:" key), decided before "Type:" is inserted */
|
|
396
|
+
const isLegacy = !keys.has("Type") || keys.has("Properties");
|
|
397
|
+
/* insert the mandatory "Type:" key */
|
|
398
|
+
if (!keys.has("Type"))
|
|
399
|
+
keys.set("Type", TASK_TYPE);
|
|
400
|
+
/* map a legacy "Status:" value unconditionally onto the configured
|
|
401
|
+
lifecycle model, as legacy values like "DEFERRED" carry
|
|
402
|
+
a different meaning than their same-named current states */
|
|
403
|
+
const status = keys.get("Status");
|
|
404
|
+
if (isLegacy && status !== undefined && Object.hasOwn(legacyStates, status)) {
|
|
405
|
+
const state = legacyStates[status].find((state) => lifecycle.states.includes(state));
|
|
406
|
+
keys.set("Status", state ?? lifecycle.initial);
|
|
407
|
+
}
|
|
408
|
+
/* rewrite the legacy "Properties:" key into "Tags:", where the
|
|
409
|
+
legacy "grilled" property (a fully grilled plan) becomes one
|
|
410
|
+
"grilled:" tag per section and any other property a plain tag */
|
|
411
|
+
const properties = keys.get("Properties");
|
|
412
|
+
if (properties !== undefined) {
|
|
413
|
+
keys.delete("Properties");
|
|
414
|
+
const tags = properties.split(",")
|
|
415
|
+
.map((token) => token.trim())
|
|
416
|
+
.filter((token) => token !== "" && token.toLowerCase() !== "none")
|
|
417
|
+
.flatMap((token) => token.toLowerCase() === "grilled" ?
|
|
418
|
+
["grilled:specification", "grilled:design", "grilled:verification"] : [token]);
|
|
419
|
+
if (tags.length > 0)
|
|
420
|
+
keys.set("Tags", [keys.get("Tags") ?? "", ...tags].filter((tag) => tag !== "").join(", "));
|
|
421
|
+
}
|
|
422
|
+
/* rewrite the legacy "Branch:" key into "Changeset:" (see migrateBranch) */
|
|
423
|
+
const branch = keys.get("Branch");
|
|
424
|
+
if (branch !== undefined) {
|
|
425
|
+
keys.delete("Branch");
|
|
426
|
+
const changeset = migrateBranch(branch);
|
|
427
|
+
if (changeset !== undefined && !keys.has("Changeset"))
|
|
428
|
+
keys.set("Changeset", changeset);
|
|
429
|
+
}
|
|
430
|
+
/* re-assemble the frontmatter block, followed by the untouched
|
|
431
|
+
remainder of the plan */
|
|
432
|
+
return assembleFront(keys, fm.other) + text.slice(fm.length);
|
|
433
|
+
};
|
|
434
|
+
/* track the fenced code block state across a body line: returns the
|
|
435
|
+
opening fence, or an empty string if outside any fenced code block */
|
|
436
|
+
export const fenceTrack = (fence, line) => {
|
|
437
|
+
const m = /^ {0,3}(`{3,}|~{3,})[ \t]*(.*)$/.exec(line);
|
|
438
|
+
if (m !== null && fence === "")
|
|
439
|
+
return m[1];
|
|
440
|
+
else if (m !== null && m[2] === "" && m[1][0] === fence[0] && m[1].length >= fence.length)
|
|
441
|
+
return "";
|
|
442
|
+
return fence;
|
|
443
|
+
};
|
|
444
|
+
/* check whether a body survives a round-trip through the task plan text:
|
|
445
|
+
returns null, or the reason why the parser would misread it -- a "---"
|
|
446
|
+
line outside a fenced code block followed by a "Type:" line (read as an
|
|
447
|
+
attachment start) or an unclosed fenced code block (swallowing all attachments) */
|
|
448
|
+
export const bodyConflict = (body) => {
|
|
449
|
+
const lines = body.split(/\r?\n/);
|
|
450
|
+
let fence = "";
|
|
451
|
+
let open = 0;
|
|
452
|
+
for (let i = 0; i < lines.length; i++) {
|
|
453
|
+
const prev = fence;
|
|
454
|
+
fence = fenceTrack(fence, lines[i]);
|
|
455
|
+
if (prev === "" && fence !== "")
|
|
456
|
+
open = i + 1;
|
|
457
|
+
if (lines[i] === "---" && fence === "" && /^Type:/.test(lines[i + 1] ?? ""))
|
|
458
|
+
return `line ${i + 1}: a "---" line followed by a "Type:" line would start an attachment`;
|
|
459
|
+
}
|
|
460
|
+
return fence !== "" ? `line ${open}: fenced code block is not closed` : null;
|
|
461
|
+
};
|
|
462
|
+
/* parse a task plan text (after legacy normalization) into the JSON
|
|
463
|
+
task plan structure: the text is split into blocks at its "---"
|
|
464
|
+
lines, the first block yields the header (with "After" and "Tags"
|
|
465
|
+
split into arrays and "Id" taken from the authoritative id), the
|
|
466
|
+
second block the body (without its leading and trailing empty line),
|
|
467
|
+
and every further block an attachment (with a "Data: |4+" or "|4-" literal
|
|
468
|
+
block scalar de-indented and a legacy "preflight" diff kind migrated into
|
|
469
|
+
"draft"); a text without a closed frontmatter is taken as body;
|
|
470
|
+
a "---" line not starting a block stays part of the body */
|
|
471
|
+
export const parseTaskText = (id, text, lifecycle) => {
|
|
472
|
+
text = normalizeTaskText(id, text, lifecycle);
|
|
473
|
+
const lines = text.split(/\r?\n/);
|
|
474
|
+
if (lines.length > 0 && lines[lines.length - 1] === "")
|
|
475
|
+
lines.pop();
|
|
476
|
+
const blocks = [];
|
|
477
|
+
let fence = "";
|
|
478
|
+
for (let i = 0; i < lines.length; i++) {
|
|
479
|
+
/* track the fenced code blocks of the body */
|
|
480
|
+
if (blocks.length >= 2)
|
|
481
|
+
fence = fenceTrack(fence, lines[i]);
|
|
482
|
+
/* beyond the frontmatter, a "---" line starts a block only outside a
|
|
483
|
+
fenced code block and if followed by the mandatory "Type:" key of an
|
|
484
|
+
attachment -- else it is kept as body (lossless for legacy plans) */
|
|
485
|
+
if (lines[i] === "---" && lines[0] === "---" && fence === ""
|
|
486
|
+
&& (blocks.length < 2 || /^Type:/.test(lines[i + 1] ?? "")))
|
|
487
|
+
blocks.push([]);
|
|
488
|
+
else if (blocks.length > 0)
|
|
489
|
+
blocks[blocks.length - 1].push(lines[i]);
|
|
490
|
+
}
|
|
491
|
+
const header = {};
|
|
492
|
+
let body;
|
|
493
|
+
const attachment = [];
|
|
494
|
+
if (blocks.length < 2)
|
|
495
|
+
body = text;
|
|
496
|
+
else {
|
|
497
|
+
for (const line of blocks[0]) {
|
|
498
|
+
const m = keyLineRe.exec(line);
|
|
499
|
+
if (m === null)
|
|
500
|
+
continue;
|
|
501
|
+
header[m[1]] = arrayKeys.includes(m[1]) ?
|
|
502
|
+
m[2].split(",").map((token) => token.trim()).filter((token) => token !== "") :
|
|
503
|
+
m[2].trimEnd();
|
|
504
|
+
}
|
|
505
|
+
const bodyLines = blocks[1] ?? [];
|
|
506
|
+
if (bodyLines[0] === "")
|
|
507
|
+
bodyLines.shift();
|
|
508
|
+
while (bodyLines.length > 0 && bodyLines[bodyLines.length - 1] === "")
|
|
509
|
+
bodyLines.pop();
|
|
510
|
+
body = bodyLines.length > 0 ? `${bodyLines.join("\n")}\n` : "";
|
|
511
|
+
for (const block of blocks.slice(2)) {
|
|
512
|
+
/* drop one trailing empty line of the block, as it separates the block
|
|
513
|
+
from the next one (written by formatTaskText only behind a block ending
|
|
514
|
+
with an empty line, but typically added by hand, too) */
|
|
515
|
+
if (block.length > 0 && block[block.length - 1] === "")
|
|
516
|
+
block.pop();
|
|
517
|
+
const att = {};
|
|
518
|
+
for (let i = 0; i < block.length; i++) {
|
|
519
|
+
const m = keyLineRe.exec(block[i]);
|
|
520
|
+
if (m === null)
|
|
521
|
+
continue;
|
|
522
|
+
if (m[1] === "Data" && m[2].startsWith("|")) {
|
|
523
|
+
const data = [];
|
|
524
|
+
while (i + 1 < block.length && (block[i + 1] === "" || block[i + 1].startsWith(" ")))
|
|
525
|
+
data.push(block[++i].slice(4));
|
|
526
|
+
/* honor the "strip" chomping indicator of a "|4-" block scalar */
|
|
527
|
+
if (m[2].trimEnd().endsWith("-")) {
|
|
528
|
+
while (data.length > 0 && data[data.length - 1] === "")
|
|
529
|
+
data.pop();
|
|
530
|
+
att.Data = data.join("\n");
|
|
531
|
+
}
|
|
532
|
+
else
|
|
533
|
+
att.Data = `${data.join("\n")}\n`;
|
|
534
|
+
}
|
|
535
|
+
else
|
|
536
|
+
att[m[1]] = m[2].trimEnd();
|
|
537
|
+
}
|
|
538
|
+
attachment.push(migrateAttachment(att));
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
header.Type ??= TASK_TYPE;
|
|
542
|
+
header.Id = id;
|
|
543
|
+
return { header, body, attachment };
|
|
544
|
+
};
|
|
545
|
+
/* format the header of the JSON task plan structure into the key lines
|
|
546
|
+
of the task plan text (without the enclosing "---" lines): the keys in
|
|
547
|
+
canonical order (unknown keys trailing) with the array values comma-joined */
|
|
548
|
+
export const formatTaskHeader = (header) => {
|
|
549
|
+
const value = (v) => Array.isArray(v) ? v.join(", ") : v;
|
|
550
|
+
return orderKeys(frontKeys, Object.keys(header).filter((key) => header[key] !== undefined))
|
|
551
|
+
.map((key) => `${keyLine(key, value(header[key]))}\n`).join("");
|
|
552
|
+
};
|
|
553
|
+
/* format the JSON task plan structure into the task plan text: the
|
|
554
|
+
header (see formatTaskHeader), the body enclosed in its leading and
|
|
555
|
+
trailing empty line, and every attachment as its own block with
|
|
556
|
+
"Data" rendered as a "|4+" (or, without trailing newline, "|4-")
|
|
557
|
+
literal block scalar, as an empty value if empty, or as the elision
|
|
558
|
+
marker itself if elided, and a block ending with an empty line (of its
|
|
559
|
+
"Data") followed by a separating empty line (see parseTaskText) */
|
|
560
|
+
export const formatTaskText = (plan) => {
|
|
561
|
+
let text = `---\n${formatTaskHeader(plan.header)}---\n\n`;
|
|
562
|
+
if (plan.body !== "")
|
|
563
|
+
text += plan.body.endsWith("\n") ? `${plan.body}\n` : `${plan.body}\n\n`;
|
|
564
|
+
for (const att of plan.attachment) {
|
|
565
|
+
const keys = orderKeys(attachKeys, Object.keys(att).filter((key) => att[key] !== undefined));
|
|
566
|
+
text += "---\n";
|
|
567
|
+
for (const key of keys) {
|
|
568
|
+
if (key === "Data" && (att.Data === "" || isElided(att.Data)))
|
|
569
|
+
text += `${keyLine("Data", att.Data)}\n`;
|
|
570
|
+
else if (key === "Data") {
|
|
571
|
+
const keep = att.Data.endsWith("\n");
|
|
572
|
+
const data = att.Data.split("\n");
|
|
573
|
+
if (keep)
|
|
574
|
+
data.pop();
|
|
575
|
+
text += `${keyLine("Data", keep ? "|4+" : "|4-")}\n`;
|
|
576
|
+
for (const line of data)
|
|
577
|
+
text += line === "" ? "\n" : ` ${line}\n`;
|
|
578
|
+
}
|
|
579
|
+
else
|
|
580
|
+
text += `${keyLine(key, att[key])}\n`;
|
|
581
|
+
}
|
|
582
|
+
if (text.endsWith("\n\n"))
|
|
583
|
+
text += "\n";
|
|
584
|
+
}
|
|
585
|
+
return text;
|
|
586
|
+
};
|
|
587
|
+
/* the marker of an elided "Data" of an attachment, carrying its number of lines */
|
|
588
|
+
const elidedRe = /^\[elided: \d+ lines?\]$/;
|
|
589
|
+
export const isElided = (data) => data !== undefined && elidedRe.test(data);
|
|
590
|
+
/* elide the non-empty "Data" of all attachments of a task plan, i.e., replace it
|
|
591
|
+
by a marker, so the plan can be passed through an LLM without its potentially
|
|
592
|
+
huge attachment payloads, and the marker be restored on saving (see restoreAttachments) */
|
|
593
|
+
export const elideAttachments = (plan) => ({
|
|
594
|
+
...plan,
|
|
595
|
+
attachment: plan.attachment.map((att) => {
|
|
596
|
+
if (att.Data === undefined || att.Data === "" || isElided(att.Data))
|
|
597
|
+
return att;
|
|
598
|
+
const lines = att.Data.replace(/\n$/, "").split("\n").length;
|
|
599
|
+
return { ...att, Data: `[elided: ${lines} line${lines === 1 ? "" : "s"}]` };
|
|
600
|
+
})
|
|
601
|
+
});
|
|
602
|
+
/* restore the elided "Data" of the attachments of a task plan from the stored plan:
|
|
603
|
+
each elided attachment takes the "Data" of the first not yet taken stored attachment
|
|
604
|
+
of the same "Type", preferably also of the same "Created" (so the attachments can be
|
|
605
|
+
reordered or removed meanwhile); throws if no stored attachment matches */
|
|
606
|
+
export const restoreAttachments = (plan, stored) => {
|
|
607
|
+
if (!plan.attachment.some((att) => isElided(att.Data)))
|
|
608
|
+
return plan;
|
|
609
|
+
const taken = new Set();
|
|
610
|
+
const take = (att, created) => (stored?.attachment ?? []).findIndex((s, i) => !taken.has(i) && s.Type === att.Type
|
|
611
|
+
&& s.Data !== undefined && !isElided(s.Data) && (!created || s.Created === att.Created));
|
|
612
|
+
return {
|
|
613
|
+
...plan,
|
|
614
|
+
attachment: plan.attachment.map((att) => {
|
|
615
|
+
if (!isElided(att.Data))
|
|
616
|
+
return att;
|
|
617
|
+
let i = take(att, true);
|
|
618
|
+
if (i < 0)
|
|
619
|
+
i = take(att, false);
|
|
620
|
+
if (i < 0)
|
|
621
|
+
throw new Error(`elided attachment of type "${att.Type ?? ""}" not found in the stored task plan ` +
|
|
622
|
+
"(an elided attachment can only be kept, but not be created or copied)");
|
|
623
|
+
taken.add(i);
|
|
624
|
+
return { ...att, Data: stored.attachment[i].Data };
|
|
625
|
+
})
|
|
626
|
+
};
|
|
627
|
+
};
|
|
628
|
+
/* the type of the usage attachment of a task plan, carrying its AI usage accounting,
|
|
629
|
+
which is owned by the task store: changed by bookings only, but never by any other save */
|
|
630
|
+
export const USAGE_TYPE = "application/yaml; kind=\"usage\"";
|
|
631
|
+
/* whether an attachment is the usage attachment of a task plan */
|
|
632
|
+
export const isUsage = (att) => (att.Type ?? "").trim() === USAGE_TYPE;
|
|
633
|
+
/* a task plan without its usage attachments */
|
|
634
|
+
export const withoutUsage = (plan) => plan.attachment.some(isUsage) ? { ...plan, attachment: plan.attachment.filter((att) => !isUsage(att)) } : plan;
|
|
635
|
+
/* keep the usage attachments of the stored plan in a task plan to be saved: each usage
|
|
636
|
+
attachment of the plan is replaced (in place) by the next stored one or else dropped,
|
|
637
|
+
and the remaining stored ones are appended, so no save can change them */
|
|
638
|
+
export const keepUsage = (plan, stored) => {
|
|
639
|
+
const kept = stored.attachment.filter(isUsage);
|
|
640
|
+
if (kept.length === 0 && !plan.attachment.some(isUsage))
|
|
641
|
+
return plan;
|
|
642
|
+
const attachment = [];
|
|
643
|
+
for (const att of plan.attachment) {
|
|
644
|
+
if (!isUsage(att))
|
|
645
|
+
attachment.push(att);
|
|
646
|
+
else if (kept.length > 0)
|
|
647
|
+
attachment.push(kept.shift());
|
|
648
|
+
}
|
|
649
|
+
return { ...plan, attachment: [...attachment, ...kept] };
|
|
650
|
+
};
|
|
651
|
+
/* the amounts of the AI usage accounting of a task, as "<group>.<field>" of the YAML data of
|
|
652
|
+
its usage attachment: the AI request time, the human time, and the real (wall-clock) time
|
|
653
|
+
(in minutes), the cache read, cache write, (uncached) input, and output tokens, and the AI
|
|
654
|
+
and human costs (in USD) */
|
|
655
|
+
export const usageKeys = [
|
|
656
|
+
"time.ai", "time.hr", "time.real",
|
|
657
|
+
"token.cread", "token.cwrite", "token.input", "token.output",
|
|
658
|
+
"cost.ai", "cost.hr"
|
|
659
|
+
];
|
|
660
|
+
/* parse the YAML data of a usage attachment into its amounts (leniently,
|
|
661
|
+
i.e., with unparsable data and non-numeric amounts taken as absent) */
|
|
662
|
+
export const usageOf = (data) => {
|
|
663
|
+
let doc;
|
|
664
|
+
try {
|
|
665
|
+
doc = YAML.parse(data);
|
|
666
|
+
}
|
|
667
|
+
catch {
|
|
668
|
+
doc = null;
|
|
669
|
+
}
|
|
670
|
+
const groups = (typeof doc === "object" && doc !== null ? doc : {});
|
|
671
|
+
const usage = {};
|
|
672
|
+
for (const key of usageKeys) {
|
|
673
|
+
const [group, field] = key.split(".");
|
|
674
|
+
const amount = Number(groups[group]?.[field]);
|
|
675
|
+
if (Number.isFinite(amount))
|
|
676
|
+
usage[key] = amount;
|
|
677
|
+
}
|
|
678
|
+
return usage;
|
|
679
|
+
};
|
|
680
|
+
/* render the amounts of a usage attachment as its YAML data: one flow mapping per group */
|
|
681
|
+
export const usageData = (usage) => {
|
|
682
|
+
const groups = new Map();
|
|
683
|
+
for (const key of usageKeys) {
|
|
684
|
+
const [group, field] = key.split(".");
|
|
685
|
+
groups.set(group, [...(groups.get(group) ?? []), `${field}: ${String(usage[key] ?? 0)}`]);
|
|
686
|
+
}
|
|
687
|
+
return Array.from(groups, ([group, fields]) => `${(group + ":").padEnd(7)} { ${fields.join(", ")} }\n`).join("");
|
|
688
|
+
};
|
|
689
|
+
/* the T-shirt sizes of the effort estimation of a task plan (its "Effort" key), in ascending order */
|
|
690
|
+
export const effortSizes = ["XS", "S", "M", "L", "XL"];
|
|
691
|
+
/* the default upper band bounds of the sizes "XS" to "L" (in human minutes,
|
|
692
|
+
see "project.task.effort.scale"), on a logarithmic scale of factor 4 */
|
|
693
|
+
export const EFFORT_SCALE = "15,60,240,960";
|
|
694
|
+
/* parse the upper band bounds "<xs>,<s>,<m>,<l>" of the sizes "XS" to "L"
|
|
695
|
+
(strictly ascending positive human minutes), else throw */
|
|
696
|
+
export const parseEffortScale = (spec) => {
|
|
697
|
+
const bounds = spec.split(",").map((bound) => bound.trim());
|
|
698
|
+
if (bounds.length !== 4 || !bounds.every((bound) => /^\d+(?:\.\d+)?$/.test(bound)))
|
|
699
|
+
throw new Error(`invalid effort scale "${spec}" (expected four comma-separated numbers of minutes)`);
|
|
700
|
+
const scale = bounds.map((bound) => Number(bound));
|
|
701
|
+
if (!scale.every((bound, i) => bound > (i > 0 ? scale[i - 1] : 0)))
|
|
702
|
+
throw new Error(`invalid effort scale "${spec}" (expected strictly ascending positive bounds)`);
|
|
703
|
+
return scale;
|
|
704
|
+
};
|
|
705
|
+
/* the representative human minutes of a size, for summing up efforts: the midpoint
|
|
706
|
+
of its band, where the open-ended "XL" counts twice the upper bound of "L" */
|
|
707
|
+
export const effortMinutes = (size, scale) => {
|
|
708
|
+
const i = effortSizes.indexOf(size);
|
|
709
|
+
return i < 4 ? ((i > 0 ? scale[i - 1] : 0) + scale[i]) / 2 : 2 * scale[3];
|
|
710
|
+
};
|
|
711
|
+
/* parse the value "<size> [<hash>]" of an "Effort" key into its size and its
|
|
712
|
+
content hash (empty if not stamped yet), or null if malformed */
|
|
713
|
+
export const parseEffort = (value) => {
|
|
714
|
+
const m = /^(XS|S|M|L|XL)(?:[ \t]+([0-9A-Za-z]+))?$/.exec(value.trim());
|
|
715
|
+
return m === null ? null : { size: m[1], hash: m[2] ?? "" };
|
|
716
|
+
};
|
|
717
|
+
/* the content hash of a task plan body, the "Effort" key is bound to: the body with
|
|
718
|
+
its bullet-points in the inert checkbox states "[-]" and "[>]" left out entirely, the
|
|
719
|
+
checkboxes of all other bullet-points left out, and all whitespace left out, so checkbox
|
|
720
|
+
state changes and re-wrapping keep the hash, while every other content change (including
|
|
721
|
+
cancelling or deferring a bullet-point) changes it */
|
|
722
|
+
export const effortHash = (body) => {
|
|
723
|
+
const content = [];
|
|
724
|
+
let inert = false;
|
|
725
|
+
for (const line of body.split(/\r?\n/)) {
|
|
726
|
+
const m = /^[-*+][ \t]+\[([ ?/xX>-])\]/.exec(line);
|
|
727
|
+
if (m !== null)
|
|
728
|
+
inert = m[1] === "-" || m[1] === ">";
|
|
729
|
+
else if (!/^[ \t]+\S/.test(line))
|
|
730
|
+
inert = false;
|
|
731
|
+
if (!inert)
|
|
732
|
+
content.push(line.replace(/^([ \t]*[-*+][ \t]+)\[[ ?/xX>-]\]/, "$1"));
|
|
733
|
+
}
|
|
734
|
+
return nanoid(`effort:${content.join("").replace(/\s+/g, "")}`);
|
|
735
|
+
};
|
|
736
|
+
/* bind the "Effort" key of a task plan to its body (in place): a key without
|
|
737
|
+
content hash is stamped with the content hash of the body, while a malformed
|
|
738
|
+
or stale key (whose content hash differs from the one of the body) is dropped */
|
|
739
|
+
export const bindEffort = (plan) => {
|
|
740
|
+
const value = plan.header.Effort;
|
|
741
|
+
if (value === undefined)
|
|
742
|
+
return plan;
|
|
743
|
+
const effort = typeof value === "string" ? parseEffort(value) : null;
|
|
744
|
+
const hash = effortHash(plan.body);
|
|
745
|
+
if (effort !== null && effort.hash === "")
|
|
746
|
+
plan.header.Effort = `${effort.size} ${hash}`;
|
|
747
|
+
else if (effort === null || effort.hash !== hash)
|
|
748
|
+
Reflect.deleteProperty(plan.header, "Effort");
|
|
749
|
+
return plan;
|
|
750
|
+
};
|
|
751
|
+
/* derive the title of a task plan from its body as defined by the
|
|
752
|
+
"Task titles" convention: the text following "TASK:" on the first
|
|
753
|
+
"# TASK: <title>" heading line, or the empty string if absent */
|
|
754
|
+
export const taskTitle = (body) => {
|
|
755
|
+
const m = /^#[ \t]+TASK:[ \t]*(.*)$/m.exec(body);
|
|
756
|
+
return m === null ? "" : m[1].trim();
|
|
757
|
+
};
|