dw-kit 1.9.0-rc.1 → 1.9.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.
@@ -85,6 +85,16 @@ if [ "$ACTIVE_TASK_FORMAT" = "v3" ] && [ -n "$ACTIVE_TASK" ]; then
85
85
  fi
86
86
  fi
87
87
 
88
+ # --- Refresh tasks-index@v1 (ADR-0017 doc contract) — fire-and-forget ---
89
+ # Keeps the adapter read-contract (.dw/tasks/tasks-index.json) fresh after manual
90
+ # task.md edits. Idempotent at the writer (writeTaskIndex sorts keys + skips the
91
+ # write when the task set is byte-identical), so an unchanged set produces ZERO
92
+ # git diff and never blocks a post-commit `git checkout` (#21). Silent; errors ignored.
93
+ DW_BIN="$PROJECT_DIR/bin/dw.mjs"
94
+ if [ -f "$DW_BIN" ] && [ -d "$PROJECT_DIR/.dw/tasks" ] && command -v node >/dev/null 2>&1; then
95
+ node "$DW_BIN" task index >/dev/null 2>&1 || true
96
+ fi
97
+
88
98
  # --- Auto-handoff: append snippet to active task's tracking/timeline file if uncommitted ---
89
99
  if [ "$HAS_UNCOMMITTED" = "1" ] && [ -n "$ACTIVE_TASK_FILE" ]; then
90
100
  TS=$(date -u +"%Y-%m-%d %H:%M UTC")
@@ -73,12 +73,13 @@ Show preview (first 30 lines) rồi ask:
73
73
  Save this ADR to .dw/decisions/{NNNN}-{title}.md? (Y/n)
74
74
  ```
75
75
 
76
- Nếu yes → write file + print:
76
+ Nếu yes → write file, sau đó chạy `dw decision index` (cập nhật `decisions-index@v1` — read-contract cho adapter, ADR-0017 v1.1), rồi print:
77
77
  ```
78
78
  ✓ ADR-{NNNN} created
79
79
  Status: Proposed
80
80
  Next: Review → change status to Accepted when approved
81
81
  Related task: update task.md (v3) / tracking.md (v2) với reference
82
+ decisions-index refreshed (dw decision index)
82
83
  ```
83
84
 
84
85
  ## Quality Bar
@@ -0,0 +1,54 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://github.com/dv-workflow/dv-workflow/blob/main/.dw/core/schemas/decision-frontmatter.schema.json",
4
+ "title": "decision-frontmatter",
5
+ "description": "Normalized ADR metadata for decisions-index@v1 (ADR-0017 v1.1). ADRs use two on-disk styles: YAML frontmatter (early, e.g. ADR-0001) OR markdown headers `# ADR-NNNN: Title` + `## Status:`/`## Date:`/`## Deciders:`/`## Related:`. The `dw decision index` parser tolerates both and projects to this normalized shape. New ADRs MAY adopt YAML frontmatter with these keys for lossless extraction.",
6
+ "type": "object",
7
+ "required": ["title", "status"],
8
+ "additionalProperties": true,
9
+ "properties": {
10
+ "title": {
11
+ "type": "string",
12
+ "description": "ADR title (without the `ADR-NNNN:` prefix)"
13
+ },
14
+ "status": {
15
+ "type": "string",
16
+ "enum": ["Proposed", "Accepted", "Deprecated", "Superseded"],
17
+ "description": "Normalized lifecycle status keyword (extracted from a possibly-decorated status line, e.g. 'Accepted (Round 2, ...)')"
18
+ },
19
+ "status_raw": {
20
+ "type": ["string", "null"],
21
+ "description": "Original status string before normalization"
22
+ },
23
+ "date": {
24
+ "type": ["string", "null"],
25
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
26
+ "description": "First ISO date found in the Date field/header (YYYY-MM-DD)"
27
+ },
28
+ "deciders": {
29
+ "type": ["string", "null"],
30
+ "description": "Free-form deciders (names/roles)"
31
+ },
32
+ "related": {
33
+ "type": "array",
34
+ "items": { "type": "string", "pattern": "^ADR-\\d{3,4}$" },
35
+ "uniqueItems": true,
36
+ "description": "Referenced ADRs (from Related field/header + status line); excludes self. Causal-link candidates for org-memory ingest."
37
+ },
38
+ "supersedes": {
39
+ "type": "array",
40
+ "items": { "type": "string", "pattern": "^ADR-\\d{3,4}$" },
41
+ "uniqueItems": true,
42
+ "description": "ADRs this one supersedes"
43
+ },
44
+ "superseded_by": {
45
+ "type": ["string", "null"],
46
+ "pattern": "^ADR-\\d{3,4}$",
47
+ "description": "ADR that supersedes this one (from 'Superseded by ADR-NNNN' or frontmatter)"
48
+ },
49
+ "file": {
50
+ "type": "string",
51
+ "description": "Repo-relative path to the ADR markdown file"
52
+ }
53
+ }
54
+ }
@@ -73,12 +73,12 @@
73
73
  "icon": {
74
74
  "type": ["string", "null"],
75
75
  "maxLength": 8,
76
- "description": "Optional emoji/icon for visual differentiation in portfolio cards (bigokr borrow)."
76
+ "description": "Optional emoji/icon for visual differentiation in portfolio cards."
77
77
  },
78
78
  "cycle": {
79
79
  "type": ["string", "null"],
80
80
  "maxLength": 32,
81
- "description": "Optional temporal grouping label e.g. 'Q1 2026', 'v1.7-cycle', '2026 H1' (bigokr borrow). Portfolio groups goals by cycle."
81
+ "description": "Optional temporal grouping label e.g. 'Q1 2026', 'v1.7-cycle', '2026 H1'. Portfolio groups goals by cycle."
82
82
  }
83
83
  }
84
84
  }
@@ -67,8 +67,8 @@
67
67
  },
68
68
  "schema_version": {
69
69
  "type": "string",
70
- "enum": ["v3.0", "v3.1"],
71
- "description": "Frontmatter schema version. v3.1 adds optional parent_goal_id, contributing_goal_ids, summary per ADR-0010."
70
+ "enum": ["task@v3.0", "task@v3.1", "v3.0", "v3.1"],
71
+ "description": "Frontmatter schema version. Canonical form is namespaced (task@v3.0/task@v3.1) to match the repo-wide @v convention (goal@v1, tasks-index@v1); bare v3.0/v3.1 stay valid for back-compat. v3.1 adds optional parent_goal_id, contributing_goal_ids, summary per ADR-0010 (#22)."
72
72
  },
