@bvdm/delano 0.2.3 → 0.2.4

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.
@@ -12,7 +12,16 @@ const { spawn, spawnSync } = require('node:child_process');
12
12
  const repoRoot = path.resolve(process.env.DELANO_VIEWER_ROOT || path.resolve(__dirname, '..', '..'));
13
13
  const projectRoot = path.join(repoRoot, '.project');
14
14
  const publicRoot = path.join(__dirname, 'public');
15
- const port = Number(process.env.DELANO_VIEWER_PORT || process.env.PORT || 3977);
15
+ const DEFAULT_PORT = 3977;
16
+ const MAX_PORT = 65535;
17
+ const MAX_PORT_ATTEMPTS = 100;
18
+ const startPort = normalizePort(process.env.DELANO_VIEWER_PORT || process.env.PORT, DEFAULT_PORT);
19
+
20
+ function normalizePort(value, fallback) {
21
+ const parsed = Number(value || fallback);
22
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > MAX_PORT) return fallback;
23
+ return parsed;
24
+ }
16
25
 
17
26
  function isInside(parent, child) {
18
27
  const rel = path.relative(parent, child);
@@ -332,6 +341,7 @@ function sendStatic(res, pathname) {
332
341
  const ext = path.extname(resolved).toLowerCase();
333
342
  const mimeMap = {
334
343
  '.js': 'text/javascript',
344
+ '.jsx': 'text/javascript',
335
345
  '.css': 'text/css',
336
346
  '.svg': 'image/svg+xml',
337
347
  '.png': 'image/png',
@@ -340,7 +350,7 @@ function sendStatic(res, pathname) {
340
350
  '.webp': 'image/webp',
341
351
  '.ico': 'image/x-icon',
342
352
  };
343
- const isText = ext === '.js' || ext === '.css' || ext === '.svg' || ext === '' || ext === '.html';
353
+ const isText = ext === '.js' || ext === '.jsx' || ext === '.css' || ext === '.svg' || ext === '' || ext === '.html';
344
354
  const type = mimeMap[ext] || 'text/html';
345
355
  const headers = isText ? { 'content-type': `${type}; charset=utf-8` } : { 'content-type': type };
346
356
  res.writeHead(200, headers);
@@ -384,6 +394,37 @@ const server = http.createServer((req, res) => {
384
394
  }
385
395
  });
386
396
 
387
- server.listen(port, '127.0.0.1', () => {
388
- console.log(`Delano read-only viewer: http://127.0.0.1:${port}`);
389
- });
397
+ function listenWithPortFallback(server, firstPort, host = '127.0.0.1') {
398
+ let port = firstPort;
399
+ let attempts = 0;
400
+
401
+ const listen = () => {
402
+ server.once('error', onError);
403
+ server.listen(port, host);
404
+ };
405
+
406
+ const onError = (error) => {
407
+ if (error.code === 'EADDRINUSE' && port < MAX_PORT && attempts < MAX_PORT_ATTEMPTS) {
408
+ attempts += 1;
409
+ port += 1;
410
+ listen();
411
+ return;
412
+ }
413
+
414
+ console.error(`Failed to start Delano viewer on ${host}:${port}: ${error.message}`);
415
+ process.exitCode = 1;
416
+ };
417
+
418
+ const onListening = () => {
419
+ server.removeListener('error', onError);
420
+ const address = server.address();
421
+ const actualPort = typeof address === 'object' && address ? address.port : port;
422
+ const skipped = actualPort !== firstPort ? ` (${firstPort} was unavailable)` : '';
423
+ console.log(`Delano read-only viewer: http://${host}:${actualPort}${skipped}`);
424
+ };
425
+
426
+ server.on('listening', onListening);
427
+ listen();
428
+ }
429
+
430
+ listenWithPortFallback(server, startPort);
package/README.md CHANGED
@@ -15,7 +15,7 @@ The npm package is intentionally thin. It distributes the approved runtime paylo
15
15
  ## Delano CLI
16
16
 
17
17
  - Package: `@bvdm/delano`
18
- - Current package version: `0.2.3`
18
+ - Current package version: `0.2.4`
19
19
  - Binary: `delano`
20
20
  - Commands: `onboarding`, `install`, `viewer`, `init`, `validate`, `status`, `next`
21
21
  - Primary goal: bootstrap a repo safely, expose local delivery state clearly, and keep runtime gates verifiable
@@ -111,6 +111,7 @@ npx -y @bvdm/delano@latest onboarding --approve-agents-analysis
111
111
  npx -y @bvdm/delano@latest viewer
112
112
  npx -y @bvdm/delano@latest validate
113
113
  npx -y @bvdm/delano@latest status
114
+ npx -y @bvdm/delano@latest status --open --brief
114
115
  npx -y @bvdm/delano@latest next -- --all
115
116
  ```
116
117
 
@@ -121,6 +122,7 @@ delano onboarding
121
122
  delano viewer
122
123
  delano validate
123
124
  delano status
125
+ delano status --open --brief
124
126
  delano next -- --all
125
127
  delano init <slug> "<Project Name>" [owner] [lead]
126
128
  ```
@@ -130,6 +132,7 @@ The wrapper commands call the existing PM scripts under `.agents/scripts/pm/`. Y
130
132
  ```bash
131
133
  bash .agents/scripts/pm/validate.sh
132
134
  bash .agents/scripts/pm/status.sh
135
+ bash .agents/scripts/pm/status.sh --open --brief
133
136
  bash .agents/scripts/pm/next.sh --all
134
137
  ```
135
138
 
@@ -160,6 +163,28 @@ The CLI does not bundle its own shell or Python runtime.
160
163
 
161
164
  The base install payload intentionally excludes top-level adapter entry docs such as `AGENTS.md`, `CLAUDE.md`, `CODEX.md`, `OPENCODE.md`, and `PI.md`. Those remain opt-in only.
162
165
  The base install payload includes `.delano/`, including the read-only viewer UI.
166
+ The base install payload also includes `.codex/hooks.json`, a Codex `SessionStart` hook config that injects compact open-project context when Codex hooks are enabled. If a target repo already has `.codex/hooks.json`, `delano install` merges the Delano hook into the existing JSON instead of replacing it. Invalid or non-file hook configs are skipped without blocking the rest of the install.
167
+
168
+ Codex hook activation is intentionally manual:
169
+
170
+ 1. Enable hooks for a session with `codex --enable hooks`, or persist the feature in `~/.codex/config.toml`:
171
+
172
+ ```toml
173
+ [features]
174
+ hooks = true
175
+ ```
176
+
177
+ 2. Start Codex in the repository and approve the project trust prompt for the repo-local `.codex/` layer. Codex records trusted projects in `~/.codex/config.toml`, for example:
178
+
179
+ ```toml
180
+ [projects."E:\\path\\to\\repo"]
181
+ trust_level = "trusted"
182
+ ```
183
+
184
+ 3. Approve the Delano `SessionStart` hook when Codex asks whether to trust it.
185
+
186
+ Older docs and builds may refer to `[features].codex_hooks`; newer Codex builds warn that this key is deprecated in favor of `[features].hooks`.
187
+
163
188
  The installable `.project/context/` pack is seeded from generic templates during packaging; it does not ship Delano's own repo-specific context files into consumer repositories.
164
189
  After install, the recommended first step is `delano onboarding`, which requires explicit approval before it reviews `AGENTS.md`.
165
190
 
@@ -174,7 +199,7 @@ delano install --no-project-state --force --yes
174
199
 
175
200
  The interactive installer presents presets for updating the runtime while preserving project state, updating only skills and project templates, full install or repair, and custom category selection.
176
201
 
177
- Install categories are `agent-runtime`, `skills`, `viewer`, `project-context`, `project-templates`, `project-registry`, `project-projects`, `handbook`, and `legacy-installer`. The `--no-project-state` shortcut excludes `.project/context`, `.project/projects`, and `.project/registry`.
202
+ Install categories are `agent-runtime`, `codex-hooks`, `skills`, `viewer`, `project-context`, `project-templates`, `project-registry`, `project-projects`, `handbook`, and `legacy-installer`. The `--no-project-state` shortcut excludes `.project/context`, `.project/projects`, and `.project/registry`.
178
203
 
179
204
  ## Optional AGENTS.md / CLAUDE.md snippet
180
205
 
@@ -254,7 +279,7 @@ Before the first Actions publish, configure npm trusted publishing for `@bvdm/de
254
279
 
255
280
  The package metadata must keep `repository.url` set to `https://github.com/MajesteitBart/delano`; npm validates that value against the GitHub Actions provenance bundle.
256
281
 
257
- After trusted publishing is configured, publish by pushing a matching version tag such as `v0.2.3`, or run the `Publish package to npm` workflow manually from `main`. The workflow rebuilds the package payload, checks manifest drift, runs tests, dry-runs the package contents, verifies the version is not already published, and then runs `npm publish --access public` from GitHub Actions using OIDC. A manual `dry_run` input is available to run the same checks without publishing.
282
+ After trusted publishing is configured, publish by pushing a matching version tag such as `v0.2.4`, or run the `Publish package to npm` workflow manually from `main`. The workflow rebuilds the package payload, checks manifest drift, runs tests, dry-runs the package contents, verifies the version is not already published, and then runs `npm publish --access public` from GitHub Actions using OIDC. A manual `dry_run` input is available to run the same checks without publishing.
258
283
 
259
284
  If npm publish fails after the package checks pass, verify that the npm trusted publisher settings match the repository and workflow filename exactly, and that the workflow has `id-token: write`.
260
285
 
@@ -14,6 +14,7 @@
14
14
  ".agents/fixtures/linear/issue-snapshot.json",
15
15
  ".agents/hooks/README.md",
16
16
  ".agents/hooks/bash-worktree-fix.sh",
17
+ ".agents/hooks/codex-session-status.js",
17
18
  ".agents/hooks/post-tool-logger.js",
18
19
  ".agents/hooks/session-tracker.js",
19
20
  ".agents/hooks/user-prompt-logger.js",
@@ -155,6 +156,7 @@
155
156
  ".agents/validation-fixtures/strict/invalid/stale-context/context.md",
156
157
  ".agents/validation-fixtures/strict/manifest.json",
157
158
  ".agents/validation-fixtures/strict/valid/minimal-project/task.md",
159
+ ".codex/hooks.json",
158
160
  ".delano/README.md",
159
161
  ".delano/viewer/README.md",
160
162
  ".delano/viewer/server.js",
@@ -7,5 +7,10 @@ Default hooks:
7
7
  - `post-tool-logger.js`
8
8
  - `user-prompt-logger.js`
9
9
  - `bash-worktree-fix.sh`
10
+ - `codex-session-status.js`
10
11
 
11
- All hooks append JSONL records in `.agents/logs/`.
12
+ The logging hooks append JSONL records in `.agents/logs/`.
13
+
14
+ `codex-session-status.js` is used by the optional `.codex/hooks.json` SessionStart
15
+ configuration. It emits `delano status --open --brief` context and fails open if
16
+ the local runtime is not available.
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ const { existsSync } = require("node:fs");
3
+ const path = require("node:path");
4
+ const { spawnSync } = require("node:child_process");
5
+
6
+ function findDelanoRoot(startDir) {
7
+ let current = path.resolve(startDir);
8
+ while (true) {
9
+ if (
10
+ existsSync(path.join(current, ".project", "projects")) &&
11
+ existsSync(path.join(current, ".agents", "scripts", "pm", "status.sh"))
12
+ ) {
13
+ return current;
14
+ }
15
+
16
+ const parent = path.dirname(current);
17
+ if (parent === current) {
18
+ return null;
19
+ }
20
+ current = parent;
21
+ }
22
+ }
23
+
24
+ function toBashPath(filePath) {
25
+ return filePath.replace(/\\/g, "/");
26
+ }
27
+
28
+ function resolveBash() {
29
+ const candidates = [];
30
+
31
+ if (process.env.DELANO_BASH) {
32
+ candidates.push(process.env.DELANO_BASH);
33
+ }
34
+
35
+ if (process.platform === "win32") {
36
+ candidates.push(
37
+ "C:\\Program Files\\Git\\bin\\bash.exe",
38
+ "C:\\Program Files\\Git\\usr\\bin\\bash.exe"
39
+ );
40
+
41
+ const whereResult = spawnSync("where.exe", ["bash"], {
42
+ encoding: "utf8",
43
+ stdio: ["ignore", "pipe", "ignore"]
44
+ });
45
+ if (whereResult.status === 0 && whereResult.stdout) {
46
+ candidates.push(...whereResult.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean));
47
+ }
48
+ } else {
49
+ const whichResult = spawnSync("which", ["bash"], {
50
+ encoding: "utf8",
51
+ stdio: ["ignore", "pipe", "ignore"]
52
+ });
53
+ if (whichResult.status === 0 && whichResult.stdout.trim()) {
54
+ candidates.push(whichResult.stdout.trim());
55
+ }
56
+ candidates.push("/usr/bin/bash", "/bin/bash");
57
+ }
58
+
59
+ return candidates.find((candidate) => candidate && existsSync(candidate)) || null;
60
+ }
61
+
62
+ const root = findDelanoRoot(process.cwd());
63
+ const bashPath = resolveBash();
64
+ if (!root || !bashPath) {
65
+ process.exit(0);
66
+ }
67
+
68
+ const statusScript = toBashPath(path.join(root, ".agents", "scripts", "pm", "status.sh"));
69
+ const result = spawnSync(bashPath, [statusScript, "--open", "--brief"], {
70
+ cwd: root,
71
+ encoding: "utf8",
72
+ timeout: 4500,
73
+ maxBuffer: 64 * 1024
74
+ });
75
+
76
+ if (result.error || result.status !== 0) {
77
+ process.exit(0);
78
+ }
79
+
80
+ const statusOutput = result.stdout.trim();
81
+ if (!statusOutput) {
82
+ process.exit(0);
83
+ }
84
+
85
+ const additionalContext = formatStatusContext(statusOutput);
86
+
87
+ console.log(JSON.stringify({
88
+ hookSpecificOutput: {
89
+ hookEventName: "SessionStart",
90
+ additionalContext
91
+ }
92
+ }));
93
+
94
+ function formatStatusContext(rawStatusOutput) {
95
+ const lines = rawStatusOutput.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
96
+ const projectLines = lines.filter((line) => (
97
+ !line.startsWith("Delano ") &&
98
+ !/^=+$/.test(line) &&
99
+ !line.startsWith("No open projects")
100
+ ));
101
+
102
+ if (projectLines.length === 0) {
103
+ return "Delano startup context. Open projects: none.";
104
+ }
105
+
106
+ const projects = projectLines.map(formatProjectLine);
107
+ return `Delano startup context. Open projects: ${projects.join("; ")}.`;
108
+ }
109
+
110
+ function formatProjectLine(line) {
111
+ const match = line.match(/^(\S+)\s+spec=(\S+)\s+plan=(\S+)\s+open_tasks=(\d+)\s+total_tasks=(\d+)$/);
112
+ if (!match) {
113
+ return line;
114
+ }
115
+
116
+ const [, slug, spec, plan, openTasks, totalTasks] = match;
117
+ return `${slug} (spec=${spec}, plan=${plan}, open_tasks=${openTasks}, total_tasks=${totalTasks})`;
118
+ }
119
+
120
+ module.exports = {
121
+ formatProjectLine,
122
+ formatStatusContext
123
+ };
@@ -21,6 +21,24 @@
21
21
  "description": "A done task must not close over unresolved local dependencies.",
22
22
  "requires": ["all local depends_on task statuses are done"]
23
23
  },
