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.
- package/.claude/hooks/stop-check.sh +10 -0
- package/.claude/skills/dw-decision/SKILL.md +2 -1
- package/.dw/core/schemas/decision-frontmatter.schema.json +54 -0
- package/.dw/core/schemas/goal-frontmatter.schema.json +2 -2
- package/.dw/core/schemas/task-frontmatter.schema.json +2 -2
- package/.dw/core/templates/v3/task.md +38 -9
- package/README.md +5 -2
- package/package.json +5 -3
- package/src/cli.mjs +33 -0
- package/src/commands/decision-index.mjs +45 -0
- package/src/commands/goal-delete.mjs +3 -1
- package/src/commands/goal-link.mjs +3 -1
- package/src/commands/goal-status.mjs +95 -0
- package/src/commands/lint-task.mjs +20 -0
- package/src/commands/task-index.mjs +47 -0
- package/src/commands/task-migrate.mjs +16 -5
- package/src/commands/task-new.mjs +6 -0
- package/src/commands/task-summary.mjs +4 -3
- package/src/lib/decision-store.mjs +146 -0
- package/src/lib/goal-store.mjs +40 -1
- package/src/lib/lint-rules.mjs +10 -1
- package/src/lib/task-store.mjs +164 -0
- package/.dw/core/PILLARS.md +0 -122
- package/CLAUDE.md +0 -44
|
@@ -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
|
|
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
|
|
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'
|
|
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
|
-
###
|
|
82
|
+
### Functional Acceptance (handoff scenarios)
|
|
83
83
|
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/lib/goal-store.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/lib/lint-rules.mjs
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/.dw/core/PILLARS.md
DELETED
|
@@ -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
|