okstra 0.50.0 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.kr.md +8 -7
- package/README.md +8 -7
- package/bin/okstra +2 -0
- package/docs/kr/architecture.md +15 -16
- package/docs/kr/cli.md +5 -5
- package/docs/project-structure-overview.md +10 -6
- package/docs/superpowers/specs/2026-06-06-vertical-slice-tdd-planning-design.md +179 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +15 -11
- package/runtime/agents/workers/claude-worker.md +3 -3
- package/runtime/agents/workers/codex-worker.md +2 -2
- package/runtime/agents/workers/gemini-worker.md +2 -2
- package/runtime/bin/lib/okstra/cli.sh +8 -1
- package/runtime/bin/lib/okstra/globals.sh +3 -0
- package/runtime/bin/lib/okstra/interactive.sh +14 -12
- package/runtime/bin/lib/okstra/usage.sh +6 -0
- package/runtime/bin/okstra-team-reconcile.sh +28 -0
- package/runtime/bin/okstra.sh +2 -0
- package/runtime/prompts/launch.template.md +3 -1
- package/runtime/prompts/profiles/_common-contract.md +4 -4
- package/runtime/prompts/profiles/_implementation-executor.md +2 -2
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +8 -4
- package/runtime/prompts/profiles/implementation.md +1 -1
- package/runtime/python/okstra_ctl/analysis_packet.py +259 -0
- package/runtime/python/okstra_ctl/context_cost.py +308 -0
- package/runtime/python/okstra_ctl/migrate.py +2 -12
- package/runtime/python/okstra_ctl/paths.py +22 -0
- package/runtime/python/okstra_ctl/render.py +284 -125
- package/runtime/python/okstra_ctl/render_final_report.py +31 -0
- package/runtime/python/okstra_ctl/run.py +507 -245
- package/runtime/python/okstra_ctl/sequence.py +2 -5
- package/runtime/python/okstra_ctl/team_reconcile.py +131 -0
- package/runtime/python/okstra_ctl/wizard.py +129 -133
- package/runtime/python/okstra_ctl/worktree.py +13 -5
- package/runtime/schemas/final-report-v1.0.schema.json +4 -0
- package/runtime/skills/okstra-coding-preflight/SKILL.md +69 -0
- package/runtime/skills/okstra-coding-preflight/architecture/hexagonal.md +116 -0
- package/runtime/skills/okstra-coding-preflight/clean-code.md +254 -0
- package/runtime/skills/okstra-coding-preflight/languages/java.md +64 -0
- package/runtime/skills/okstra-coding-preflight/languages/javascript-typescript.md +87 -0
- package/runtime/skills/okstra-coding-preflight/languages/kotlin.md +69 -0
- package/runtime/skills/okstra-coding-preflight/languages/nodejs.md +66 -0
- package/runtime/skills/okstra-coding-preflight/languages/python.md +179 -0
- package/runtime/skills/okstra-coding-preflight/languages/rust.md +105 -0
- package/runtime/skills/okstra-coding-preflight/languages/sql.md +68 -0
- package/runtime/skills/okstra-context-loader/SKILL.md +12 -6
- package/runtime/skills/okstra-inspect/SKILL.md +100 -1
- package/runtime/skills/okstra-memory/SKILL.md +28 -5
- package/runtime/skills/okstra-report-writer/SKILL.md +5 -1
- package/runtime/skills/okstra-run/SKILL.md +1 -1
- package/runtime/skills/okstra-team-contract/SKILL.md +7 -4
- package/runtime/templates/reports/final-report.template.md +1 -0
- package/runtime/templates/worker-prompt-preamble.md +3 -3
- package/runtime/validators/validate-implementation-plan-stages.py +57 -11
- package/src/_python-helper.mjs +3 -3
- package/src/context-cost.mjs +27 -0
- package/src/install.mjs +1 -0
- package/src/memory.mjs +50 -11
- package/src/uninstall.mjs +1 -0
|
@@ -18,6 +18,7 @@ project-id: {{ frontmatter.projectId | yaml_scalar }}
|
|
|
18
18
|
taskType: {{ frontmatter.taskType | yaml_scalar }}
|
|
19
19
|
workerId: {{ frontmatter.workerId | yaml_scalar }}
|
|
20
20
|
approved: {{ frontmatter.approved | yaml_scalar }}
|
|
21
|
+
implementation-option: {{ frontmatter.implementationOption | yaml_scalar }}
|
|
21
22
|
---
|
|
22
23
|
|
|
23
24
|
# {{ header.taskKey }} - Multi-Agent Cross Verification Final Report
|
|
@@ -8,7 +8,7 @@ It replaces the previous practice of inlining ~80 lines of identical boilerplate
|
|
|
8
8
|
|
|
9
9
|
## Required reading (analysis workers + report-writer worker)
|
|
10
10
|
|
|
11
|
-
You are required to read every input file enumerated by the dispatcher (the lead's prompt lists them under `[Required reading]`) from the very first character to the very last character before you produce any analysis output. Skimming, partial reads, jumping to a single section, or relying on prior knowledge of a similar file's structure is not acceptable.
|
|
11
|
+
You are required to read every primary input file enumerated by the dispatcher (the lead's prompt lists them under `[Required reading]`) from the very first character to the very last character before you produce any analysis output. Skimming, partial reads, jumping to a single section, or relying on prior knowledge of a similar file's structure is not acceptable. Source files listed as fallback/evidence paths are read on demand when you need to verify a citation, resolve ambiguity, or inspect material the packet says it omitted.
|
|
12
12
|
|
|
13
13
|
### Audience-scoped enumeration (BLOCKING — performance optimization)
|
|
14
14
|
|
|
@@ -16,7 +16,7 @@ Different recipients need different files. Do NOT include `final-report-template
|
|
|
16
16
|
|
|
17
17
|
| Recipient | Files included in `[Required reading]` |
|
|
18
18
|
|---|---|
|
|
19
|
-
| Claude / Codex / Gemini analysis workers | task-brief, analysis-profile, analysis-material
|
|
19
|
+
| Claude / Codex / Gemini analysis workers | analysis-packet.md as the primary compact input; task-brief, analysis-profile, analysis-material, reference-expectations, and clarification-response remain source/fallback paths, not automatic first-read files |
|
|
20
20
|
| Report writer worker (Phase 6) | all of the above **plus** the instruction-set-local `final-report-template.md` (phase-stripped) and `final-report-schema.json` (per-task-type excerpt) — NOT the full `templates/reports/...` / `schemas/...` sources |
|
|
21
21
|
| Reverify dispatches (Phase 5.5, lightweight mode) | **do NOT inject `[Required reading]` at all** — see [okstra-convergence](../skills/okstra-convergence/SKILL.md) "Reverify prompt: required-reading suppression". |
|
|
22
22
|
|
|
@@ -25,7 +25,7 @@ Different recipients need different files. Do NOT include `final-report-template
|
|
|
25
25
|
- Use a single `Read` tool call per file with no `offset` and no `limit`. If a file is genuinely too large for one read, page through with explicit `offset` / `limit` covering the entire file, and state the page boundaries in your Findings.
|
|
26
26
|
- For the carry-in clarification response, walk every row of `## 1. Clarification Items` (`C-001`, `C-002`, ...) in full, including rows whose `User input` cell is blank — a blank `User input` with `Status=open` is itself a signal you must surface. The structural similarity between the prior final report and the upcoming output is NOT a license to skim.
|
|
27
27
|
- Write the Reading Confirmation block to your **audit sidecar** at `runs/<task-type>/worker-results/<worker>-audit-<task-type>-<seq>.md` (sibling to the main worker-results file). One short line per input file confirming end-to-end reading. Do NOT include a `## 0. Reading Confirmation` heading in the main worker-results file — the validator fails worker-results that contain one. If you cannot truthfully confirm a file end-to-end, record a `tool-failure` in the errors sidecar instead of fabricating Findings.
|
|
28
|
-
-
|
|
28
|
+
- Treat `analysis-packet.md` as the canonical primary analysis input. It preserves the source files' frontmatter and extracts the task-specific brief, phase focus, reference expectations, and carry-in clarification rows. If the packet appears incomplete or a finding depends on a source citation, open the corresponding source file and cite it directly.
|
|
29
29
|
|
|
30
30
|
---
|
|
31
31
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""S1–
|
|
2
|
+
"""S1–S10 checks for the Stage Map structure of an approved
|
|
3
3
|
implementation-planning final-report.md. Run from prepare_task_bundle
|
|
4
4
|
of `implementation` task or standalone."""
|
|
5
5
|
|
|
@@ -40,7 +40,7 @@ class StageMeta:
|
|
|
40
40
|
|
|
41
41
|
@dataclass
|
|
42
42
|
class ValidationError:
|
|
43
|
-
code: str # S1..
|
|
43
|
+
code: str # S1..S10
|
|
44
44
|
stage: int # 0 = global
|
|
45
45
|
message: str
|
|
46
46
|
|
|
@@ -104,30 +104,36 @@ def _slice_stage_section(text: str, stage_number: int) -> str:
|
|
|
104
104
|
return text[start: start + nxt.start()] if nxt else text[start:]
|
|
105
105
|
|
|
106
106
|
|
|
107
|
-
def
|
|
107
|
+
def _effective_step_rows(section: str) -> List[List[str]]:
|
|
108
|
+
"""Effective (non header/divider/comment) rows of the `### Stepwise
|
|
109
|
+
Execution Order` table, each as a list of stripped cells. Columns are
|
|
110
|
+
`step | action | files | command | expected`, so action is index 1."""
|
|
108
111
|
m = re.search(r"^###\s+Stepwise Execution Order\b", section, re.M)
|
|
109
112
|
if not m:
|
|
110
|
-
return
|
|
113
|
+
return []
|
|
111
114
|
body = section[m.end():]
|
|
112
115
|
nxt = re.search(r"^###\s+\w", body, re.M)
|
|
113
116
|
if nxt:
|
|
114
117
|
body = body[: nxt.start()]
|
|
115
|
-
|
|
118
|
+
rows: List[List[str]] = []
|
|
116
119
|
for line in body.splitlines():
|
|
117
120
|
s = line.strip()
|
|
118
121
|
if not s or s.startswith("<!--"):
|
|
119
122
|
continue
|
|
120
123
|
if not s.startswith("|"):
|
|
121
124
|
continue
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
first_cell = s.strip("|").split("|")[0].strip()
|
|
125
|
+
cells = [c.strip() for c in s.strip("|").split("|")]
|
|
126
|
+
first_cell = cells[0]
|
|
125
127
|
if first_cell.lower() == "step":
|
|
126
128
|
continue
|
|
127
129
|
if set(first_cell) <= set("-: "):
|
|
128
130
|
continue
|
|
129
|
-
|
|
130
|
-
return
|
|
131
|
+
rows.append(cells)
|
|
132
|
+
return rows
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _count_effective_steps(section: str) -> int:
|
|
136
|
+
return len(_effective_step_rows(section))
|
|
131
137
|
|
|
132
138
|
|
|
133
139
|
def _check_each_stage_section(text: str, stages: List[StageMeta]) -> List[ValidationError]:
|
|
@@ -159,6 +165,45 @@ def _check_each_stage_section(text: str, stages: List[StageMeta]) -> List[Valida
|
|
|
159
165
|
return errs
|
|
160
166
|
|
|
161
167
|
|
|
168
|
+
SLICE_VALUE = re.compile(r"^\s*Slice value\s*:\s*(.+?)\s*$", re.M)
|
|
169
|
+
ACCEPTANCE = re.compile(r"^\s*Acceptance\s*:\s*(.+?)\s*$", re.M)
|
|
170
|
+
TDD_EXEMPTION = re.compile(r"^\s*TDD exemption\s*:\s*\S", re.M)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _check_slice_tdd(text: str, stages: List[StageMeta]) -> List[ValidationError]:
|
|
174
|
+
"""S10: each stage declares a vertical slice and follows RED→GREEN ordering.
|
|
175
|
+
|
|
176
|
+
S10a — `Slice value:` line with a non-empty value.
|
|
177
|
+
S10b — `Acceptance:` line with a non-empty value.
|
|
178
|
+
S10c — first effective Stepwise step's action starts with `RED:` AND some
|
|
179
|
+
action starts with `GREEN:`, OR a `TDD exemption:` line is present.
|
|
180
|
+
"""
|
|
181
|
+
errs: List[ValidationError] = []
|
|
182
|
+
for s in stages:
|
|
183
|
+
section = _slice_stage_section(text, s.stage_number)
|
|
184
|
+
if not section:
|
|
185
|
+
continue # S3 already reported the missing section
|
|
186
|
+
|
|
187
|
+
if not SLICE_VALUE.search(section):
|
|
188
|
+
errs.append(ValidationError("S10", s.stage_number,
|
|
189
|
+
"S10a: 'Slice value:' line missing or empty"))
|
|
190
|
+
if not ACCEPTANCE.search(section):
|
|
191
|
+
errs.append(ValidationError("S10", s.stage_number,
|
|
192
|
+
"S10b: 'Acceptance:' line missing or empty"))
|
|
193
|
+
|
|
194
|
+
if TDD_EXEMPTION.search(section):
|
|
195
|
+
continue
|
|
196
|
+
rows = _effective_step_rows(section)
|
|
197
|
+
actions = [r[1] for r in rows if len(r) > 1]
|
|
198
|
+
first_is_red = bool(actions) and actions[0].startswith("RED:")
|
|
199
|
+
has_green = any(a.startswith("GREEN:") for a in actions)
|
|
200
|
+
if not (first_is_red and has_green):
|
|
201
|
+
errs.append(ValidationError("S10", s.stage_number,
|
|
202
|
+
"S10c: first step action must start with 'RED:' and some "
|
|
203
|
+
"step action with 'GREEN:', or add a 'TDD exemption:' line"))
|
|
204
|
+
return errs
|
|
205
|
+
|
|
206
|
+
|
|
162
207
|
def _check_depends_on(stages: List[StageMeta]) -> List[ValidationError]:
|
|
163
208
|
errs: List[ValidationError] = []
|
|
164
209
|
valid = {s.stage_number for s in stages}
|
|
@@ -229,7 +274,7 @@ def _check_parallel_safety(text: str, stages: List[StageMeta]) -> List[Validatio
|
|
|
229
274
|
|
|
230
275
|
|
|
231
276
|
def collect_validation_errors(text: str) -> List[ValidationError]:
|
|
232
|
-
"""All S1–
|
|
277
|
+
"""All S1–S10 checks against the report text; empty list means valid.
|
|
233
278
|
|
|
234
279
|
S1 (missing `## 5.5 Stage Map` heading) makes the rest unparseable, so it
|
|
235
280
|
short-circuits. Shared by `main()` (CLI / implementation entry) and the
|
|
@@ -244,6 +289,7 @@ def collect_validation_errors(text: str) -> List[ValidationError]:
|
|
|
244
289
|
errors.extend(s2_errs)
|
|
245
290
|
if stages:
|
|
246
291
|
errors.extend(_check_each_stage_section(text, stages))
|
|
292
|
+
errors.extend(_check_slice_tdd(text, stages))
|
|
247
293
|
errors.extend(_check_depends_on(stages))
|
|
248
294
|
errors.extend(_check_parallel_safety(text, stages))
|
|
249
295
|
return errors
|
package/src/_python-helper.mjs
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { resolvePaths } from "./paths.mjs";
|
|
2
|
+
import { buildPythonpath, resolvePaths } from "./paths.mjs";
|
|
3
3
|
|
|
4
4
|
export async function runPythonSnippet({ script, args = [], extraEnv = {} }) {
|
|
5
5
|
const paths = await resolvePaths();
|
|
6
6
|
return new Promise((resolve) => {
|
|
7
7
|
const child = spawn("python3", ["-c", script, ...args], {
|
|
8
8
|
stdio: ["ignore", "pipe", "pipe"],
|
|
9
|
-
env: { ...process.env, PYTHONPATH: paths
|
|
9
|
+
env: { ...process.env, PYTHONPATH: buildPythonpath(paths), ...extraEnv },
|
|
10
10
|
});
|
|
11
11
|
let stdout = "";
|
|
12
12
|
let stderr = "";
|
|
@@ -22,7 +22,7 @@ export async function runPythonModule({ module, args = [], extraEnv = {}, stdio
|
|
|
22
22
|
return new Promise((resolve) => {
|
|
23
23
|
const child = spawn("python3", ["-m", module, ...args], {
|
|
24
24
|
stdio: stdio === "capture" ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "inherit"],
|
|
25
|
-
env: { ...process.env, PYTHONPATH: paths
|
|
25
|
+
env: { ...process.env, PYTHONPATH: buildPythonpath(paths), ...extraEnv },
|
|
26
26
|
});
|
|
27
27
|
let stdout = "";
|
|
28
28
|
let stderr = "";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { runPythonModule } from "./_python-helper.mjs";
|
|
2
|
+
|
|
3
|
+
const USAGE = `okstra context-cost — estimate context/read cost for a task bundle
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
okstra context-cost <task-root>
|
|
7
|
+
okstra context-cost <task-key> --project-root <dir>
|
|
8
|
+
okstra context-cost <task-key> --cwd <dir>
|
|
9
|
+
|
|
10
|
+
Output: JSON with task file counts, current-run counts, lead Phase 1 read
|
|
11
|
+
surface, analysis-worker initial read surface, and report-writer synthesis
|
|
12
|
+
surface. This is read-only; it never mutates task artifacts.
|
|
13
|
+
`;
|
|
14
|
+
|
|
15
|
+
export async function run(args) {
|
|
16
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
17
|
+
process.stdout.write(USAGE);
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const result = await runPythonModule({
|
|
22
|
+
module: "okstra_ctl.context_cost",
|
|
23
|
+
args,
|
|
24
|
+
stdio: "inherit-stdout",
|
|
25
|
+
});
|
|
26
|
+
return result.code ?? 0;
|
|
27
|
+
}
|
package/src/install.mjs
CHANGED
package/src/memory.mjs
CHANGED
|
@@ -46,8 +46,11 @@ It is separate from project-local .okstra task artifacts.
|
|
|
46
46
|
|
|
47
47
|
Usage:
|
|
48
48
|
okstra memory add [--content <text> | --file <path>] [options]
|
|
49
|
-
okstra memory list [--limit <n>] [--tag <tag>] [--type <type>]
|
|
50
|
-
|
|
49
|
+
okstra memory list [--limit <n>] [--tag <tag>] [--type <type>]
|
|
50
|
+
[--project-group <name>] [--json]
|
|
51
|
+
okstra memory search <query> [--limit <n>] [--project-group <name>]
|
|
52
|
+
[--include-archived] [--json]
|
|
53
|
+
okstra memory groups [--include-archived] [--json]
|
|
51
54
|
okstra memory show <id> [--json]
|
|
52
55
|
okstra memory archive <id> [--json]
|
|
53
56
|
|
|
@@ -55,7 +58,7 @@ Add options:
|
|
|
55
58
|
--title <title> Entry title. Defaults to the first non-empty line.
|
|
56
59
|
--type <type> context|decision|preference|requirement|person|
|
|
57
60
|
project-hint|follow-up. Default: context.
|
|
58
|
-
--
|
|
61
|
+
--project-group <name> Project group label (e.g. acme, private). Default: global.
|
|
59
62
|
--project <id> Related project id. Repeatable.
|
|
60
63
|
--tag <tag> Tag. Repeatable or comma-separated.
|
|
61
64
|
--source <source> Source label. Default: conversation.
|
|
@@ -97,7 +100,7 @@ function parseAddArgs(args) {
|
|
|
97
100
|
file: null,
|
|
98
101
|
title: null,
|
|
99
102
|
type: "context",
|
|
100
|
-
|
|
103
|
+
projectGroup: "global",
|
|
101
104
|
source: "conversation",
|
|
102
105
|
tags: [],
|
|
103
106
|
projects: [],
|
|
@@ -110,7 +113,7 @@ function parseAddArgs(args) {
|
|
|
110
113
|
else if (flag === "--file") opts.file = takeValue(args, i++, flag);
|
|
111
114
|
else if (flag === "--title") opts.title = takeValue(args, i++, flag);
|
|
112
115
|
else if (flag === "--type") opts.type = takeValue(args, i++, flag);
|
|
113
|
-
else if (flag === "--
|
|
116
|
+
else if (flag === "--project-group") opts.projectGroup = takeValue(args, i++, flag);
|
|
114
117
|
else if (flag === "--source") opts.source = takeValue(args, i++, flag);
|
|
115
118
|
else if (flag === "--project") opts.projects.push(takeValue(args, i++, flag));
|
|
116
119
|
else if (flag === "--tag") opts.tags.push(...splitCsv(takeValue(args, i++, flag)));
|
|
@@ -128,24 +131,26 @@ function parseAddArgs(args) {
|
|
|
128
131
|
}
|
|
129
132
|
|
|
130
133
|
function parseListArgs(args) {
|
|
131
|
-
const opts = { ...parseGlobalFlags(args), limit: 20, tag: null, type: null };
|
|
134
|
+
const opts = { ...parseGlobalFlags(args), limit: 20, tag: null, type: null, projectGroup: null };
|
|
132
135
|
for (let i = 0; i < args.length; i++) {
|
|
133
136
|
const flag = args[i];
|
|
134
137
|
if (flag === "--json" || flag === "--include-archived") continue;
|
|
135
138
|
if (flag === "--limit") opts.limit = parseLimit(takeValue(args, i++, flag));
|
|
136
139
|
else if (flag === "--tag") opts.tag = takeValue(args, i++, flag);
|
|
137
140
|
else if (flag === "--type") opts.type = takeValue(args, i++, flag);
|
|
141
|
+
else if (flag === "--project-group") opts.projectGroup = takeValue(args, i++, flag);
|
|
138
142
|
else throw new Error(`unknown flag ${flag}`);
|
|
139
143
|
}
|
|
140
144
|
return opts;
|
|
141
145
|
}
|
|
142
146
|
|
|
143
147
|
function parseQueryArgs(args) {
|
|
144
|
-
const opts = { ...parseGlobalFlags(args), limit: 20, query: [] };
|
|
148
|
+
const opts = { ...parseGlobalFlags(args), limit: 20, query: [], projectGroup: null };
|
|
145
149
|
for (let i = 0; i < args.length; i++) {
|
|
146
150
|
const flag = args[i];
|
|
147
151
|
if (flag === "--json" || flag === "--include-archived") continue;
|
|
148
152
|
if (flag === "--limit") opts.limit = parseLimit(takeValue(args, i++, flag));
|
|
153
|
+
else if (flag === "--project-group") opts.projectGroup = takeValue(args, i++, flag);
|
|
149
154
|
else if (flag.startsWith("--")) throw new Error(`unknown flag ${flag}`);
|
|
150
155
|
else opts.query.push(flag);
|
|
151
156
|
}
|
|
@@ -153,6 +158,19 @@ function parseQueryArgs(args) {
|
|
|
153
158
|
return { ...opts, query: opts.query.join(" ").trim() };
|
|
154
159
|
}
|
|
155
160
|
|
|
161
|
+
function parseGroupsArgs(args) {
|
|
162
|
+
for (const flag of args) {
|
|
163
|
+
if (flag !== "--json" && flag !== "--include-archived") {
|
|
164
|
+
throw new Error(`unknown flag ${flag}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return parseGlobalFlags(args);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function entryGroup(entry) {
|
|
171
|
+
return entry.projectGroup ?? "global";
|
|
172
|
+
}
|
|
173
|
+
|
|
156
174
|
function parseLimit(raw) {
|
|
157
175
|
const value = Number.parseInt(raw, 10);
|
|
158
176
|
if (!Number.isInteger(value) || value < 1) {
|
|
@@ -220,7 +238,7 @@ function buildEntry(opts, content, now) {
|
|
|
220
238
|
id,
|
|
221
239
|
title,
|
|
222
240
|
type: opts.type,
|
|
223
|
-
|
|
241
|
+
projectGroup: opts.projectGroup,
|
|
224
242
|
source: opts.source,
|
|
225
243
|
tags: [...new Set(opts.tags)],
|
|
226
244
|
relatedProjects: [...new Set(opts.projects)],
|
|
@@ -237,7 +255,7 @@ function renderMarkdown(entry, content) {
|
|
|
237
255
|
`id: ${entry.id}`,
|
|
238
256
|
`createdAt: ${entry.createdAt}`,
|
|
239
257
|
`source: ${entry.source}`,
|
|
240
|
-
`
|
|
258
|
+
`project-group: ${entry.projectGroup}`,
|
|
241
259
|
`type: ${entry.type}`,
|
|
242
260
|
`status: ${entry.status}`,
|
|
243
261
|
`relatedProjects: [${entry.relatedProjects.join(", ")}]`,
|
|
@@ -258,6 +276,7 @@ async function confirmSave(entry, content, opts) {
|
|
|
258
276
|
process.stdout.write(`Memory Book entry:\n`);
|
|
259
277
|
process.stdout.write(` title: ${entry.title}\n`);
|
|
260
278
|
process.stdout.write(` type: ${entry.type}\n`);
|
|
279
|
+
process.stdout.write(` group: ${entry.projectGroup}\n`);
|
|
261
280
|
process.stdout.write(` tags: ${entry.tags.join(", ") || "(none)"}\n`);
|
|
262
281
|
process.stdout.write(` text: ${truncate(content.trim().replace(/\s+/g, " "), 140)}\n`);
|
|
263
282
|
const rl = createInterface({ input, output });
|
|
@@ -341,6 +360,7 @@ async function opList(args) {
|
|
|
341
360
|
const entries = visibleEntries(await readIndex(), opts)
|
|
342
361
|
.filter((entry) => !opts.tag || entry.tags.includes(opts.tag))
|
|
343
362
|
.filter((entry) => !opts.type || entry.type === opts.type)
|
|
363
|
+
.filter((entry) => !opts.projectGroup || entryGroup(entry) === opts.projectGroup)
|
|
344
364
|
.slice(0, opts.limit);
|
|
345
365
|
if (opts.json) emitJson(entries);
|
|
346
366
|
else process.stdout.write(entries.map(formatEntryLine).join("\n") + (entries.length ? "\n" : ""));
|
|
@@ -350,7 +370,9 @@ async function opList(args) {
|
|
|
350
370
|
async function opSearch(args) {
|
|
351
371
|
const opts = parseQueryArgs(args);
|
|
352
372
|
const needle = opts.query.toLowerCase();
|
|
353
|
-
const entries = visibleEntries(await readIndex(), opts)
|
|
373
|
+
const entries = visibleEntries(await readIndex(), opts).filter(
|
|
374
|
+
(entry) => !opts.projectGroup || entryGroup(entry) === opts.projectGroup,
|
|
375
|
+
);
|
|
354
376
|
const matches = [];
|
|
355
377
|
for (const entry of entries) {
|
|
356
378
|
if (await entryMatches(entry, needle)) matches.push(entry);
|
|
@@ -361,12 +383,27 @@ async function opSearch(args) {
|
|
|
361
383
|
return 0;
|
|
362
384
|
}
|
|
363
385
|
|
|
386
|
+
async function opGroups(args) {
|
|
387
|
+
const opts = parseGroupsArgs(args);
|
|
388
|
+
const counts = new Map();
|
|
389
|
+
for (const entry of visibleEntries(await readIndex(), opts)) {
|
|
390
|
+
const group = entryGroup(entry);
|
|
391
|
+
counts.set(group, (counts.get(group) ?? 0) + 1);
|
|
392
|
+
}
|
|
393
|
+
const groups = [...counts.entries()]
|
|
394
|
+
.map(([name, count]) => ({ name, count }))
|
|
395
|
+
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
|
396
|
+
if (opts.json) emitJson(groups);
|
|
397
|
+
else process.stdout.write(groups.map((g) => `${g.name} (${g.count})`).join("\n") + (groups.length ? "\n" : ""));
|
|
398
|
+
return 0;
|
|
399
|
+
}
|
|
400
|
+
|
|
364
401
|
async function entryMatches(entry, needle) {
|
|
365
402
|
const haystack = [
|
|
366
403
|
entry.id,
|
|
367
404
|
entry.title,
|
|
368
405
|
entry.type,
|
|
369
|
-
entry
|
|
406
|
+
entryGroup(entry),
|
|
370
407
|
entry.source,
|
|
371
408
|
...entry.tags,
|
|
372
409
|
...entry.relatedProjects,
|
|
@@ -446,6 +483,8 @@ export async function run(args) {
|
|
|
446
483
|
return await opList(rest);
|
|
447
484
|
case "search":
|
|
448
485
|
return await opSearch(rest);
|
|
486
|
+
case "groups":
|
|
487
|
+
return await opGroups(rest);
|
|
449
488
|
case "show":
|
|
450
489
|
return await opShow(rest);
|
|
451
490
|
case "archive":
|