24
+ {
25
+ "id": "progressed-task-requires-active-project",
26
+ "status": "in-progress|done",
27
+ "description": "A task must not start or complete while the parent project spec or plan is still planned.",
28
+ "requires": [
29
+ "spec.status is active or complete",
30
+ "plan.status is active or done"
31
+ ]
32
+ },
33
+ {
34
+ "id": "closed-task-set-requires-closed-project",
35
+ "status": "done|deferred",
36
+ "description": "A project with no open tasks must not remain open through stale spec or plan statuses.",
37
+ "requires": [
38
+ "spec.status is complete or deferred",
39
+ "plan.status is done or deferred"
40
+ ]
41
+ },
24
42
  {
25
43
  "id": "blocked-owner-check-back",
26
44
  "status": "blocked",
@@ -11,7 +11,7 @@ Compatibility path: `.claude/scripts/...` when the mirror is present.
11
11
  Critical path:
12
12
  - `init.sh`
13
13
  - `validate.sh`
14
- - `status.sh`
14
+ - `status.sh` (`--open` and `--brief` are available for compact startup context)
15
15
  - `next.sh`
16
16
  - `blocked.sh`
17
17
 
@@ -15,7 +15,12 @@ if (contract.schema_version !== 1) {
15
15
  errors.push("status-transitions.json schema_version must be 1.");
16
16
  }
17
17
  const rules = Array.isArray(contract.task_rules) ? contract.task_rules : [];
18
- for (const requiredRule of ["ready-dependencies-done", "blocked-owner-check-back"]) {
18
+ for (const requiredRule of [
19
+ "ready-dependencies-done",
20
+ "blocked-owner-check-back",
21
+ "progressed-task-requires-active-project",
22
+ "closed-task-set-requires-closed-project"
23
+ ]) {
19
24
  if (!rules.some((rule) => rule.id === requiredRule)) {
20
25
  errors.push(`status transition contract missing rule: ${requiredRule}`);
21
26
  }
@@ -28,16 +33,39 @@ if (transitionRequest) {
28
33
  }
29
34
 
30
35
  for (const projectDir of listDirectories(projectsRoot)) {
36
+ const specPath = path.join(projectDir, "spec.md");
37
+ const planPath = path.join(projectDir, "plan.md");
38
+ const specFrontmatter = existsSync(specPath) ? parseFrontmatter(specPath) : null;
39
+ const planFrontmatter = existsSync(planPath) ? parseFrontmatter(planPath) : null;
40
+ const hasProjectLifecycle = Boolean(specFrontmatter || planFrontmatter);
31
41
  const tasksDir = path.join(projectDir, "tasks");
32
42
  if (!existsSync(tasksDir)) continue;
33
43
 
34
44
  const tasks = new Map();
45
+ let totalTaskCount = 0;
46
+ let openTaskCount = 0;
47
+ let progressedTaskCount = 0;
35
48
  for (const taskFile of listMarkdownFiles(tasksDir)) {
36
49
  const frontmatter = parseFrontmatter(taskFile);
37
50
  const id = frontmatter.id || path.basename(taskFile, ".md").split("-").slice(0, 2).join("-");
51
+ const status = frontmatter.status || "";
52
+ totalTaskCount += 1;
53
+ if (!isClosedTaskStatus(status)) openTaskCount += 1;
54
+ if (isProgressedTaskStatus(status)) progressedTaskCount += 1;
38
55
  tasks.set(id, { file: taskFile, frontmatter });
39
56
  }
40
57
 
58
+ if (hasProjectLifecycle) {
59
+ validateProjectLifecycle({
60
+ projectDir,
61
+ specStatus: specFrontmatter?.status || "",
62
+ planStatus: planFrontmatter?.status || "",
63
+ totalTaskCount,
64
+ openTaskCount,
65
+ progressedTaskCount
66
+ });
67
+ }
68
+
41
69
  for (const [taskId, task] of tasks.entries()) {
42
70
  const status = task.frontmatter.status || "";
43
71
  const dependencies = parseList(task.frontmatter.depends_on || "[]");
@@ -75,7 +103,9 @@ function parseTransitionArgs(args) {
75
103
  .filter(Boolean);
76
104
  const blockedOwner = valueAfter(args, "--blocked-owner");
77
105
  const blockedCheckBack = valueAfter(args, "--blocked-check-back");
78
- return { nextStatus, dependencyStatuses, blockedOwner, blockedCheckBack };
106
+ const specStatus = valueAfter(args, "--spec-status");
107
+ const planStatus = valueAfter(args, "--plan-status");
108
+ return { nextStatus, dependencyStatuses, blockedOwner, blockedCheckBack, specStatus, planStatus };
79
109
  }
80
110
 
81
111
  function validateTransitionRequest(request) {
@@ -87,12 +117,70 @@ function validateTransitionRequest(request) {
87
117
  }
88
118
  }
89
119
 
120
+ if (["in-progress", "done"].includes(request.nextStatus)) {
121
+ if (request.specStatus && !isActiveOrClosedSpecStatus(request.specStatus)) {
122
+ errors.push(`cannot transition to ${request.nextStatus} while spec status is ${request.specStatus}; expected active or complete`);
123
+ }
124
+ if (request.planStatus && !isActiveOrClosedPlanStatus(request.planStatus)) {
125
+ errors.push(`cannot transition to ${request.nextStatus} while plan status is ${request.planStatus}; expected active or done`);
126
+ }
127
+ }
128
+
90
129
  if (request.nextStatus === "blocked") {
91
130
  if (!request.blockedOwner) errors.push("cannot transition to blocked without blocked_owner");
92
131
  if (!request.blockedCheckBack) errors.push("cannot transition to blocked without blocked_check_back");
93
132
  }
94
133
  }
95
134
 
135
+ function validateProjectLifecycle(request) {
136
+ const projectPath = toRepoPath(request.projectDir);
137
+ if (request.progressedTaskCount > 0) {
138
+ if (!isActiveOrClosedSpecStatus(request.specStatus)) {
139
+ errors.push(`${projectPath} has ${request.progressedTaskCount} progressed task(s) but spec.md status is ${describeStatus(request.specStatus)}; expected active or complete before tasks can progress.`);
140
+ }
141
+ if (!isActiveOrClosedPlanStatus(request.planStatus)) {
142
+ errors.push(`${projectPath} has ${request.progressedTaskCount} progressed task(s) but plan.md status is ${describeStatus(request.planStatus)}; expected active or done before tasks can progress.`);
143
+ }
144
+ }
145
+
146
+ if (request.totalTaskCount > 0 && request.openTaskCount === 0) {
147
+ if (!isClosedSpecStatus(request.specStatus)) {
148
+ errors.push(`${projectPath} has no open tasks but spec.md status is ${describeStatus(request.specStatus)}; expected complete or deferred.`);
149
+ }
150
+ if (!isClosedPlanStatus(request.planStatus)) {
151
+ errors.push(`${projectPath} has no open tasks but plan.md status is ${describeStatus(request.planStatus)}; expected done or deferred.`);
152
+ }
153
+ }
154
+ }
155
+
156
+ function isProgressedTaskStatus(status) {
157
+ return ["in-progress", "done"].includes(status);
158
+ }
159
+
160
+ function isClosedTaskStatus(status) {
161
+ return ["done", "deferred", "canceled"].includes(status);
162
+ }
163
+
164
+ function isActiveOrClosedSpecStatus(status) {
165
+ return ["active", "complete"].includes(status);
166
+ }
167
+
168
+ function isActiveOrClosedPlanStatus(status) {
169
+ return ["active", "done"].includes(status);
170
+ }
171
+
172
+ function isClosedSpecStatus(status) {
173
+ return ["complete", "deferred"].includes(status);
174
+ }
175
+
176
+ function isClosedPlanStatus(status) {
177
+ return ["done", "deferred"].includes(status);
178
+ }
179
+
180
+ function describeStatus(status) {
181
+ return status || "missing status";
182
+ }
183
+
96
184
  function valueAfter(args, flag) {
97
185
  const index = args.indexOf(flag);
98
186
  if (index === -1 || index === args.length - 1) return "";
@@ -4,24 +4,92 @@ set -euo pipefail
4
4
  root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
5
5
  cd "$root"
6
6
 
7
+ open_only=false
8
+ brief=false
9
+
10
+ usage() {
11
+ cat <<'EOF'
12
+ Usage:
13
+ status.sh [--open] [--brief]
14
+
15
+ Options:
16
+ --open Show only projects that are not closed.
17
+ --brief Show one compact line per project.
18
+ -h, --help
19
+ Show this help.
20
+ EOF
21
+ }
22
+
23
+ while [[ $# -gt 0 ]]; do
24
+ case "$1" in
25
+ --open)
26
+ open_only=true
27
+ ;;
28
+ --brief)
29
+ brief=true
30
+ ;;
31
+ -h|--help)
32
+ usage
33
+ exit 0
34
+ ;;
35
+ *)
36
+ echo "Unknown status option: $1" >&2
37
+ usage >&2
38
+ exit 1
39
+ ;;
40
+ esac
41
+ shift
42
+ done
43
+
7
44
  fm_get() {
8
45
  local file="$1"
9
46
  local key="$2"
10
- awk -v key="$key" '
11
- BEGIN {in_fm=0}
12
- /^---[[:space:]]*$/ {if (in_fm==0) {in_fm=1; next} else {exit}}
13
- in_fm==1 && $0 ~ "^" key ":[[:space:]]*" {
14
- sub("^" key ":[[:space:]]*", "")
15
- print
16
- exit
17
- }
18
- ' "$file"
47
+ local line
48
+ local in_fm=0
49
+ while IFS= read -r line; do
50
+ if [[ "$line" =~ ^---[[:space:]]*$ ]]; then
51
+ if [[ $in_fm -eq 0 ]]; then
52
+ in_fm=1
53
+ continue
54
+ fi
55
+ return 1
56
+ fi
57
+
58
+ if [[ $in_fm -eq 1 && "$line" == "$key:"* ]]; then
59
+ local value="${line#"$key:"}"
60
+ value="${value#"${value%%[![:space:]]*}"}"
61
+ printf '%s\n' "$value"
62
+ return 0
63
+ fi
64
+ done < "$file"
65
+ return 1
19
66
  }
20
67
 
21
- echo "Delano portfolio status"
22
- echo "======================="
68
+ is_closed_spec_status() {
69
+ local status="${1:-unknown}"
70
+ [[ "$status" == "complete" || "$status" == "deferred" ]]
71
+ }
72
+
73
+ is_closed_plan_status() {
74
+ local status="${1:-unknown}"
75
+ [[ "$status" == "done" || "$status" == "deferred" ]]
76
+ }
77
+
78
+ is_closed_task_status() {
79
+ local status="${1:-unknown}"
80
+ [[ "$status" == "done" || "$status" == "deferred" || "$status" == "canceled" ]]
81
+ }
82
+
83
+ if [[ "$open_only" == "true" ]]; then
84
+ echo "Delano open project status"
85
+ echo "=========================="
86
+ else
87
+ echo "Delano portfolio status"
88
+ echo "======================="
89
+ fi
23
90
 
24
91
  project_count=0
92
+ printed_count=0
25
93
  for project_dir in .project/projects/*; do
26
94
  [[ -d "$project_dir" ]] || continue
27
95
  [[ "$(basename "$project_dir")" == ".gitkeep" ]] && continue
@@ -31,31 +99,70 @@ for project_dir in .project/projects/*; do
31
99
  spec_status="$(fm_get "$project_dir/spec.md" status 2>/dev/null || true)"
32
100
  plan_status="$(fm_get "$project_dir/plan.md" status 2>/dev/null || true)"
33
101
 
34
- echo ""
35
- echo "Project: $slug"
36
- echo " Spec status: ${spec_status:-unknown}"
37
- echo " Plan status: ${plan_status:-unknown}"
38
-
39
102
  total=0
103
+ open_tasks=0
104
+ backlog_count=0
105
+ ready_count=0
106
+ in_progress_count=0
107
+ review_count=0
108
+ done_count=0
109
+ blocked_count=0
110
+ deferred_count=0
111
+ canceled_count=0
112
+ unknown_count=0
40
113
  for task in "$project_dir"/tasks/*.md; do
41
114
  [[ -f "$task" ]] || continue
115
+ status="$(fm_get "$task" status 2>/dev/null || true)"
42
116
  total=$((total + 1))
117
+ if ! is_closed_task_status "$status"; then
118
+ open_tasks=$((open_tasks + 1))
119
+ fi
120
+ case "$status" in
121
+ backlog) backlog_count=$((backlog_count + 1)) ;;
122
+ ready) ready_count=$((ready_count + 1)) ;;
123
+ in-progress) in_progress_count=$((in_progress_count + 1)) ;;
124
+ review) review_count=$((review_count + 1)) ;;
125
+ done) done_count=$((done_count + 1)) ;;
126
+ blocked) blocked_count=$((blocked_count + 1)) ;;
127
+ deferred) deferred_count=$((deferred_count + 1)) ;;
128
+ canceled) canceled_count=$((canceled_count + 1)) ;;
129
+ *) unknown_count=$((unknown_count + 1)) ;;
130
+ esac
43
131
  done
44
132
 
45
- for st in backlog ready in-progress review done blocked canceled; do
46
- count=0
47
- for task in "$project_dir"/tasks/*.md; do
48
- [[ -f "$task" ]] || continue
49
- status="$(fm_get "$task" status 2>/dev/null || true)"
50
- if [[ "$status" == "$st" ]]; then
51
- count=$((count + 1))
52
- fi
53
- done
54
- [[ $count -gt 0 ]] && echo " $st: $count"
55
- done
56
- echo " total tasks: $total"
133
+ project_open=false
134
+ if ! is_closed_spec_status "$spec_status" || ! is_closed_plan_status "$plan_status" || [[ $open_tasks -gt 0 ]]; then
135
+ project_open=true
136
+ fi
137
+
138
+ if [[ "$open_only" == "true" && "$project_open" != "true" ]]; then
139
+ continue
140
+ fi
141
+
142
+ printed_count=$((printed_count + 1))
143
+
144
+ if [[ "$brief" == "true" ]]; then
145
+ echo "${slug} spec=${spec_status:-unknown} plan=${plan_status:-unknown} open_tasks=${open_tasks} total_tasks=${total}"
146
+ else
147
+ echo ""
148
+ echo "Project: $slug"
149
+ echo " Spec status: ${spec_status:-unknown}"
150
+ echo " Plan status: ${plan_status:-unknown}"
151
+ [[ $backlog_count -gt 0 ]] && echo " backlog: $backlog_count"
152
+ [[ $ready_count -gt 0 ]] && echo " ready: $ready_count"
153
+ [[ $in_progress_count -gt 0 ]] && echo " in-progress: $in_progress_count"
154
+ [[ $review_count -gt 0 ]] && echo " review: $review_count"
155
+ [[ $done_count -gt 0 ]] && echo " done: $done_count"
156
+ [[ $blocked_count -gt 0 ]] && echo " blocked: $blocked_count"
157
+ [[ $deferred_count -gt 0 ]] && echo " deferred: $deferred_count"
158
+ [[ $canceled_count -gt 0 ]] && echo " canceled: $canceled_count"
159
+ [[ $unknown_count -gt 0 ]] && echo " unknown: $unknown_count"
160
+ echo " total tasks: $total"
161
+ fi
57
162
  done
58
163
 
59
164
  if [[ $project_count -eq 0 ]]; then
60
165
  echo "No projects found. Create one with: .agents/scripts/pm/init.sh <slug> <project-name>"
166
+ elif [[ "$open_only" == "true" && $printed_count -eq 0 ]]; then
167
+ echo "No open projects found."
61
168
  fi
@@ -497,6 +497,8 @@ fi
497
497
 
498
498
  if [[ -n "$status_transition_check" ]]; then
499
499
  echo ""
500
+ echo "Project lifecycle and status transition check"
501
+ echo "---------------------------------------------"
500
502
  if command -v node >/dev/null 2>&1; then
501
503
  if node "$status_transition_check"; then
502
504
  true