nikos 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +45 -9
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +57 -1
  4. package/dist/commands/approve.d.ts +16 -0
  5. package/dist/commands/approve.js +120 -0
  6. package/dist/commands/ci.d.ts +18 -0
  7. package/dist/commands/ci.js +123 -0
  8. package/dist/commands/clear.d.ts +14 -0
  9. package/dist/commands/decide.d.ts +11 -0
  10. package/dist/commands/decide.js +3 -3
  11. package/dist/commands/decisions.d.ts +14 -0
  12. package/dist/commands/decisions.js +2 -2
  13. package/dist/commands/doctor.d.ts +11 -0
  14. package/dist/commands/doctor.js +92 -0
  15. package/dist/commands/export.d.ts +48 -0
  16. package/dist/commands/export.js +164 -0
  17. package/dist/commands/init.d.ts +16 -0
  18. package/dist/commands/init.js +16 -13
  19. package/dist/commands/join.d.ts +17 -0
  20. package/dist/commands/join.js +91 -0
  21. package/dist/commands/map.d.ts +65 -0
  22. package/dist/commands/map.js +202 -25
  23. package/dist/commands/migrate.d.ts +10 -0
  24. package/dist/commands/migrate.js +105 -0
  25. package/dist/commands/next.d.ts +19 -0
  26. package/dist/commands/next.js +29 -5
  27. package/dist/commands/plan.d.ts +8 -0
  28. package/dist/commands/plan.js +9 -8
  29. package/dist/commands/release.d.ts +66 -0
  30. package/dist/commands/release.js +119 -17
  31. package/dist/commands/show.d.ts +3 -0
  32. package/dist/commands/show.js +26 -4
  33. package/dist/commands/start.d.ts +28 -0
  34. package/dist/commands/start.js +138 -0
  35. package/dist/commands/state.d.ts +27 -0
  36. package/dist/commands/state.js +40 -22
  37. package/dist/commands/step.d.ts +17 -0
  38. package/dist/commands/step.js +38 -2
  39. package/dist/commands/story.d.ts +21 -0
  40. package/dist/commands/story.js +7 -10
  41. package/dist/commands/suggest.d.ts +16 -0
  42. package/dist/commands/suggest.js +140 -0
  43. package/dist/commands/tracker.d.ts +60 -0
  44. package/dist/commands/tracker.js +185 -0
  45. package/dist/commands/validate.d.ts +15 -0
  46. package/dist/commands/validate.js +149 -14
  47. package/dist/commands/verify.d.ts +20 -0
  48. package/dist/commands/video.d.ts +33 -0
  49. package/dist/commands/video.js +4 -3
  50. package/dist/index.d.ts +20 -0
  51. package/dist/index.js +24 -0
  52. package/dist/lib/approvals.d.ts +29 -0
  53. package/dist/lib/approvals.js +28 -0
  54. package/dist/lib/bets.d.ts +21 -0
  55. package/dist/lib/bets.js +9 -2
  56. package/dist/lib/frontmatter.d.ts +5 -0
  57. package/dist/lib/project.d.ts +25 -0
  58. package/dist/lib/project.js +25 -7
  59. package/dist/lib/settings.d.ts +29 -0
  60. package/dist/lib/settings.js +110 -0
  61. package/dist/lib/state-files.d.ts +44 -0
  62. package/dist/lib/state-files.js +102 -0
  63. package/dist/lib/store.d.ts +44 -0
  64. package/dist/lib/store.js +97 -0
  65. package/dist/lint/constitution.d.ts +5 -0
  66. package/dist/lint/gherkin.d.ts +12 -0
  67. package/dist/lint/gherkin.js +2 -2
  68. package/dist/lint/portable.d.ts +10 -0
  69. package/dist/mcp/server.d.ts +4 -0
  70. package/dist/mcp/server.js +239 -0
  71. package/dist/planning/bets.d.ts +31 -0
  72. package/dist/planning/cli.d.ts +8 -0
  73. package/dist/planning/cli.js +17 -1
  74. package/dist/planning/delivery.d.ts +12 -0
  75. package/dist/planning/delivery.js +8 -5
  76. package/dist/planning/krs.d.ts +28 -0
  77. package/dist/planning/model.d.ts +55 -0
  78. package/dist/planning/model.js +10 -10
  79. package/dist/planning/rollout.d.ts +72 -0
  80. package/dist/planning/rollout.js +206 -0
  81. package/dist/planning/server.d.ts +113 -0
  82. package/dist/planning/server.js +9 -6
  83. package/dist/planning/ui.d.ts +7 -0
  84. package/dist/planning/ui.js +26 -1
  85. package/dist/planning/upstream.d.ts +73 -0
  86. package/dist/planning/upstream.js +78 -20
  87. package/dist/verify/detect.d.ts +3 -0
  88. package/docs/format.md +112 -2
  89. package/docs/gherkin.md +3 -0
  90. package/docs/install.md +39 -0
  91. package/docs/library.md +53 -0
  92. package/docs/planning.md +51 -5
  93. package/docs/settings.md +112 -0
  94. package/package.json +12 -2
  95. package/schemas/approvals.schema.json +27 -0
  96. package/schemas/planning/kr.schema.json +1 -1
  97. package/schemas/settings.schema.json +40 -0
  98. package/schemas/state.schema.json +6 -2
  99. package/schemas/storymap.schema.json +13 -0
  100. package/skills/nikos-mode/SKILL.md +22 -1
  101. package/skills/nikos-mode/playbooks/build-a-story.md +1 -1
  102. package/skills/nikos-mode/playbooks/company.md +16 -0
  103. package/skills/nikos-mode/playbooks/index.md +4 -0
  104. package/skills/nikos-mode/playbooks/map-existing.md +14 -0
  105. package/skills/nikos-mode/playbooks/start.md +12 -0
  106. package/skills/nikos-mode/playbooks/tracker.md +14 -0
  107. package/skills/nikos-mode/trackers/github.md +18 -0
  108. package/skills/nikos-mode/trackers/gitlab.md +17 -0
  109. package/skills/nikos-mode/trackers/jira.md +19 -0
  110. package/skills/nikos-mode/trackers/linear.md +17 -0
  111. package/skills/verify/SKILL.md +9 -0