73
73
  "blockers": {
74
74
  "type": "string",
@@ -8,7 +8,7 @@ owner: {name}
8
8
  depth: quick | standard | thorough
9
9
  related_adr: {ADR-NNNN | none}
10
10
  target_ship: {milestone or TBD}
11
- schema_version: v3.0
11
+ schema_version: task@v3.0
12
12
  blockers: none
13
13
  ---
14
14
 
@@ -79,12 +79,32 @@ genuinely changes.
79
79
 
80
80
  - {Explicit exclusions — prevents scope creep}
81
81
 
82
- ### Success Criteria
82
+ ### Functional Acceptance (handoff scenarios)
83
83
 
84
- Measurable outcomes (not vibes):
84
+ <!--
85
+ Feature behaviour in business / user language — readable by PM/BA/QC WITHOUT code
86
+ (no API/file/variable names). This is the cross-role handoff contract, NOT a full
87
+ test plan (`/dw:test-plan` expands it into QC cases). Per-component acceptance
88
+ lives with each subtask; this is the feature-level scenario layer.
89
+ -->
90
+
91
+ - [ ] {User-visible scenario, e.g., "User resets password and gets a confirmation email"}
92
+ - [ ] {Error / edge scenario, e.g., "Expired reset link shows a clear retry prompt"}
93
+ - [ ] {Measurable gate where relevant, e.g., "p95 latency <100ms"}
85
94
 
86
- - [ ] {Numeric threshold, e.g., "p95 latency <100ms"}
87
- - [ ] {Binary gate, e.g., "passes smoke test"}
95
+ ### Definition of Done (role lens)
96
+
97
+ <!--
98
+ Plain-language sign-off anchored to the outcome. Roles absent from `team.roles`
99
+ sign by proxy / degrade gracefully. These are `- [ ]` checkboxes (NOT status
100
+ markers — those live only in Section 3).
101
+ -->
102
+
103
+ - [ ] 🎯 **Goal** — result advances the linked goal's KR (update the goal)
104
+ - [ ] 📋 **BA** — matches the business expectation
105
+ - [ ] ✅ **QC** — handoff scenarios pass, incl. error cases + no regression
106
+ - [ ] 🛠 **TL** — approach/code sound, no new tech debt, automated checks pass
107
+ - [ ] 🤝 **Handoff** — docs sufficient + changes merged
88
108
 
89
109
  ### Dependencies
90
110
 
@@ -102,12 +122,21 @@ Measurable outcomes (not vibes):
102
122
  <!--
103
123
  SINGLE SOURCE OF TRUTH for subtask status. This is the only section where
104
124
  status markers (⬜🟡✅🔴⏸) are allowed.
125
+
126
+ Section 2 ("Subtasks (in scope)") and this tracker are deliberately separate:
127
+ §2 owns the stable INTENT (name + actions + acceptance), this table owns the
128
+ churning STATUS. They are NOT merged — that separation is what keeps status in
129
+ exactly one place (drift-prevention). The Subtask name is restated here as a
130
+ short label only.
131
+
132
+ `Est` is an optional effort estimate (hours / story-points / t-shirt per
133
+ `estimation_unit`); leave `—` if unused. See `/dw:estimate`.
105
134
  -->
106
135
 
107
- | # | Subtask | Status | Date | Notes |
108
- |---|---------|--------|------|-------|
109
- | ST-1 | ... | ⬜ Pending | — | |
110
- | ST-2 | ... | ⬜ Pending | — | |
136
+ | # | Subtask | Status | Date | Notes | Est |
137
+ |---|---------|--------|------|-------|-----|
138
+ | ST-1 | ... | ⬜ Pending | — | | — |
139
+ | ST-2 | ... | ⬜ Pending | — | | — |
111
140
 
112
141
  Status legend: ⬜ Pending · 🟡 In Progress · ✅ Done · 🔴 Blocked · ⏸ Paused
113
142
 
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > An AI development workflow toolkit for teams using agentic IDEs (Claude Code, Cursor) — from idea to review-ready commits.
4
4
 
5
- **v1.3.6** · `npm install -g dw-kit` · [Docs](docs/README.md) · [Get started](docs/get-started.md) · [Cheatsheet](docs/cheatsheet.md) · [Migration v1.3](MIGRATION-v1.3.md) · [Changelog](CHANGELOG.md)
5
+ **v1.9.0** · `npm install -g dw-kit` · [Docs](docs/README.md) · [Get started](docs/get-started.md) · [Cheatsheet](docs/cheatsheet.md) · [Migration v1.3](MIGRATION-v1.3.md) · [Changelog](CHANGELOG.md)
6
6
 
7
7
  ---
8
8
 
@@ -36,7 +36,10 @@ It’s designed for collaboration (Dev / Tech Lead / QA / PM) and keeps work aud
36
36
 
37
37
  ## Release notes
38
38
 
39
- - **v1.6.0-rc.1 (current)** — **Agent OS Multi-Agent Orchestration** ([ADR-0009](.dw/decisions/0009-agent-os-multi-agent-orchestration.md)): file-based cooperative protocol for Claude Code / Codex / Gemini / human agents to work from same `task.md` without conflict. 6-command CLI `dw agent *` (claim / release / expire / claims / reports / conflicts). Dual ownership model: `subtasks` (semantic) + `write_scope` (filesystem). Lifecycle states + lease (wall+relative) per Round 2. Audit trail via committed `reports/` + `events.jsonl`; claims gitignored ephemeral. Trust = cooperative, post-hoc conflict detection.
39
+ - **v1.9.0 (current, stable)** — substrate-complete for the realtime voice-orchestration Root Goal: persistent CLI-agent sessions, Telegram bridge, multi-workspace registry, browser voice MVP + orchestrator action-execution, multi-agent live debate, and the **DW Event/Document schema + adapter protocol** ([ADR-0011](.dw/decisions/0011-session-runtime-voice-orchestrator.md)/[0014](.dw/decisions/0014-orchestrator-action-execution.md)/[0015](.dw/decisions/0015-multi-agent-live-debate.md)/[0016](.dw/decisions/0016-dw-goal-outcome-pursuit-loop.md)/[0017](.dw/decisions/0017-dw-document-schema-index.md)). Plus team-dogfood fixes: `dw goal status` (#20), deterministic index writers (#21), `task@v3.x` schema_version converge (#22), v3 template Functional Acceptance + role-lens DoD + Est (#24).
40
+ - **v1.8.0** — remote-agent-first substrate: `dw session *`, `dw connector telegram`, `dw workspace *`, `dw voice` (bilingual en+vi, hybrid orchestrator, action-execution with voice confirm).
41
+ - **v1.7.0** — **Goals Layer (OKR-inspired)** ([ADR-0010](.dw/decisions/0010-goals-management-layer.md)): strategic layer above tasks — `dw goal *`, `goals-index@v1`, bidirectional task↔goal linking, portfolio + HTML view.
42
+ - **v1.6.0** — **Agent OS Multi-Agent Orchestration** ([ADR-0009](.dw/decisions/0009-agent-os-multi-agent-orchestration.md)): file-based cooperative protocol for Claude Code / Codex / Gemini / human agents to work from same `task.md` without conflict. 6-command CLI `dw agent *` (claim / release / expire / claims / reports / conflicts). Dual ownership model: `subtasks` (semantic) + `write_scope` (filesystem). Audit trail via committed `reports/` + `events.jsonl`; claims gitignored ephemeral.
40
43
  - **v1.5.0 — Task Docs v3** ([ADR-0008](.dw/decisions/0008-task-docs-format-one-file-timeline.md)): single-file `task.md` replaces v2 `spec.md` + `tracking.md` (drift fixed structurally). 8-command CLI `dw task *` (show / new / view / watch / render / lint / migrate / rotate). Lean HTML dashboard for human dev orchestrator (~10KB, scan-in-5-seconds) + bounded SVG sidecar via `dw-kit-render` v0.2. Live preview server with auto-refresh. Migration script with `--dry-run`, `--diff`, `--rollback`. Lint strict-default blocks drift markers. See [`MIGRATION-v1.5.md`](MIGRATION-v1.5.md).
41
44
  - **v1.4 (in progress)** — Optional **Review Render Pipeline** ([ADR-0007](.dw/decisions/0007-decoupled-review-render-pipeline.md)): `/dw:review --visual` plus a separate `dw-kit-render` package turn findings into SVG + PNG cards for PR comments / Slack / stakeholders. Pure JS + WASM, universal `npm install`, no system deps. See [`docs/review-renderer.md`](docs/review-renderer.md).
42
45
  - **v1.3.6** (2026-05-14) — Supply-Chain Guard upgraded to 3-pillar architecture: OSV snapshot + curated IoC fixture (version-aware, wired into default scan) + **AI-Native NEW-package heuristic** that catches zero-day-ish risk at the AI-edit boundary. See [`CHANGELOG.md#v136--2026-05-14`](CHANGELOG.md#v136--2026-05-14) and [ADR-0006](.dw/decisions/0006-supply-chain-guard-heuristic.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dw-kit",
3
- "version": "1.9.0-rc.1",
3
+ "version": "1.9.0",
4
4
  "description": "AI development workflow toolkit — structured, quality-assured, team-ready. From requirements to dashboard.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,6 +12,7 @@
12
12
  "src/commands/",
13
13
  "src/lib/",
14
14
  ".dw/core/",
15
+ "!.dw/core/PILLARS.md",
15
16
  ".dw/config/",
16
17
  ".dw/adapters/",
17
18
  ".dw/security/",
@@ -50,7 +51,6 @@
50
51
  ".claude/skills/dw-upgrade/",
51
52
  ".claude/templates/",
52
53
  ".claude/settings.json",
53
- "CLAUDE.md",
54
54
  "MIGRATION-v1.3.md",
55
55
  "MIGRATION-v1.5.md",
56
56
  "NOTICE",
@@ -65,7 +65,9 @@
65
65
  "test:renderer": "cd packages/dw-kit-render && npm test",
66
66
  "link": "npm link",
67
67
  "test:e2e-local": "bash scripts/e2e-local-check.sh",
68
- "gen:event-schemas": "node scripts/generate-event-schemas.mjs"
68
+ "gen:event-schemas": "node scripts/generate-event-schemas.mjs",
69
+ "audit:pack": "node scripts/audit-pack.mjs",
70
+ "prepublishOnly": "node scripts/audit-pack.mjs"
69
71
  },
70
72
  "keywords": [
71
73
  "ai",
package/src/cli.mjs CHANGED
@@ -195,6 +195,30 @@ export function run(argv) {
195
195
  await taskSummaryCommand(taskName, opts);
196
196
  });
197
197
 
198
+ taskCmd
199
+ .command('index')
200
+ .description('Regenerate .dw/tasks/tasks-index.json — DW Document Schema + Index v1.0 read-contract (ADR-0017)')
201
+ .option('--json', 'Print the machine-readable index (for external adapters)')
202
+ .option('--check', 'Print the current index without rebuilding')
203
+ .action(async (opts) => {
204
+ const { taskIndexCommand } = await import('./commands/task-index.mjs');
205
+ await taskIndexCommand(opts);
206
+ });
207
+
208
+ const decisionCmd = program
209
+ .command('decision')
210
+ .description('ADR operations (ADR-0017 v1.1 document contract)');
211
+
212
+ decisionCmd
213
+ .command('index')
214
+ .description('Regenerate .dw/decisions/decisions-index.json — decisions-index@v1 read-contract (ADR-0017 v1.1; consumed by external org-memory adapters)')
215
+ .option('--json', 'Print the machine-readable index (for adapters)')
216
+ .option('--check', 'Print the current index without rebuilding')
217
+ .action(async (opts) => {
218
+ const { decisionIndexCommand } = await import('./commands/decision-index.mjs');
219
+ await decisionIndexCommand(opts);
220
+ });
221
+
198
222
  const agentCmd = program
199
223
  .command('agent')
200
224
  .description('Agent OS multi-agent orchestration (ADR-0009): claim · release · renew · expire · claims · reports · conflicts · check-staged · verify');
@@ -495,6 +519,15 @@ export function run(argv) {
495
519
  await goalSetCommand(goalId, opts);
496
520
  });
497
521
 
522
+ goalCmd
523
+ .command('status <goal-id> <new-status>')
524
+ .description('Transition lifecycle status (Draft|Active|Achieved|Pivoted); auto-bumps goal_version + emits goal_status_changed. Use `goal delete` to abandon.')
525
+ .option('--reason <text>', 'Optional reason, recorded in the goal_status_changed event')
526
+ .action(async (goalId, newStatus, opts) => {
527
+ const { goalStatusCommand } = await import('./commands/goal-status.mjs');
528
+ await goalStatusCommand(goalId, newStatus, opts);
529
+ });
530
+
498
531
  goalCmd
499
532
  .command('show [goal-id]')
500
533
  .description('ANSI snapshot of a goal (no arg = list all)')
@@ -0,0 +1,45 @@
1
+ import chalk from 'chalk';
2
+ import { rebuildDecisionIndex, readDecisionIndex, decisionIndexFile } from '../lib/decision-store.mjs';
3
+ import { logEvent } from '../lib/telemetry.mjs';
4
+
5
+ // `dw decision index` — regenerate .dw/decisions/decisions-index.json
6
+ // (DW Document Schema + Index v1.0, ADR-0017 v1.1). Read-contract surface for
7
+ // external org-memory adapters.
8
+ // dw decision index rebuild + human summary
9
+ // dw decision index --json rebuild + print machine-readable index
10
+ // dw decision index --check print current index without rebuilding
11
+ export async function decisionIndexCommand(opts = {}) {
12
+ const rootDir = process.cwd();
13
+ const index = opts.check ? readDecisionIndex(rootDir) : rebuildDecisionIndex(rootDir);
14
+
15
+ if (opts.json) {
16
+ console.log(JSON.stringify(index, null, 2));
17
+ logEvent({ event: 'decision', action: 'index.json', name: Object.keys(index.decisions).length }, rootDir);
18
+ return;
19
+ }
20
+
21
+ const decisions = index.decisions || {};
22
+ const ids = Object.keys(decisions);
23
+ const byStatus = {};
24
+ for (const id of ids) {
25
+ const s = decisions[id].status || 'Proposed';
26
+ byStatus[s] = (byStatus[s] || 0) + 1;
27
+ }
28
+
29
+ console.log();
30
+ console.log(chalk.bold(` decisions-index (${chalk.cyan(index.schema_version)}) — ${ids.length} ADR(s)`));
31
+ console.log(chalk.dim(` ${decisionIndexFile(rootDir)}`));
32
+ console.log();
33
+ if (ids.length === 0) {
34
+ console.log(chalk.dim(' (no ADRs — create one with /dw:decision)'));
35
+ } else {
36
+ for (const [status, n] of Object.entries(byStatus)) {
37
+ console.log(` ${String(n).padStart(3)} ${status}`);
38
+ }
39
+ }
40
+ console.log();
41
+ console.log(chalk.dim(' Tip: `dw decision index --json` for the machine-readable adapter contract.'));
42
+ console.log();
43
+
44
+ logEvent({ event: 'decision', action: opts.check ? 'index.check' : 'index.rebuild', name: ids.length }, rootDir);
45
+ }
@@ -2,7 +2,7 @@ import { readFileSync, writeFileSync, existsSync, rmSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import chalk from 'chalk';
4
4
  import { parseFrontmatter, stringifyFrontmatter } from '../lib/frontmatter.mjs';
5
- import { readGoal, removeIndexEntry, findLinkedTaskIds, goalDir, todayIso, nowUtc } from '../lib/goal-store.mjs';
5
+ import { readGoal, removeIndexEntry, syncIndexEntry, findLinkedTaskIds, goalDir, todayIso, nowUtc } from '../lib/goal-store.mjs';
6
6
  import { logGoalEvent } from '../lib/goal-events.mjs';
7
7
  import { logEvent } from '../lib/telemetry.mjs';
8
8
 
@@ -94,6 +94,8 @@ export async function goalDeleteCommand(goalId, opts = {}) {
94
94
  fm.last_updated = todayIso();
95
95
  const body = content.replace(/^---\n[\s\S]*?\n---\n?/, '');
96
96
  writeFileSync(goal.file, stringifyFrontmatter(fm) + body, 'utf8');
97
+ // Keep goals-index in sync — otherwise `goal lint` flags status drift (#20).
98
+ syncIndexEntry(goalId, rootDir);
97
99
 
98
100
  logGoalEvent({
99
101
  event: 'goal_status_changed',
@@ -5,6 +5,7 @@ import { parseFrontmatter, stringifyFrontmatter } from '../lib/frontmatter.mjs';
5
5
  import { readGoal, syncIndexEntry, todayIso } from '../lib/goal-store.mjs';
6
6
  import { logGoalEvent } from '../lib/goal-events.mjs';
7
7
  import { logEvent } from '../lib/telemetry.mjs';
8
+ import { bareSchemaVersion } from '../lib/task-store.mjs';
8
9
 
9
10
  const TASKS_DIR = '.dw/tasks';
10
11
 
@@ -17,7 +18,8 @@ function writeTaskFrontmatter(taskId, mutator, rootDir = process.cwd()) {
17
18
  const content = readFileSync(file, 'utf8');
18
19
  const fm = parseFrontmatter(content);
19
20
  const updated = mutator({ ...fm });
20
- if (updated.schema_version === 'v3.0') updated.schema_version = 'v3.1';
21
+ // Linking goal fields requires v3.1; bump v3.0 canonical task@v3.1 (#22).
22
+ if (bareSchemaVersion(updated.schema_version) === 'v3.0') updated.schema_version = 'task@v3.1';
21
23
  updated.last_updated = todayIso();
22
24
  const body = content.replace(/^---\n[\s\S]*?\n---\n?/, '');
23
25
  writeFileSync(file, stringifyFrontmatter(updated) + body, 'utf8');
@@ -0,0 +1,95 @@
1
+ import chalk from 'chalk';
2
+ import {
3
+ readGoal,
4
+ updateGoalFrontmatter,
5
+ syncIndexEntry,
6
+ goalStatusEnum,
7
+ todayIso,
8
+ } from '../lib/goal-store.mjs';
9
+ import { logGoalEvent } from '../lib/goal-events.mjs';
10
+ import { logEvent } from '../lib/telemetry.mjs';
11
+
12
+ // Statuses whose entry carries cascade + archive semantics owned by `goal delete`.
13
+ const DELEGATED_STATUSES = new Set(['Abandoned']);
14
+ // Closed states — reopening them is a backward move worth warning about.
15
+ const CLOSED_STATUSES = new Set(['Achieved', 'Abandoned']);
16
+
17
+ // Title-case a user-typed status so `active` matches the PascalCase enum (#20).
18
+ function normalizeStatus(input, allowed) {
19
+ if (!input) return input;
20
+ const exact = allowed.find((s) => s === input);
21
+ if (exact) return exact;
22
+ return allowed.find((s) => s.toLowerCase() === input.toLowerCase()) || input;
23
+ }
24
+
25
+ export async function goalStatusCommand(goalId, newStatusRaw, opts = {}) {
26
+ const rootDir = process.cwd();
27
+
28
+ const allowed = goalStatusEnum(rootDir);
29
+ if (!goalId || !newStatusRaw) {
30
+ console.error(chalk.red(`✗ Usage: dw goal status <goal-id> <${allowed.join('|')}> [--reason "..."]`));
31
+ process.exit(1);
32
+ }
33
+
34
+ const newStatus = normalizeStatus(newStatusRaw, allowed);
35
+ if (!allowed.includes(newStatus)) {
36
+ console.error(chalk.red(`✗ Invalid status "${newStatusRaw}". Allowed: ${allowed.join(', ')}`));
37
+ process.exit(1);
38
+ }
39
+
40
+ // Abandonment is owned by `goal delete` (linked-task cascade + goal_archived
41
+ // event). Redirect rather than create a second, divergent abandon path (#20).
42
+ if (DELEGATED_STATUSES.has(newStatus)) {
43
+ console.error(chalk.red(`✗ Use \`dw goal delete ${goalId}\` to abandon a goal.`));
44
+ console.error(chalk.dim(' It handles linked-task cleanup (--cascade) and emits goal_archived.'));
45
+ process.exit(1);
46
+ }
47
+
48
+ const goal = readGoal(goalId, rootDir);
49
+ if (!goal) {
50
+ console.error(chalk.red(`✗ Goal ${goalId} not found`));
51
+ process.exit(1);
52
+ }
53
+
54
+ const fromStatus = goal.fm.status || 'Draft';
55
+ if (fromStatus === newStatus) {
56
+ console.log(chalk.dim(` ${goalId} is already ${newStatus} — no change.`));
57
+ return;
58
+ }
59
+
60
+ // Warn (don't block) on reopening a closed goal — least surprise per ADR-0001.
61
+ if (CLOSED_STATUSES.has(fromStatus)) {
62
+ console.error(chalk.yellow(` ⚠ reopening a ${fromStatus} goal → ${newStatus}`));
63
+ }
64
+
65
+ const changedBy = process.env.USER || process.env.USERNAME || 'unknown';
66
+ const oldVersion = goal.fm.goal_version || 1;
67
+ const newVersion = oldVersion + 1; // ADR-0010 Q4/C-1: status transitions are material → auto-bump
68
+
69
+ updateGoalFrontmatter(goalId, (fm) => {
70
+ fm.status = newStatus;
71
+ fm.goal_version = newVersion;
72
+ // Leaving any non-Abandoned target must clear a stale archive timestamp
73
+ // (schema: archived_at null = active). Covers reopen from a soft-delete.
74
+ if (fm.archived_at) fm.archived_at = null;
75
+ fm.last_updated = todayIso();
76
+ return fm;
77
+ }, rootDir);
78
+ syncIndexEntry(goalId, rootDir);
79
+
80
+ logGoalEvent({
81
+ event: 'goal_status_changed',
82
+ goal_id: goalId,
83
+ from_status: fromStatus,
84
+ to_status: newStatus,
85
+ changed_by: changedBy,
86
+ ...(opts.reason ? { reason: opts.reason } : {}),
87
+ }, rootDir);
88
+ logEvent({ event: 'goal', action: 'status', name: goalId, from: fromStatus, to: newStatus }, rootDir);
89
+
90
+ console.log();
91
+ console.log(chalk.green(` ✓ ${chalk.bold(goalId)} status ${fromStatus} → ${newStatus} (v${oldVersion} → v${newVersion})`));
92
+ if (opts.reason) console.log(chalk.dim(` Reason: ${opts.reason}`));
93
+ console.log(chalk.dim(' Logged to .dw/events-global.jsonl as goal_status_changed'));
94
+ console.log();
95
+ }
@@ -106,6 +106,26 @@ export async function lintTaskCommand(taskName, opts = {}) {
106
106
  level,
107
107
  }, rootDir);
108
108
 
109
+ // tasks-index freshness advisory (ADR-0017 doc contract) — read-only, never mutates.
110
+ try {
111
+ const { readTaskIndex, listTaskIds, readTask } = await import('../lib/task-store.mjs');
112
+ const index = readTaskIndex(rootDir);
113
+ const idxKeys = new Set(Object.keys(index.tasks || {}));
114
+ const onDisk = listTaskIds(rootDir);
115
+ const missing = onDisk.filter((id) => !idxKeys.has(id));
116
+ let statusDrift = 0;
117
+ for (const id of onDisk) {
118
+ const entry = index.tasks?.[id];
119
+ if (!entry) continue;
120
+ const t = readTask(id, rootDir);
121
+ if (t && (t.fm.status || 'Draft') !== entry.status) statusDrift++;
122
+ }
123
+ if (missing.length || statusDrift) {
124
+ console.log(chalk.dim(` ℹ tasks-index stale (${missing.length} unindexed, ${statusDrift} status drift) — run \`dw task index\``));
125
+ console.log();
126
+ }
127
+ } catch { /* advisory only */ }
128
+
109
129
  if (level === 'strict' && totalErrors > 0) {
110
130
  process.exit(1);
111
131
  }
@@ -0,0 +1,47 @@
1
+ import chalk from 'chalk';
2
+ import { rebuildTaskIndex, readTaskIndex, taskIndexFile } from '../lib/task-store.mjs';
3
+ import { logEvent } from '../lib/telemetry.mjs';
4
+
5
+ // `dw task index` — regenerate .dw/tasks/tasks-index.json (DW Document Schema
6
+ // + Index v1.0, ADR-0017). Read-only contract surface for adapters.
7
+ // dw task index rebuild + human summary
8
+ // dw task index --json rebuild + print machine-readable index
9
+ // dw task index --check print current index without rebuilding (--json optional)
10
+ export async function taskIndexCommand(opts = {}) {
11
+ const rootDir = process.cwd();
12
+ const index = opts.check ? readTaskIndex(rootDir) : rebuildTaskIndex(rootDir);
13
+
14
+ if (opts.json) {
15
+ console.log(JSON.stringify(index, null, 2));
16
+ logEvent({ event: 'task', action: 'index.json', name: Object.keys(index.tasks).length }, rootDir);
17
+ return;
18
+ }
19
+
20
+ const tasks = index.tasks || {};
21
+ const ids = Object.keys(tasks);
22
+ const byStatus = {};
23
+ for (const id of ids) {
24
+ const s = tasks[id].status || 'Draft';
25
+ byStatus[s] = (byStatus[s] || 0) + 1;
26
+ }
27
+
28
+ console.log();
29
+ console.log(chalk.bold(` tasks-index (${chalk.cyan(index.schema_version)}) — ${ids.length} task(s)`));
30
+ console.log(chalk.dim(` ${taskIndexFile(rootDir)}`));
31
+ console.log();
32
+ if (ids.length === 0) {
33
+ console.log(chalk.dim(' (no tasks — scaffold one with `dw task new <name>`)'));
34
+ } else {
35
+ for (const [status, n] of Object.entries(byStatus)) {
36
+ console.log(` ${String(n).padStart(3)} ${status}`);
37
+ }
38
+ const linked = ids.filter((id) => tasks[id].parent_goal_id).length;
39
+ console.log();
40
+ console.log(chalk.dim(` ${linked}/${ids.length} linked to a parent goal`));
41
+ }
42
+ console.log();
43
+ console.log(chalk.dim(' Tip: `dw task index --json` for the machine-readable adapter contract.'));
44
+ console.log();
45
+
46
+ logEvent({ event: 'task', action: opts.check ? 'index.check' : 'index.rebuild', name: ids.length }, rootDir);
47
+ }
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import chalk from 'chalk';
4
4
  import { parseFrontmatter, stringifyFrontmatter } from '../lib/frontmatter.mjs';
5
5
  import { logEvent } from '../lib/telemetry.mjs';
6
+ import { bareSchemaVersion } from '../lib/task-store.mjs';
6
7
 
7
8
  const TASKS_DIR = '.dw/tasks';
8
9
 
@@ -72,7 +73,7 @@ function mergeFrontmatter(specFm, trackingFm) {
72
73
  depth: specFm.depth || 'standard',
73
74
  related_adr: String(specFm.related_adr || 'none').match(/^(ADR-\d{4}|none)$/) ? specFm.related_adr : 'none',
74
75
  target_ship: specFm.target_ship || 'TBD',
75
- schema_version: 'v3.0',
76
+ schema_version: 'task@v3.0',
76
77
  blockers: trackingFm.blockers || 'none',
77
78
  };
78
79
  }
@@ -308,7 +309,7 @@ function findV3Tasks(rootDir, targetSchemaVersion = 'v3.0') {
308
309
  const taskFile = join(e.path, 'task.md');
309
310
  if (!existsSync(taskFile)) return false;
310
311
  const fm = parseFrontmatter(readFileSync(taskFile, 'utf8'));
311
- return fm.schema_version === targetSchemaVersion;
312
+ return bareSchemaVersion(fm.schema_version) === bareSchemaVersion(targetSchemaVersion);
312
313
  } catch { return false; }
313
314
  });
314
315
  }
@@ -321,16 +322,16 @@ async function migrateOneToV31(taskDir, opts) {
321
322
  }
322
323
  const content = readFileSync(taskFile, 'utf8');
323
324
  const fm = parseFrontmatter(content);
324
- if (fm.schema_version === 'v3.1') {
325
+ if (bareSchemaVersion(fm.schema_version) === 'v3.1') {
325
326
  console.log(chalk.dim(` · ${taskDir} — already v3.1`));
326
327
  return { ok: true, noop: true };
327
328
  }
328
- if (fm.schema_version !== 'v3.0') {
329
+ if (bareSchemaVersion(fm.schema_version) !== 'v3.0') {
329
330
  console.log(chalk.yellow(` ⚠ ${taskDir} — schema_version=${fm.schema_version || 'missing'}, expected v3.0 — skipping`));
330
331
  return { ok: false, skipped: true };
331
332
  }
332
333
 
333
- const updated = { ...fm, schema_version: 'v3.1' };
334
+ const updated = { ...fm, schema_version: 'task@v3.1' };
334
335
  if (!('parent_goal_id' in updated)) updated.parent_goal_id = null;
335
336
  if (!('contributing_goal_ids' in updated)) updated.contributing_goal_ids = [];
336
337
  if (!('summary' in updated)) updated.summary = null;
@@ -457,6 +458,16 @@ export async function taskMigrateCommand(taskName, opts = {}) {
457
458
  dry_run: !!opts.dryRun,
458
459
  }, rootDir);
459
460
 
461
+ // Keep tasks-index@v1 current after a real migration (ADR-0017 doc contract).
462
+ if (!opts.dryRun && !opts.diff) {
463
+ try {
464
+ const { syncTaskIndexEntry } = await import('../lib/task-store.mjs');
465
+ for (const r of results) {
466
+ if (r.ok && !r.noop && !r.skipped) syncTaskIndexEntry(r.name, rootDir);
467
+ }
468
+ } catch { /* best-effort; `dw task index` rebuilds authoritatively */ }
469
+ }
470
+
460
471
  console.log();
461
472
  if (opts.dryRun) {
462
473
  console.log(chalk.cyan(` Dry-run complete. Re-run without --dry-run to apply.`));
@@ -76,6 +76,12 @@ export async function taskNewCommand(taskName, opts = {}) {
76
76
  const target = join(taskDir, 'task.md');
77
77
  writeFileSync(target, filled, 'utf8');
78
78
 
79
+ // Keep tasks-index@v1 current on creation (ADR-0017 doc contract).
80
+ try {
81
+ const { syncTaskIndexEntry } = await import('../lib/task-store.mjs');
82
+ syncTaskIndexEntry(slug, rootDir);
83
+ } catch { /* index is best-effort here; `dw task index` rebuilds authoritatively */ }
84
+
79
85
  logEvent({ event: 'task', action: 'new', name: slug, depth }, rootDir);
80
86
 
81
87
  console.log();
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import chalk from 'chalk';
4
4
  import { parseFrontmatter, stringifyFrontmatter } from '../lib/frontmatter.mjs';
5
5
  import { logEvent } from '../lib/telemetry.mjs';
6
+ import { bareSchemaVersion } from '../lib/task-store.mjs';
6
7
 
7
8
  const TASKS_DIR = '.dw/tasks';
8
9
  const MAX_SUMMARY_CHARS = 1000;
@@ -38,7 +39,7 @@ export async function taskSummaryCommand(taskName, opts = {}) {
38
39
  process.exit(1);
39
40
  }
40
41
  const updated = { ...fm, summary: opts.write || null, last_updated: todayIso() };
41
- if (updated.schema_version === 'v3.0') updated.schema_version = 'v3.1';
42
+ if (bareSchemaVersion(updated.schema_version) === 'v3.0') updated.schema_version = 'task@v3.1';
42
43
  const body = content.replace(/^---\n[\s\S]*?\n---\n?/, '');
43
44
  writeFileSync(file, stringifyFrontmatter(updated) + body, 'utf8');
44
45
 
@@ -46,8 +47,8 @@ export async function taskSummaryCommand(taskName, opts = {}) {
46
47
 
47
48
  console.log();
48
49
  console.log(chalk.green(` ✓ Summary updated for ${chalk.bold(taskName)} (${(opts.write || '').length}/${MAX_SUMMARY_CHARS} chars)`));
49
- if (fm.schema_version === 'v3.0') {
50
- console.log(chalk.dim(` Auto-bumped schema_version v3.0 → v3.1`));
50
+ if (bareSchemaVersion(fm.schema_version) === 'v3.0') {
51
+ console.log(chalk.dim(` Auto-bumped schema_version v3.0 → task@v3.1`));
51
52
  }
52
53
  console.log();
53
54
  return;
@@ -0,0 +1,146 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { parseFrontmatter } from './frontmatter.mjs';
4
+
5
+ // DW Document Schema + Index v1.0 — decisions (ADR) half (ADR-0017 v1.1).
6
+ // ADRs use TWO metadata styles in the wild:
7
+ // - YAML frontmatter (early ADRs, e.g. ADR-0001)
8
+ // - markdown headers `# ADR-NNNN: Title` + `## Status:` / `## Date:` / ...
9
+ // The parser tolerates both so external adapters consume one normalized index.
10
+
11
+ const DECISIONS_DIR = '.dw/decisions';
12
+ const INDEX_FILE = '.dw/decisions/decisions-index.json';
13
+ const SCHEMA_VERSION = 'decisions-index@v1';
14
+ const STATUS_KEYWORDS = ['Proposed', 'Accepted', 'Deprecated', 'Superseded'];
15
+
16
+ export function decisionsDir(rootDir = process.cwd()) {
17
+ return join(rootDir, DECISIONS_DIR);
18
+ }
19
+
20
+ export function decisionIndexFile(rootDir = process.cwd()) {
21
+ return join(rootDir, INDEX_FILE);
22
+ }
23
+
24
+ function nowUtc() {
25
+ return new Date().toISOString().replace(/\.\d+Z$/, 'Z');
26
+ }
27
+
28
+ function emptyIndex() {
29
+ return { schema_version: SCHEMA_VERSION, last_updated: nowUtc(), decisions: {} };
30
+ }
31
+
32
+ export function readDecisionIndex(rootDir = process.cwd()) {
33
+ const file = decisionIndexFile(rootDir);
34
+ if (!existsSync(file)) return emptyIndex();
35
+ try {
36
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
37
+ if (!parsed || typeof parsed !== 'object' || !parsed.decisions) return emptyIndex();
38
+ return parsed;
39
+ } catch {
40
+ return emptyIndex();
41
+ }
42
+ }
43
+
44
+ export function writeDecisionIndex(index, rootDir = process.cwd()) {
45
+ const file = decisionIndexFile(rootDir);
46
+ const dir = dirname(file);
47
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
48
+ const updated = { ...index, schema_version: SCHEMA_VERSION, last_updated: nowUtc() };
49
+ writeFileSync(file, JSON.stringify(updated, null, 2) + '\n', 'utf8');
50
+ }
51
+
52
+ // .dw/decisions/NNNN-slug.md → { id: 'ADR-NNNN', num: 'NNNN', file }
53
+ export function listDecisionFiles(rootDir = process.cwd()) {
54
+ const dir = decisionsDir(rootDir);
55
+ if (!existsSync(dir)) return [];
56
+ const out = [];
57
+ for (const name of readdirSync(dir)) {
58
+ if (!name.endsWith('.md')) continue;
59
+ if (name.startsWith('_')) continue; // _template.md
60
+ const m = name.match(/^(\d{3,4})-/);
61
+ if (!m) continue;
62
+ out.push({ id: `ADR-${m[1]}`, num: m[1], file: join(dir, name), rel: `${DECISIONS_DIR}/${name}` });
63
+ }
64
+ return out;
65
+ }
66
+
67
+ function firstDate(s) {
68
+ if (!s) return null;
69
+ const m = String(s).match(/\d{4}-\d{2}-\d{2}/);
70
+ return m ? m[0] : null;
71
+ }
72
+
73
+ function normalizeStatus(raw) {
74
+ if (!raw) return { status: 'Proposed', superseded_by: null };
75
+ const text = String(raw);
76
+ const kw = STATUS_KEYWORDS.find((k) => new RegExp(`^\\s*${k}`, 'i').test(text));
77
+ const status = kw || (/superseded/i.test(text) ? 'Superseded' : 'Proposed');
78
+ const sb = text.match(/superseded by\s+(ADR-\d{3,4})/i);
79
+ return { status, superseded_by: sb ? sb[1] : null };
80
+ }
81
+
82
+ function adrRefs(s, selfId) {
83
+ if (!s) return [];
84
+ const refs = [...String(s).matchAll(/ADR-\d{3,4}/g)].map((m) => m[0]);
85
+ return [...new Set(refs)].filter((r) => r !== selfId);
86
+ }
87
+
88
+ function mdHeader(content, label) {
89
+ const m = content.match(new RegExp(`^##\\s+${label}:\\s*(.+)$`, 'm'));
90
+ return m ? m[1].trim() : null;
91
+ }
92
+
93
+ // Tolerant extractor: YAML frontmatter wins; falls back to markdown headers.
94
+ export function parseDecisionMeta(id, content) {
95
+ const fm = parseFrontmatter(content);
96
+ const hasFm = fm && (fm.status || fm.title || fm.id);
97
+
98
+ const title = (hasFm && fm.title)
99
+ || (content.match(/^#\s+ADR-\d{3,4}:\s*(.+?)\s*$/m)?.[1])
100
+ || (content.match(/^#\s+(.+?)\s*$/m)?.[1])
101
+ || id;
102
+
103
+ const rawStatus = (hasFm && fm.status) || mdHeader(content, 'Status');
104
+ const { status, superseded_by } = normalizeStatus(rawStatus);
105
+
106
+ const date = firstDate((hasFm && fm.date) || mdHeader(content, 'Date'));
107
+ const deciders = ((hasFm && fm.deciders) || mdHeader(content, 'Deciders') || null);
108
+
109
+ const relatedSrc = `${(hasFm && fm.related) || mdHeader(content, 'Related') || ''} ${rawStatus || ''}`;
110
+ const related = adrRefs(relatedSrc, id);
111
+ const supersedes = hasFm && fm.supersedes && fm.supersedes !== 'null'
112
+ ? adrRefs(String(fm.supersedes), id)
113
+ : [];
114
+
115
+ return {
116
+ title: String(title).trim(),
117
+ status,
118
+ status_raw: rawStatus ? String(rawStatus).trim() : null,
119
+ date,
120
+ deciders: deciders ? String(deciders).trim() : null,
121
+ related,
122
+ supersedes,
123
+ superseded_by: superseded_by || (hasFm && fm['superseded-by'] && fm['superseded-by'] !== 'null' ? String(fm['superseded-by']) : null),
124
+ };
125
+ }
126
+
127
+ function decisionEntry(file, id, rootDir) {
128
+ if (!existsSync(file)) return null;
129
+ const content = readFileSync(file, 'utf8');
130
+ const meta = parseDecisionMeta(id, content);
131
+ return { ...meta, file: `${DECISIONS_DIR}/${file.split(/[\\/]/).pop()}` };
132
+ }
133
+
134
+ export function rebuildDecisionIndex(rootDir = process.cwd()) {
135
+ const index = emptyIndex();
136
+ for (const d of listDecisionFiles(rootDir)) {
137
+ const entry = decisionEntry(d.file, d.id, rootDir);
138
+ if (entry) index.decisions[d.id] = entry;
139
+ }
140
+ const existing = readDecisionIndex(rootDir);
141
+ if (JSON.stringify(existing.decisions) === JSON.stringify(index.decisions)) {
142
+ return existing;
143
+ }
144
+ writeDecisionIndex(index, rootDir);
145
+ return index;
146
+ }
@@ -38,15 +38,35 @@ export function readGoalIndex(rootDir = process.cwd()) {
38
38
  }
39
39
  }
40
40
 
41
+ // Stable key order so the same goal set serializes byte-identically regardless
42
+ // of writer path (syncIndexEntry appends; rebuilds use readdirSync order) (#21).
43
+ function sortGoals(goals) {
44
+ const sorted = {};
45
+ for (const id of Object.keys(goals || {}).sort()) sorted[id] = goals[id];
46
+ return sorted;
47
+ }
48
+
41
49
  export function writeGoalIndex(index, rootDir = process.cwd()) {
42
50
  const file = goalIndexFile(rootDir);
43
51
  const dir = dirname(file);
44
52
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
53
+ const goals = sortGoals(index.goals);
54
+ // Skip the write when the goal set is byte-identical to disk: preserves the
55
+ // on-disk last_updated (freshness signal, ADR-0017/0018) and yields zero diff.
56
+ const prior = readGoalIndex(rootDir);
57
+ if (existsSync(file) && JSON.stringify(sortGoals(prior.goals)) === JSON.stringify(goals)) {
58
+ return prior;
59
+ }
60
+ // Spread `index` first so non-payload top-level fields ($schema_note,
61
+ // schema_version) and their order are preserved; override goals (sorted) +
62
+ // last_updated in place.
45
63
  const updated = {
46
64
  ...index,
47
65
  last_updated: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
66
+ goals,
48
67
  };
49
68
  writeFileSync(file, JSON.stringify(updated, null, 2) + '\n', 'utf8');
69
+ return updated;
50
70
  }
51
71
 
52
72
  export function readGoal(goalId, rootDir = process.cwd()) {
@@ -181,7 +201,9 @@ export function findLinkedTaskIds(goalId, rootDir = process.cwd()) {
181
201
  else if (Array.isArray(fm.contributing_goal_ids) && fm.contributing_goal_ids.includes(goalId)) linked.push(entry);
182
202
  } catch { /* skip */ }
183
203
  }
184
- return linked;
204
+ // Sort for determinism — readdirSync order is filesystem-dependent and would
205
+ // otherwise churn linked_task_ids in the committed index (#21).
206
+ return linked.sort();
185
207
  }
186
208
 
187
209
  function extractTitle(content) {
@@ -200,3 +222,20 @@ export function nowUtc() {
200
222
  export function validateGoalId(goalId) {
201
223
  return /^G-[A-Za-z0-9](?:[A-Za-z0-9.-]{0,31}[A-Za-z0-9])?$/.test(goalId);
202
224
  }
225
+
226
+ // Single source for the goal lifecycle status enum (#20). Reads the project's
227
+ // schema (consumer-vendored via `dw init`) and falls back to the canonical set
228
+ // when absent, so the status command works in a stripped install.
229
+ const FALLBACK_GOAL_STATUSES = ['Draft', 'Active', 'Achieved', 'Abandoned', 'Pivoted'];
230
+
231
+ export function goalStatusEnum(rootDir = process.cwd()) {
232
+ const schemaFile = join(rootDir, '.dw/core/schemas/goal-frontmatter.schema.json');
233
+ if (!existsSync(schemaFile)) return [...FALLBACK_GOAL_STATUSES];
234
+ try {
235
+ const schema = JSON.parse(readFileSync(schemaFile, 'utf8'));
236
+ const enumVals = schema?.properties?.status?.enum;
237
+ return Array.isArray(enumVals) && enumVals.length ? enumVals : [...FALLBACK_GOAL_STATUSES];
238
+ } catch {
239
+ return [...FALLBACK_GOAL_STATUSES];
240
+ }
241
+ }
@@ -44,10 +44,19 @@ export function lintTimeline(taskDir, opts = {}) {
44
44
  const result = validateFrontmatter(fm, schema);
45
45
  if (!result.ok) {
46
46
  for (const e of result.errors) {
47
+ let message = `${e.path}: ${e.message}${e.keyword ? ` (${e.keyword})` : ''}`;
48
+ // #22: the raw ajv enum error for schema_version is opaque. Teach the
49
+ // allowed values + the namespaced-vs-bare convention right at the error.
50
+ if (e.path === '/schema_version' && e.keyword === 'enum') {
51
+ const allowed = (e.params?.allowedValues || []).map((v) => `"${v}"`).join(', ');
52
+ message = `schema_version: must be one of ${allowed}. Canonical is namespaced `
53
+ + `(task@v3.x) to match goal@v1 / tasks-index@v1; bare v3.x stays valid for `
54
+ + `back-compat. See docs/specs/dw-document-schema-v1.0.md §2.1.`;
55
+ }
47
56
  violations.push({
48
57
  severity: 'error',
49
58
  rule: 'frontmatter-schema',
50
- message: `${e.path}: ${e.message}${e.keyword ? ` (${e.keyword})` : ''}`,
59
+ message,
51
60
  file: timelineFile,
52
61
  });
53
62
  }
@@ -0,0 +1,164 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { parseFrontmatter } from './frontmatter.mjs';
4
+
5
+ // DW Document Schema + Index v1.0 (ADR-0017) — task half.
6
+ // Mirrors goal-store.mjs / goals-index@v1: a committed manifest so external
7
+ // adapters read task state from one O(1) file instead of reverse-engineering
8
+ // every task.md.
9
+
10
+ const TASKS_DIR = '.dw/tasks';
11
+ const INDEX_FILE = '.dw/tasks/tasks-index.json';
12
+ const SCHEMA_VERSION = 'tasks-index@v1';
13
+
14
+ export function tasksDir(rootDir = process.cwd()) {
15
+ return join(rootDir, TASKS_DIR);
16
+ }
17
+
18
+ export function taskFile(taskId, rootDir = process.cwd()) {
19
+ return join(rootDir, TASKS_DIR, taskId, 'task.md');
20
+ }
21
+
22
+ export function taskIndexFile(rootDir = process.cwd()) {
23
+ return join(rootDir, INDEX_FILE);
24
+ }
25
+
26
+ function nowUtc() {
27
+ return new Date().toISOString().replace(/\.\d+Z$/, 'Z');
28
+ }
29
+
30
+ // Stable key order so the same task set always serializes byte-identically,
31
+ // regardless of writer path (syncTaskIndexEntry appends; rebuildTaskIndex uses
32
+ // readdirSync order, which is filesystem-dependent). Without this the two paths
33
+ // disagree and the post-commit hook refresh churns the file (#21).
34
+ function sortTasks(tasks) {
35
+ const sorted = {};
36
+ for (const id of Object.keys(tasks || {}).sort()) sorted[id] = tasks[id];
37
+ return sorted;
38
+ }
39
+
40
+ // schema_version naming (#22): task frontmatter accepts both the legacy bare
41
+ // form (v3.0/v3.1) and the canonical namespaced form (task@v3.0/task@v3.1) that
42
+ // matches the repo-wide @v convention (goal@v1, *-index@v1). New writes emit the
43
+ // namespaced form; readers tolerate both. `bareSchemaVersion` strips the prefix
44
+ // for version-comparison logic; `canonicalSchemaVersion` adds it for emission.
45
+ export function bareSchemaVersion(v) {
46
+ if (!v || typeof v !== 'string') return v;
47
+ return v.startsWith('task@') ? v.slice('task@'.length) : v;
48
+ }
49
+
50
+ export function canonicalSchemaVersion(v) {
51
+ if (!v || typeof v !== 'string') return v;
52
+ return v.startsWith('task@') ? v : `task@${v}`;
53
+ }
54
+
55
+ function emptyIndex() {
56
+ return { schema_version: SCHEMA_VERSION, last_updated: nowUtc(), tasks: {} };
57
+ }
58
+
59
+ export function readTaskIndex(rootDir = process.cwd()) {
60
+ const file = taskIndexFile(rootDir);
61
+ if (!existsSync(file)) return emptyIndex();
62
+ try {
63
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
64
+ if (!parsed || typeof parsed !== 'object' || !parsed.tasks) return emptyIndex();
65
+ return parsed;
66
+ } catch {
67
+ return emptyIndex();
68
+ }
69
+ }
70
+
71
+ export function writeTaskIndex(index, rootDir = process.cwd()) {
72
+ const file = taskIndexFile(rootDir);
73
+ const dir = dirname(file);
74
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
75
+ const tasks = sortTasks(index.tasks);
76
+ // Skip the write entirely when the task set is byte-identical to disk: this
77
+ // keeps the on-disk `last_updated` (the spec freshness signal, ADR-0017) and
78
+ // guarantees zero git diff. `last_updated` advances only on a real change.
79
+ const prior = readTaskIndex(rootDir);
80
+ if (existsSync(file) && JSON.stringify(sortTasks(prior.tasks)) === JSON.stringify(tasks)) {
81
+ return prior;
82
+ }
83
+ const updated = { schema_version: SCHEMA_VERSION, last_updated: nowUtc(), tasks };
84
+ writeFileSync(file, JSON.stringify(updated, null, 2) + '\n', 'utf8');
85
+ return updated;
86
+ }
87
+
88
+ export function listTaskIds(rootDir = process.cwd()) {
89
+ const dir = tasksDir(rootDir);
90
+ if (!existsSync(dir)) return [];
91
+ return readdirSync(dir).filter((entry) => {
92
+ if (entry === 'archive' || entry === 'ACTIVE.md') return false;
93
+ try {
94
+ const path = join(dir, entry);
95
+ return statSync(path).isDirectory() && existsSync(join(path, 'task.md'));
96
+ } catch {
97
+ return false;
98
+ }
99
+ });
100
+ }
101
+
102
+ export function readTask(taskId, rootDir = process.cwd()) {
103
+ const file = taskFile(taskId, rootDir);
104
+ if (!existsSync(file)) return null;
105
+ const content = readFileSync(file, 'utf8');
106
+ return { fm: parseFrontmatter(content), content, file };
107
+ }
108
+
109
+ export function extractTaskTitle(content) {
110
+ const m = content.match(/^#\s+(?:Timeline:|Spec:)?\s*(.+?)\s*$/m);
111
+ return m ? m[1].trim() : null;
112
+ }
113
+
114
+ // doc@v1 task projection — consumer-facing fields only (no body text).
115
+ function taskEntry(taskId, rootDir) {
116
+ const task = readTask(taskId, rootDir);
117
+ if (!task) return null;
118
+ const fm = task.fm || {};
119
+ return {
120
+ title: extractTaskTitle(task.content) || taskId,
121
+ status: fm.status || 'Draft',
122
+ phase: fm.phase || null,
123
+ owner: fm.owner || 'unknown',
124
+ depth: fm.depth || null,
125
+ related_adr: fm.related_adr || null,
126
+ target_ship: fm.target_ship || null,
127
+ parent_goal_id: fm.parent_goal_id && fm.parent_goal_id !== 'none' ? fm.parent_goal_id : null,
128
+ contributing_goal_ids: Array.isArray(fm.contributing_goal_ids) ? fm.contributing_goal_ids : [],
129
+ summary: fm.summary || null,
130
+ schema_version: fm.schema_version || null,
131
+ last_updated: fm.last_updated || new Date().toISOString().slice(0, 10),
132
+ };
133
+ }
134
+
135
+ export function syncTaskIndexEntry(taskId, rootDir = process.cwd()) {
136
+ const entry = taskEntry(taskId, rootDir);
137
+ if (!entry) return null;
138
+ const index = readTaskIndex(rootDir);
139
+ index.tasks[taskId] = entry;
140
+ writeTaskIndex(index, rootDir);
141
+ return entry;
142
+ }
143
+
144
+ export function removeTaskIndexEntry(taskId, rootDir = process.cwd()) {
145
+ const index = readTaskIndex(rootDir);
146
+ if (index.tasks[taskId]) {
147
+ delete index.tasks[taskId];
148
+ writeTaskIndex(index, rootDir);
149
+ return true;
150
+ }
151
+ return false;
152
+ }
153
+
154
+ // Authoritative full rebuild — scans .dw/tasks/ and rewrites the index.
155
+ // Idempotency (sorted keys + skip-when-identical) lives in writeTaskIndex, so an
156
+ // unchanged task set produces zero git diff and the stop-check refresh is safe.
157
+ export function rebuildTaskIndex(rootDir = process.cwd()) {
158
+ const index = emptyIndex();
159
+ for (const taskId of listTaskIds(rootDir)) {
160
+ const entry = taskEntry(taskId, rootDir);
161
+ if (entry) index.tasks[taskId] = entry;
162
+ }
163
+ return writeTaskIndex(index, rootDir);
164
+ }
@@ -1,122 +0,0 @@
1
- # dw-kit v2.0 — 5 Pillar Architecture
2
-
3
- dw-kit v2.0 positions itself as a **Context-First SDLC Governance Layer** — not a prescriptive workflow engine. AI drives execution; dw-kit provides guardrails, context, and decision trail.
4
-
5
- **Framing inversion:** `prescriptive workflow → descriptive governance`. Moat is organizational memory compounding over time — something IDE tools (Cursor, Copilot) structurally cannot own because they are session-scoped.
6
-
7
- ---
8
-
9
- ## Pillar 1: GUARDS — Block unsafe actions
10
-
11
- **Role:** Non-negotiable safety boundaries. Enforced by hooks, zero discretion.
12
-
13
- **Components:**
14
- - `.claude/hooks/privacy-block.sh` — Prevent reading `.env*`, `credentials*`, `*.pem`, key files
15
- - `.claude/hooks/pre-commit-gate.sh` — Quality checks + sensitive data scan before commits
16
-
17
- **Obsolescence test:** AI gets smarter → safety becomes MORE important (velocity × risk). These hooks never obsolete.
18
-
19
- **Team impact:** Prevents accidents at individual dev level, reduces incident count team-wide.
20
-
21
- ---
22
-
23
- ## Pillar 2: SURFACES — Make state visible
24
-
25
- **Role:** Shared team context. Human and AI both read.
26
-
27
- **Components:**
28
- - `.dw/tasks/ACTIVE.md` — Auto-generated index of active tasks (TechLead cat to see team state)
29
- - `.dw/context/project-map.md` — Module structure and boundaries
30
- - `.dw/context/modules/*.md` — Per-module documentation
31
- - `CLAUDE.md` + `.claude/rules/dw.md` — Auto-injected conventions
32
-
33
- **Obsolescence test:** AI gets smarter → teams coordinate more ambitiously → surfaces become MORE load-bearing.
34
-
35
- **Team impact:** New dev onboards from surfaces alone. TechLead audits without asking devs "what are you doing?"
36
-
37
- ---
38
-
39
- ## Pillar 3: RECORDS — Capture decisions
40
-
41
- **Role:** Organizational memory. The WHY behind architectural choices.
42
-
43
- **Components:**
44
- - `.dw/decisions/{NNNN}-{title}.md` — ADRs with structured YAML header
45
- - `/dw:decision` skill — Interactive ADR wizard
46
- - Status tracking: `Proposed | Accepted | Deprecated | Superseded by ADR-{NNNN}`
47
-
48
- **Obsolescence test:** AI gets smarter → needs WHY context to avoid technically-correct but strategically-wrong decisions. ADRs become MORE valuable.
49
-
50
- **Team impact:** Replaces scattered Slack threads. 6-month-old devs find decision trail. New hires understand architecture fast.
51
-
52
- **Unique moat:** IDE tools (Cursor, Copilot) structurally can't own this — they're session-scoped, ADRs are cross-session artifacts.
53
-
54
- ---
55
-
56
- ## Pillar 4: BRIDGES — Connect across sessions
57
-
58
- **Role:** Continuity over time. Handle the "long chat → no handoff → team lost context" problem.
59
-
60
- **Components:**
61
- - `.dw/tasks/{task}/tracking.md` — Mutable progress log with friction journal
62
- - Stop hook auto-handoff — Appends session summary to tracking.md on uncommitted changes
63
- - (v2.0+) Living docs detection — Flag when code diverges from docs
64
-
65
- **Obsolescence test:** AI sessions remain ephemeral regardless of capability. Bridges always needed.
66
-
67
- **Team impact:** Pick-up-where-left-off works across devs and across sessions. TechLead sees real velocity from actual logs, not status meeting summaries.
68
-
69
- ---
70
-
71
- ## Pillar 5: TUNES — Behavioral knobs
72
-
73
- **Role:** Team/solo customization. Governs how Pillars 1-4 behave.
74
-
75
- **Components:**
76
- - `.dw/config/dw.config.yml` — Master config (depth, roles, flags)
77
- - `.dw/config/dw.config.local.yml` — Machine-specific override (gitignored)
78
- - Presets: `solo` · `team` · `enterprise`
79
- - Depth routing: `quick` · `standard` · `thorough`
80
- - Role system: `dev` · `techlead` · `ba` · `qc` · `pm`
81
-
82
- **Obsolescence test:** Team size and preferences always vary. Config always needed.
83
-
84
- **Team impact:** Same dw-kit serves vibe coder (preset solo) and enterprise team (preset enterprise) with same core.
85
-
86
- ---
87
-
88
- ## Pillar Cross-References
89
-
90
- | Question | Answer location |
91
- |----------|----------------|
92
- | "Is this safe to do?" | Pillar 1 (Guards) — hooks block |
93
- | "What's everyone working on?" | Pillar 2 (Surfaces) — ACTIVE.md |
94
- | "Why did we choose X?" | Pillar 3 (Records) — ADRs |
95
- | "Where were we yesterday?" | Pillar 4 (Bridges) — tracking.md |
96
- | "How does our team work?" | Pillar 5 (Tunes) — config + presets |
97
-
98
- ## Design Principles
99
-
100
- 1. **Descriptive, not prescriptive** — AI chooses approach; dw-kit supplies context + safety
101
- 2. **Obsolescence-aware** — Every feature passes "more valuable if AI smarter?" test
102
- 3. **Dual audience** — Solo + team from same core, different defaults
103
- 4. **Data-driven evolution** — Telemetry guides cut decisions, not gut-feel
104
- 5. **Escape hatches** — `--no-dw`, `legacy_features: true`, `DW_NO_TELEMETRY=1`
105
-
106
- ## Future: Pillar 6 — JANITORS (deferred to post-v2.0)
107
-
108
- **Status:** Draft, deferred — see `.dw/decisions/0003-pillar-6-janitors.md`
109
-
110
- **Role:** Reactive cleanup of AI-generated waste. Current 5 pillars are *preventive* (govern what goes in); Janitors governs *what stays*.
111
-
112
- **Rationale:** When 99% of code is AI-generated, prevention alone cannot scale. Inspired by urban waste management — cities use multi-tier systems (sort → collect → recycle → regulate), not just "don't litter."
113
-
114
- **Revisit:** After v2.0 GA (post 2026-08-15), based on real-world friction data.
115
-
116
- ## Versioning
117
-
118
- - **v1.3** — Foundation (new format, scaffolding, telemetry)
119
- - **v1.4** — Data-driven cuts based on telemetry evidence
120
- - **v2.0** — Unified release with full 5-pillar integration
121
- - **v2.1+** — Advanced features (auto-handoff LLM, cross-repo, dashboard UI)
122
- - **v2.2+** — Janitors pillar (if validated by v2.0 friction data)
package/CLAUDE.md DELETED
@@ -1,44 +0,0 @@
1
- # dw-kit (repo)
2
-
3
- Workflow toolkit codebase. Rules live in `.claude/rules/` (auto-loaded).
4
-
5
- **v2.0 direction:** Context-First SDLC Governance Layer (5 pillars — see `.dw/core/PILLARS.md`)
6
- **Current:** **v1.8.0-rc.1** (2026-05-25) — 4/5 phase substrate for [G-rgoal-realtime-orch](.dw/goals/G-rgoal-realtime-orch/goal.md) voice-meeting Root Goal: persistent CLI-agent session runtime (`dw session *`), Telegram chat bridge (`dw connector telegram setup`) with 1-command interactive wizard, multi-workspace registry (`dw workspace *`), and browser voice MVP (`dw voice`) with hybrid orchestrator fallback (Claude/Codex/Gemini), full bilingual UX (en + vi), voice-not-installed fallback via Google Translate TTS proxy. **v1.7.0 stable** on `main` (2026-05-24). 368/368 smoke tests. Active ADRs: ADR-0001 (Pragmatic Lean), ADR-0005/0006 (Supply-Chain Guard; sunset review 2026-08-12), ADR-0008 (Task Docs v3, v1.5), ADR-0009 (Agent OS, v1.6), ADR-0010 (Goals Layer, v1.7), ADR-0011 (Session Runtime + Voice Orchestrator, v1.8 — Accepted), ADR-0014 (Orchestrator Action Execution — Accepted), ADR-0015 (Multi-Agent Live Debate — Accepted), ADR-0016 (`dw:goal` Outcome-Driven Pursuit Loop — Accepted).
7
-
8
- ---
9
-
10
- ## Tech Stack
11
-
12
- - Runtime: Node.js ≥18, ESM (`.mjs`)
13
- - CLI: `commander` · UI: `enquirer`, `chalk` · Config: `js-yaml`, `ajv`
14
- - Tests: `node src/smoke-test.mjs`
15
-
16
- ## Repo Structure
17
-
18
- ```
19
- bin/ CLI entrypoint
20
- src/
21
- commands/ CLI subcommands (init, upgrade, dashboard, metrics, ...)
22
- lib/ Shared utilities (config, telemetry, active-index, ...)
23
- .claude/
24
- hooks/ Bash hooks (Guards pillar)
25
- rules/ dw.md (consolidated) + code-style + commit-standards
26
- skills/ Slash commands with dw:* namespace
27
- .dw/
28
- core/ WORKFLOW · THINKING · QUALITY · ROLES · PILLARS · templates/
29
- decisions/ ADRs (Records pillar)
30
- tasks/ Active + archive/ (Bridges pillar — via task.md v3 / tracking.md v2)
31
- metrics/ Local telemetry (events.jsonl)
32
- config/ dw.config.yml
33
- security/ IoC namespace fixture (Guards pillar — ADR-0005)
34
- research/ Investigation notes, RFC-style proposals, voter panel outputs
35
- ```
36
-
37
- ## Dev Notes
38
-
39
- - All source ESM — no CommonJS
40
- - `TOOLKIT_ROOT` resolved from `import.meta.url` in each command
41
- - Hooks python3-free (node only — Windows compat)
42
- - `dw-kit-evolve` + `dw-kit-audit` are maintainer-only — excluded from npm package
43
- - Published package files declared explicitly in `package.json#files`
44
- - Telemetry local-only, `DW_NO_TELEMETRY=1` to disable