@@ -1,4 +1,4 @@
1
- import { writeFileSync } from "node:fs";
1
+ import { store } from "../lib/store.js";
2
2
  import { join } from "node:path";
3
3
  import { isMap, isSeq, parseDocument } from "yaml";
4
4
  import { findIntentDir, read } from "../lib/project.js";
@@ -33,9 +33,45 @@ export function newStep(input, dir) {
33
33
  steps.add(map.createNode({ id, title }));
34
34
  else
35
35
  activity.set("steps", map.createNode([{ id, title }]));
36
- writeFileSync(mapPath, map.toString(yamlOpts));
36
+ store().write(mapPath, map.toString(yamlOpts));
37
37
  return { id, validation: validate(intentDir) };
38
38
  }
39
+ /** Add an activity to the story map, e.g. an area of an existing product or an imported epic. Returns its id; an existing one is left as it is. */
40
+ export function newActivity(input, dir) {
41
+ const title = input.title?.trim();
42
+ if (!title)
43
+ throw new Error("An activity needs a title");
44
+ const intentDir = findIntentDir(dir);
45
+ const mapPath = join(intentDir, "storymap.yaml");
46
+ const map = parseDocument(read(mapPath));
47
+ const id = input.id ?? slugify(title);
48
+ const activities = map.get("activities", true);
49
+ if (!isSeq(activities))
50
+ throw new Error("storymap.yaml has no activities list");
51
+ if (activities.items.some((a) => isMap(a) && a.get("id") === id))
52
+ return id;
53
+ activities.add(map.createNode({ id, title }));
54
+ store().write(mapPath, map.toString(yamlOpts));
55
+ return id;
56
+ }
57
+ export function runActivity(args) {
58
+ const [sub, title, ...rest] = args;
59
+ const i = rest.indexOf("--id");
60
+ const d = rest.indexOf("--dir");
61
+ if (sub !== "add" || !title || title.startsWith("--")) {
62
+ console.error('Usage: nikos activity add "<title>" [--id <activity-id>]');
63
+ return 1;
64
+ }
65
+ try {
66
+ const id = newActivity({ title, id: i >= 0 ? rest[i + 1] : undefined }, d >= 0 ? rest[d + 1] : process.cwd());
67
+ console.log(`Activity ${id}: its stories go in intent/features/${id}/`);
68
+ return 0;
69
+ }
70
+ catch (err) {
71
+ console.error(`✗ ${err.message}`);
72
+ return 1;
73
+ }
74
+ }
39
75
  export function runStep(args) {
40
76
  const [sub, title, ...rest] = args;
41
77
  const opt = (n) => {
@@ -0,0 +1,21 @@
1
+ import { type ValidationResult } from "./validate.js";
2
+ declare const PRIORITIES: readonly ["must", "should", "could"];
3
+ export interface StoryInput {
4
+ title: string;
5
+ activity: string;
6
+ /** A step of that activity. */
7
+ step?: string;
8
+ /** Release number; defaults to the last release. */
9
+ release?: number;
10
+ priority?: (typeof PRIORITIES)[number];
11
+ }
12
+ export interface StoryResult {
13
+ id: string;
14
+ release: number;
15
+ feature: string;
16
+ validation: ValidationResult;
17
+ }
18
+ /** Add a story to storymap.yaml and state.yaml, and write its starter feature file. */
19
+ export declare function newStory(input: StoryInput, dir: string): StoryResult;
20
+ export declare function runStory(args: string[]): number;
21
+ export {};
@@ -1,7 +1,8 @@
1
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
1
+ import { join } from "node:path";
3
2
  import { parseDocument, YAMLSeq } from "yaml";
4
3
  import { findIntentDir, read } from "../lib/project.js";
4
+ import { store } from "../lib/store.js";
5
+ import { createStoryState } from "../lib/state-files.js";
5
6
  import { slugify } from "./init.js";
6
7
  import { validate } from "./validate.js";
7
8
  const PRIORITIES = ["must", "should", "could"];
@@ -16,10 +17,8 @@ export function newStory(input, dir) {
16
17
  throw new Error(`'${priority}' is not a priority (must, should, could)`);
17
18
  const intentDir = findIntentDir(dir);
18
19
  const mapPath = join(intentDir, "storymap.yaml");
19
- const statePath = join(intentDir, "state.yaml");
20
20
  // Documents keep comments and the order of everything we don't touch.
21
21
  const map = parseDocument(read(mapPath));
22
- const state = parseDocument(read(statePath));
23
22
  const plain = map.toJS();
24
23
  const activities = (plain.activities ?? []).map((a) => a?.id);
25
24
  if (!activities.includes(input.activity)) {
@@ -42,14 +41,13 @@ export function newStory(input, dir) {
42
41
  const taken = releases.flatMap((r) => r?.stories ?? []).map((s) => Number(s.slice(key.length + 1)) || 0);
43
42
  const id = `${key}-${Math.max(0, ...taken) + 1}`;
44
43
  const feature = join(intentDir, "features", input.activity, `${slugify(title)}.feature`);
45
- if (existsSync(feature))
44
+ if (store().exists(feature))
46
45
  throw new Error(`${feature} already exists; give the story a different title`);
47
46
  const stories = map.getIn(["releases", index, "stories"], true);
48
47
  if (stories instanceof YAMLSeq)
49
48
  stories.add(id);
50
49
  else
51
50
  map.setIn(["releases", index, "stories"], map.createNode([id], { flow: true }));
52
- state.setIn(["stories", id], state.createNode({ status: "todo" }));
53
51
  const text = `@${id} @release-${release} @${priority} @activity-${input.activity}${input.step ? ` @step-${input.step}` : ""}
54
52
  Feature: ${title}
55
53
 
@@ -63,10 +61,9 @@ Feature: ${title}
63
61
  When <the one action>
64
62
  Then <what they see>
65
63
  `;
66
- mkdirSync(dirname(feature), { recursive: true });
67
- writeFileSync(feature, text, { flag: "wx" });
68
- writeFileSync(mapPath, map.toString(yamlOpts));
69
- writeFileSync(statePath, state.toString(yamlOpts));
64
+ store().write(feature, text, { create: true });
65
+ store().write(mapPath, map.toString(yamlOpts));
66
+ createStoryState(intentDir, id, { status: "todo" });
70
67
  return { id, release, feature, validation: validate(intentDir) };
71
68
  }
72
69
  export function runStory(args) {
@@ -0,0 +1,16 @@
1
+ export interface Option {
2
+ /** What the person sees, e.g. "Build HT-3: Reorder tasks". */
3
+ label: string;
4
+ /** What picking it means, for the agent: a playbook to run or a question to ask. */
5
+ do: string;
6
+ /** Stories it's about, when there are any. */
7
+ stories?: string[];
8
+ }
9
+ export interface Suggestion {
10
+ /** One line on where things stand, for the top of a reply or a session start. */
11
+ summary: string;
12
+ /** Two to four options, the most useful first. */
13
+ options: Option[];
14
+ }
15
+ export declare function suggest(dir: string): Suggestion;
16
+ export declare function runSuggest(args: string[]): number;
@@ -0,0 +1,140 @@
1
+ // `nikos suggest`: what the person could do next, as a short menu. nikOS works the options out
2
+ // from the repo so every agent offers the same ones for the same situation; the agent asks the
3
+ // question in its own way (a multiple-choice prompt where it has one) and runs what's picked.
4
+ // Labels are plain words for the person. `do` tells the agent what a pick means; it's never
5
+ // shown as a command for the person to run.
6
+ import { store } from "../lib/store.js";
7
+ import { resolveSettings } from "../lib/settings.js";
8
+ import { dirname, join, resolve } from "node:path";
9
+ import { findIntentDir, isDone } from "../lib/project.js";
10
+ import { storyMapOf } from "./map.js";
11
+ import { next } from "./next.js";
12
+ import { validate } from "./validate.js";
13
+ import { isStale } from "../planning/krs.js";
14
+ import { betIds, krIds, loadBet, loadKr, today } from "../planning/model.js";
15
+ const MAX = 4;
16
+ const named = (s) => `${s.id}: ${s.title}`;
17
+ const list = (ids) => (ids.length <= 3 ? ids.join(", ") : `${ids.slice(0, 2).join(", ")} and ${ids.length - 2} more`);
18
+ export function suggest(dir) {
19
+ const intentDir = findIntentDir(dir);
20
+ if (!store().exists(join(intentDir, "storymap.yaml"))) {
21
+ return {
22
+ summary: "nikOS isn't set up in this repo yet.",
23
+ options: [
24
+ { label: "Set up nikOS here", do: "Run the start playbook: nikos start, then the first story through to passing." },
25
+ { label: "Map what this repo already does", do: "Run the map-existing playbook: draft the story map from the existing code, then prove what's there with scenarios." },
26
+ { label: "Join our company plan", do: "Ask which repo holds the company or department plan, then nikos join <repo path> (add --host for GitLab). It links this repo and shows the settings it inherits." },
27
+ ],
28
+ };
29
+ }
30
+ const map = storyMapOf(dir);
31
+ const stories = [...map.releases.flatMap((r) => r.stories), ...map.unreleased];
32
+ const byId = new Map(stories.map((s) => [s.id, s]));
33
+ const options = [];
34
+ const add = (o) => options.length < MAX && options.push(o);
35
+ const problems = validate(dir).errors.length;
36
+ if (problems)
37
+ add({ label: `Fix the ${problems} ${problems === 1 ? "problem" : "problems"} in intent/`, do: "Run nikos validate and fix each error it lists." });
38
+ const changed = stories.filter((s) => s.specChanged);
39
+ if (changed.length)
40
+ add({ label: `Review what changed in ${list(changed.map((s) => s.id))} since it was done`, do: "Show the feature's change since done, and ask whether to move it back to needs_rework.", stories: changed.map((s) => s.id) });
41
+ const n = next(intentDir);
42
+ for (const o of n.open) {
43
+ const s = byId.get(o.id);
44
+ add({ label: `${o.status === "needs_rework" ? "Rework" : "Finish"} ${s ? named(s) : o.id}`, do: "Run the build-a-story playbook for this story.", stories: [o.id] });
45
+ }
46
+ // Done stories nobody has signed off (the map shows a story with an approval that holds as
47
+ // approved): the agent asks "Do you want to approve …?"
48
+ const approvalsOn = resolveSettings(dirname(intentDir))["approval.policy"].value !== "off";
49
+ const unapproved = approvalsOn ? stories.filter((s) => s.status === "done" && !s.specChanged) : [];
50
+ if (unapproved.length) {
51
+ add({
52
+ label: unapproved.length === 1 ? `Approve ${named(unapproved[0])}` : `Approve ${unapproved.length} done stories (${list(unapproved.map((s) => s.id))})`,
53
+ do: "Ask the person, for each story, \"Do you want to approve <id>: <title>?\" with Approve, Not yet and Show me what was checked. Only on their Approve, run nikos approve <id> --via chat.",
54
+ stories: unapproved.map((s) => s.id),
55
+ });
56
+ }
57
+ if (n.story) {
58
+ const s = byId.get(n.story);
59
+ add({ label: `Build ${s ? named(s) : n.story}`, do: "Run the build-a-story playbook for this story.", stories: [n.story] });
60
+ }
61
+ else if (n.waiting.length) {
62
+ const blocker = n.waiting[0].on[0];
63
+ add({ label: `Unblock ${blocker} (${n.waiting[0].id} waits on it)`, do: `Run the build-a-story playbook for ${blocker}, or ask whether to change the order.`, stories: [blocker] });
64
+ }
65
+ // The latest release whose stories are all done: time to check it's ready to ship.
66
+ const complete = (r) => {
67
+ const live = r.stories.filter((s) => s.status !== "cancelled");
68
+ return live.length > 0 && live.every((s) => isDone(s.status) && !s.specChanged);
69
+ };
70
+ const ready = map.releases.filter(complete).at(-1);
71
+ if (ready)
72
+ add({ label: `Check release ${ready.number} is ready to ship`, do: `Run nikos release check ${ready.number} --run and report what it finds.` });
73
+ // The planning rituals, where the repo plans with nikOS: bets due for review, then stale key results.
74
+ const root = dirname(intentDir);
75
+ if (store().isDir(join(root, "planning"))) {
76
+ const due = betIds(root).flatMap((id) => {
77
+ try {
78
+ const m = loadBet(root, id).meta;
79
+ return m.outcome === "open" && m.review_on && String(m.review_on) <= today() ? [{ id, on: String(m.review_on) }] : [];
80
+ }
81
+ catch {
82
+ return [];
83
+ }
84
+ });
85
+ if (due.length)
86
+ add({ label: due.length === 1 ? `Review the ${due[0].id} bet (due ${due[0].on})` : `Review ${due.length} bets that are due`, do: "Run the nikos-planning review playbook: continue, change, stop or complete each bet, with the reason." });
87
+ const stale = krIds(root).flatMap((id) => {
88
+ try {
89
+ const k = loadKr(root, id).data;
90
+ return isStale(k) ? [String(k.title ?? id)] : [];
91
+ }
92
+ catch {
93
+ return [];
94
+ }
95
+ });
96
+ if (stale.length)
97
+ add({ label: stale.length === 1 ? `Check in on "${stale[0]}"` : `Check in on ${stale.length} key results`, do: "Run the nikos-planning checkin playbook: ask for each key result's latest value and record it." });
98
+ }
99
+ add({ label: "Add a story", do: "Ask what the person wants the product to do next, then add it with nikos story add and write its scenarios." });
100
+ add({ label: "Look at the story map", do: "Run nikos map, or open the planning dashboard's map if it's running, and show the person." });
101
+ // The release being worked on: the first that isn't complete, or the last.
102
+ const current = map.releases.find((r) => !complete(r)) ?? map.releases.at(-1);
103
+ const live = current ? current.stories.filter((s) => s.status !== "cancelled") : [];
104
+ const doing = stories.filter((s) => s.status === "in_progress").map((s) => s.id);
105
+ const summary = current
106
+ ? `Release ${current.number} · ${current.title}: ${live.filter((s) => isDone(s.status)).length} of ${live.length} done${doing.length ? ` · ${list(doing)} in progress` : ""}${unapproved.length ? ` · ${unapproved.length} waiting for approval` : ""}${problems ? ` · ${problems} ${problems === 1 ? "problem" : "problems"} in intent/` : ""}`
107
+ : "The story map has no releases yet.";
108
+ return { summary, options };
109
+ }
110
+ export function runSuggest(args) {
111
+ const i = args.indexOf("--dir");
112
+ const dir = resolve(i >= 0 ? args[i + 1] : process.cwd());
113
+ // --brief is for a session-start hook: one line, and nothing at all outside a nikOS repo.
114
+ if (args.includes("--brief")) {
115
+ if (!store().exists(join(findIntentDir(dir), "storymap.yaml")))
116
+ return 0;
117
+ try {
118
+ const s = suggest(dir);
119
+ console.log(`nikOS: ${s.summary}. Suggested next: ${s.options.map((o) => o.label).join(" · ")}.`);
120
+ }
121
+ catch {
122
+ // A hook must never break the session.
123
+ }
124
+ return 0;
125
+ }
126
+ try {
127
+ const s = suggest(dir);
128
+ if (args.includes("--json")) {
129
+ console.log(JSON.stringify(s, null, 2));
130
+ return 0;
131
+ }
132
+ console.log(s.summary);
133
+ s.options.forEach((o, n) => console.log(` ${n + 1}. ${o.label}`));
134
+ return 0;
135
+ }
136
+ catch (err) {
137
+ console.error(`✗ ${err.message}`);
138
+ return 1;
139
+ }
140
+ }
@@ -0,0 +1,60 @@
1
+ export type NikosStatus = "todo" | "in_progress" | "needs_rework" | "done" | "approved" | "cancelled";
2
+ /** Link a story to its ticket (e.g. PAY-123, ENG-42, #57), in state.yaml. */
3
+ export declare function linkTicket(input: {
4
+ id: string;
5
+ ticket: string;
6
+ }, dir: string): {
7
+ id: string;
8
+ ticket: string;
9
+ };
10
+ export interface RecordResult {
11
+ id: string;
12
+ ticket?: string;
13
+ /** What the tracker's status means to nikOS. */
14
+ means: NikosStatus;
15
+ /** What nikOS did about it. */
16
+ outcome: "approved" | "cancelled" | "in step" | "differs" | "ignored";
17
+ message: string;
18
+ }
19
+ /**
20
+ * Record what a story's ticket says, as the agent read it from the tracker. Approved (when
21
+ * tracker.approvals is on) approves the story; cancelled cancels it; anything else is only
22
+ * compared, because nikOS's own status comes from the story's scenarios.
23
+ */
24
+ export declare function recordTicket(input: {
25
+ id: string;
26
+ status: string;
27
+ by?: string;
28
+ on?: string;
29
+ }, dir: string): RecordResult;
30
+ export interface TicketMove {
31
+ id: string;
32
+ ticket: string;
33
+ nikos: NikosStatus;
34
+ /** The tracker status the ticket should have: the first one mapped to the story's status. */
35
+ status: string;
36
+ }
37
+ /** For each linked story, the tracker status its ticket should be in. The agent moves the tickets. */
38
+ export declare function ticketMoves(dir: string): TicketMove[];
39
+ export interface ImportedTicket {
40
+ /** The ticket's key or number, e.g. PAY-123. */
41
+ key: string;
42
+ title: string;
43
+ /** The epic, project or label it belongs to: it becomes the story's activity. */
44
+ group?: string;
45
+ }
46
+ /**
47
+ * Bring a tracker's backlog into the story map: each ticket becomes a story (in the last
48
+ * release, or release), linked to its ticket, with its group as the activity. Tickets already
49
+ * linked are skipped. Stories start as todo: their scenarios still have to be written.
50
+ */
51
+ export declare function importTickets(tickets: ImportedTicket[], dir: string, opts?: {
52
+ release?: number;
53
+ }): {
54
+ added: {
55
+ id: string;
56
+ ticket: string;
57
+ }[];
58
+ skipped: string[];
59
+ };
60
+ export declare function runTracker(args: string[]): number;
@@ -0,0 +1,185 @@
1
+ // `nikos tracker`: keep a story map and a team's tracker (Jira, Linear, GitHub or GitLab issues…)
2
+ // in step. nikOS never calls a tracker itself: the agent reads and writes tickets through the
3
+ // tracker's own MCP, and hands what it read to these commands, which apply the rules:
4
+ // - a ticket can approve a story (when tracker.approvals is on) or cancel it, never mark it
5
+ // done: done still means the story's scenarios pass;
6
+ // - for everything else, nikOS says which tickets should move, and the agent moves them.
7
+ import { dirname } from "node:path";
8
+ import { findIntentDir, STORY_ID } from "../lib/project.js";
9
+ import { resolveSettings } from "../lib/settings.js";
10
+ import { store } from "../lib/store.js";
11
+ import { noEntry, storyState, updateStory } from "../lib/state-files.js";
12
+ import { approve } from "./approve.js";
13
+ import { storyMapOf } from "./map.js";
14
+ import { setState, StateError } from "./state.js";
15
+ import { newStory } from "./story.js";
16
+ import { newActivity } from "./step.js";
17
+ /** The tracker settings that apply here. */
18
+ function trackerOf(root) {
19
+ const s = resolveSettings(root);
20
+ return {
21
+ system: s["tracker.system"].value,
22
+ statuses: s["tracker.statuses"].value,
23
+ approvals: s["tracker.approvals"].value,
24
+ };
25
+ }
26
+ const rootOf = (dir) => dirname(findIntentDir(dir));
27
+ /** Link a story to its ticket (e.g. PAY-123, ENG-42, #57), in state.yaml. */
28
+ export function linkTicket(input, dir) {
29
+ if (!STORY_ID.test(input.id))
30
+ throw new Error(`'${input.id}' is not a story id (expected e.g. HT-1)`);
31
+ const ticket = input.ticket.trim();
32
+ if (!ticket)
33
+ throw new Error("Give the ticket's key or number, e.g. PAY-123");
34
+ const intent = findIntentDir(dir);
35
+ if (!storyState(intent, input.id))
36
+ throw new Error(noEntry(intent, input.id));
37
+ updateStory(intent, input.id, (doc, at) => doc.setIn([...at, "ticket"], ticket));
38
+ return { id: input.id, ticket };
39
+ }
40
+ /**
41
+ * Record what a story's ticket says, as the agent read it from the tracker. Approved (when
42
+ * tracker.approvals is on) approves the story; cancelled cancels it; anything else is only
43
+ * compared, because nikOS's own status comes from the story's scenarios.
44
+ */
45
+ export function recordTicket(input, dir) {
46
+ const root = rootOf(dir);
47
+ const t = trackerOf(root);
48
+ if (t.system === "none")
49
+ throw new Error("No tracker is set up here: set tracker.system first (nikos settings set tracker.system jira)");
50
+ const means = t.statuses[input.status];
51
+ if (!means) {
52
+ throw new Error(`'${input.status}' isn't mapped to a nikOS status. Mapped: ${Object.keys(t.statuses).join(", ") || "none"}. Map it first: nikos settings set tracker.statuses '{"${input.status}": "done"}'`);
53
+ }
54
+ const story = storyMapOf(root).releases.flatMap((r) => r.stories).find((s) => s.id === input.id);
55
+ if (!story)
56
+ throw new Error(`${input.id} is not in any release in storymap.yaml`);
57
+ const ticket = storyTicket(root, input.id);
58
+ const base = { id: input.id, ...(ticket ? { ticket } : {}), means };
59
+ if (means === "approved") {
60
+ if (!t.approvals)
61
+ return { ...base, outcome: "ignored", message: `${t.system} says approved, but tracker approvals don't count here (tracker.approvals is off)` };
62
+ if (story.status === "approved")
63
+ return { ...base, outcome: "in step", message: `${input.id} is already approved` };
64
+ if (!input.by)
65
+ throw new Error(`Say who moved the ticket to '${input.status}' (--by), as the tracker's history shows it`);
66
+ approve(input.id, { dir, by: input.by, date: input.on, via: "tracker" });
67
+ return { ...base, outcome: "approved", message: `${input.id} approved by ${input.by} in ${t.system}${ticket ? ` (${ticket})` : ""}` };
68
+ }
69
+ if (means === "cancelled") {
70
+ if (story.status === "cancelled")
71
+ return { ...base, outcome: "in step", message: `${input.id} is already cancelled` };
72
+ setState(input.id, "cancelled", { dir, reason: `Cancelled in ${t.system}${ticket ? ` (${ticket})` : ""}${input.by ? ` by ${input.by}` : ""}`, date: input.on });
73
+ return { ...base, outcome: "cancelled", message: `${input.id} cancelled, as ${t.system} says` };
74
+ }
75
+ const nikos = story.status === "approved" ? "done" : story.status;
76
+ if (nikos === means)
77
+ return { ...base, outcome: "in step", message: `${input.id} is ${means} in both` };
78
+ return {
79
+ ...base,
80
+ outcome: "differs",
81
+ message: `${t.system} says ${means} but nikOS says ${nikos}. nikOS's status comes from the story's scenarios, so move the ticket to match (nikos tracker sync lists the status to move it to)`,
82
+ };
83
+ }
84
+ function storyTicket(root, id) {
85
+ const v = storyState(findIntentDir(root), id)?.ticket;
86
+ return typeof v === "string" ? v : undefined;
87
+ }
88
+ /** For each linked story, the tracker status its ticket should be in. The agent moves the tickets. */
89
+ export function ticketMoves(dir) {
90
+ const root = rootOf(dir);
91
+ const t = trackerOf(root);
92
+ const stories = storyMapOf(root).releases.flatMap((r) => r.stories);
93
+ const statusFor = (s) => Object.entries(t.statuses).find(([, v]) => v === s)?.[0] ?? (s === "approved" ? Object.entries(t.statuses).find(([, v]) => v === "done")?.[0] : undefined);
94
+ const out = [];
95
+ for (const s of stories) {
96
+ const ticket = storyTicket(root, s.id);
97
+ const status = statusFor(s.status);
98
+ if (ticket && status)
99
+ out.push({ id: s.id, ticket, nikos: s.status, status });
100
+ }
101
+ return out;
102
+ }
103
+ /**
104
+ * Bring a tracker's backlog into the story map: each ticket becomes a story (in the last
105
+ * release, or release), linked to its ticket, with its group as the activity. Tickets already
106
+ * linked are skipped. Stories start as todo: their scenarios still have to be written.
107
+ */
108
+ export function importTickets(tickets, dir, opts = {}) {
109
+ const root = rootOf(dir);
110
+ const linked = new Set(storyMapOf(root).releases.flatMap((r) => r.stories).map((s) => storyTicket(root, s.id)).filter(Boolean));
111
+ const added = [];
112
+ const skipped = [];
113
+ for (const t of tickets) {
114
+ if (!t?.key || !t?.title)
115
+ throw new Error("Each ticket needs a key and a title");
116
+ if (linked.has(t.key)) {
117
+ skipped.push(t.key);
118
+ continue;
119
+ }
120
+ const activity = newActivity({ title: t.group?.trim() || "Backlog" }, dir);
121
+ let story;
122
+ try {
123
+ story = newStory({ title: t.title, activity, release: opts.release }, dir);
124
+ }
125
+ catch (err) {
126
+ // Two tickets with the same title in one group: tell them apart by key.
127
+ if (!/already exists/.test(err.message))
128
+ throw err;
129
+ story = newStory({ title: `${t.title} (${t.key})`, activity, release: opts.release }, dir);
130
+ }
131
+ linkTicket({ id: story.id, ticket: t.key }, dir);
132
+ linked.add(t.key);
133
+ added.push({ id: story.id, ticket: t.key });
134
+ }
135
+ return { added, skipped };
136
+ }
137
+ const USAGE = `Usage:
138
+ nikos tracker link <story-id> <ticket>
139
+ nikos tracker record <story-id> --status "<tracker status>" [--by "<who moved it>"] [--on <date>]
140
+ nikos tracker sync [--json] which tickets should move, and to what
141
+ nikos tracker import <tickets.json> [--release <n>] [{ "key", "title", "group" }]`;
142
+ export function runTracker(args) {
143
+ const [sub, ...rest] = args;
144
+ const flag = (n) => {
145
+ const i = rest.indexOf(`--${n}`);
146
+ return i >= 0 ? rest[i + 1] : undefined;
147
+ };
148
+ const positional = rest.filter((a, i) => !a.startsWith("--") && !(i > 0 && rest[i - 1].startsWith("--")));
149
+ const dir = flag("dir") ?? process.cwd();
150
+ try {
151
+ if (sub === "link" && positional[1]) {
152
+ const r = linkTicket({ id: positional[0], ticket: positional[1] }, dir);
153
+ console.log(`${r.id} ↔ ${r.ticket}`);
154
+ }
155
+ else if (sub === "record" && positional[0] && flag("status")) {
156
+ const r = recordTicket({ id: positional[0], status: flag("status"), by: flag("by"), on: flag("on") }, dir);
157
+ console.log(`${r.outcome === "differs" ? "!" : "✓"} ${r.message}`);
158
+ }
159
+ else if (sub === "sync") {
160
+ const moves = ticketMoves(dir);
161
+ if (rest.includes("--json"))
162
+ console.log(JSON.stringify(moves, null, 2));
163
+ else
164
+ for (const m of moves)
165
+ console.log(`${m.ticket} → ${m.status} (${m.id} is ${m.nikos})`);
166
+ }
167
+ else if (sub === "import" && positional[0]) {
168
+ const tickets = JSON.parse(store().read(positional[0]));
169
+ const r = importTickets(tickets, dir, { release: flag("release") ? Number(flag("release")) : undefined });
170
+ for (const a of r.added)
171
+ console.log(`+ ${a.id} ↔ ${a.ticket}`);
172
+ if (r.skipped.length)
173
+ console.log(`Already linked: ${r.skipped.join(", ")}`);
174
+ }
175
+ else {
176
+ console.error(USAGE);
177
+ return 1;
178
+ }
179
+ return 0;
180
+ }
181
+ catch (err) {
182
+ console.error(`✗ ${err instanceof StateError ? err.message : err.message}`);
183
+ return 1;
184
+ }
185
+ }
@@ -0,0 +1,15 @@
1
+ export interface Issue {
2
+ file: string;
3
+ message: string;
4
+ hint?: string;
5
+ }
6
+ export interface ValidationResult {
7
+ dir: string;
8
+ errors: Issue[];
9
+ warnings: Issue[];
10
+ stories: number;
11
+ releases: number;
12
+ }
13
+ export declare function validate(start: string): ValidationResult;
14
+ export declare function formatResult(r: ValidationResult): string;
15
+ export declare function runValidate(args: string[]): number;