@mriekki/project-forge 4.10.4 → 4.11.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@mriekki/project-forge",
4
- "version": "4.10.4",
4
+ "version": "4.11.0",
5
5
  "defaultProfile": "greenfield-project",
6
6
  "profiles": {
7
7
  "greenfield-project": {
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "components": {
15
15
  "core": {
16
- "revision": "8796fd8dc22df8b64078e5b460e1008a8c7a5f7d",
16
+ "revision": "7a0c5a3ef42c0d6f38203007c957c3e59cbf4f8b",
17
17
  "version": "4.0.0",
18
18
  "distribution": "greenfield-workspace",
19
19
  "descriptor": "assets/core/distribution.json",
@@ -18,6 +18,7 @@
18
18
  {"source":"core/skills/progress/SKILL.md","destination":".github/skills/progress/SKILL.md"},
19
19
  {"source":"core/skills/next-step/SKILL.md","destination":".github/skills/next-step/SKILL.md"},
20
20
  {"source":"core/skills/deliver/SKILL.md","destination":".github/skills/deliver/SKILL.md"},
21
+ {"source":"core/skills/work-item-status/SKILL.md","destination":".github/skills/work-item-status/SKILL.md"},
21
22
  {"source":"core/skills/cleanup-branches/SKILL.md","destination":".github/skills/cleanup-branches/SKILL.md"},
22
23
  {"source":"core/skills/pull-latest/SKILL.md","destination":".github/skills/pull-latest/SKILL.md"},
23
24
  {"source":"core/skills/project-forge-update/SKILL.md","destination":".github/skills/project-forge-update/SKILL.md"},
@@ -63,6 +64,10 @@
63
64
  {"source":"tools/db_lab/sqlite_workbench.py","destination":"tools/project-forge-db-lab/sqlite-workbench.py"},
64
65
  {"source":"tools/system_lab/system_workbench.py","destination":"tools/project-forge-system-lab/system-workbench.py"},
65
66
  {"source":"tools/docs_site/docs_site.py","destination":"tools/project-forge-docs/docs-site.py"},
67
+ {"source":"tools/vscode_work_item_status/install.py","destination":"tools/project-forge-vscode/work-item-status/install.py"},
68
+ {"source":"tools/vscode_work_item_status/extension/package.json","destination":"tools/project-forge-vscode/work-item-status/extension/package.json"},
69
+ {"source":"tools/vscode_work_item_status/extension/extension.js","destination":"tools/project-forge-vscode/work-item-status/extension/extension.js"},
70
+ {"source":"tools/vscode_work_item_status/extension/README.md","destination":"tools/project-forge-vscode/work-item-status/extension/README.md"},
66
71
  {"source":"tools/package/check_baseline.py","destination":"tools/project-forge-core/check-core-baseline.py"},
67
72
  {"source":"tools/package/check_upstream.py","destination":"tools/project-forge-core/check-core-upstream.py"}
68
73
  ]
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@mriekki/project-forge",
4
- "version": "4.10.4",
4
+ "version": "4.11.0",
5
5
  "components": {
6
6
  "core": {
7
- "revision": "8796fd8dc22df8b64078e5b460e1008a8c7a5f7d",
7
+ "revision": "7a0c5a3ef42c0d6f38203007c957c3e59cbf4f8b",
8
8
  "version": "4.0.0",
9
9
  "distribution": "greenfield-workspace",
10
10
  "files": [
@@ -51,7 +51,7 @@
51
51
  {
52
52
  "source": "core/skills/setup/SKILL.md",
53
53
  "destination": ".github/skills/setup/SKILL.md",
54
- "sha256": "27419e0d330abf2b6b03a696eba352ab2ece13a616c41fc0ade9aed85e8834f1"
54
+ "sha256": "9b3dee1d965f1999490d0e3c58925c341ac5f3f5b228ccfe6a0ac53d64b19a64"
55
55
  },
56
56
  {
57
57
  "source": "core/skills/build/SKILL.md",
@@ -86,7 +86,12 @@
86
86
  {
87
87
  "source": "core/skills/deliver/SKILL.md",
88
88
  "destination": ".github/skills/deliver/SKILL.md",
89
- "sha256": "238d6c81a7253c4241e4cf8588be5a5986b406b7e3a56cac115fc47a46071c71"
89
+ "sha256": "6a3886493e9b59725ff53652972c1830478176a8c8da648448401d9051820db4"
90
+ },
91
+ {
92
+ "source": "core/skills/work-item-status/SKILL.md",
93
+ "destination": ".github/skills/work-item-status/SKILL.md",
94
+ "sha256": "7235ceebebc61519f2aa6c903ab8e814ba33cb029db4b7e5626852f935038c3c"
90
95
  },
91
96
  {
92
97
  "source": "core/skills/cleanup-branches/SKILL.md",
@@ -161,7 +166,7 @@
161
166
  {
162
167
  "source": "core/process/WORK_ITEM_MODEL.md",
163
168
  "destination": "docs/process/WORK_ITEM_MODEL.md",
164
- "sha256": "20a8dcc6ead4fe9b491917766ae402265bc84daa5ba1445d3628900044e9a1ab"
169
+ "sha256": "8d891d41691324b0552f94fe79b9f6f33ec3c50aaed96bef69a86c2fef3d93e3"
165
170
  },
166
171
  {
167
172
  "source": "core/process/PR_WORKFLOW.md",
@@ -313,6 +318,26 @@
313
318
  "destination": "tools/project-forge-docs/docs-site.py",
314
319
  "sha256": "8b0fe9a63a84a09ceeb71db0265186e427e92c8ce61e925b98f3be66682078ec"
315
320
  },
321
+ {
322
+ "source": "tools/vscode_work_item_status/install.py",
323
+ "destination": "tools/project-forge-vscode/work-item-status/install.py",
324
+ "sha256": "190015b44611b5b6f0f21bff05923262abd9d9e16e9916dbe71755fb753d8fbc"
325
+ },
326
+ {
327
+ "source": "tools/vscode_work_item_status/extension/package.json",
328
+ "destination": "tools/project-forge-vscode/work-item-status/extension/package.json",
329
+ "sha256": "3ae6351df5aa0fa4908e0e4456c84ac6e9477fde0074226d1aac0316f23b9116"
330
+ },
331
+ {
332
+ "source": "tools/vscode_work_item_status/extension/extension.js",
333
+ "destination": "tools/project-forge-vscode/work-item-status/extension/extension.js",
334
+ "sha256": "50b7551c1abc1c30552945dedaf602569dfdf770cad0b2d4652e46f2910231d8"
335
+ },
336
+ {
337
+ "source": "tools/vscode_work_item_status/extension/README.md",
338
+ "destination": "tools/project-forge-vscode/work-item-status/extension/README.md",
339
+ "sha256": "9e1d1e8b53d27c07e8de72d38db420740d7e1b3301ab146531d89882cd088418"
340
+ },
316
341
  {
317
342
  "source": "tools/package/check_baseline.py",
318
343
  "destination": "tools/project-forge-core/check-core-baseline.py",
@@ -54,3 +54,11 @@ Jira Epic == Feature Brief
54
54
  Possible relationships vary by team: an Epic may be a feature, initiative, technical workstream, or delivery container; one Epic may have no Feature Brief, one brief, or several.
55
55
 
56
56
  Existing team conventions outrank Project Forge defaults. Do not create a competing local source of truth merely because Project Forge has a template.
57
+
58
+ ## Execution-state views
59
+
60
+ Editor badges, dashboards, and `/progress` may summarize work-item execution state from current repository evidence, but those views are not additional workflow authorities.
61
+
62
+ Keep stable work-item IDs and paths unchanged merely to display status. In particular, do not prefix or rename work-item folders with status text or emoji, and do not reuse a brief/requirements `Status:` field for implementation progress when that field already represents requirement maturity or approval.
63
+
64
+ Prefer derived views over a second tracked status database. When evidence is incomplete, show no status or `NOT EVIDENCED` rather than inventing completion.
@@ -137,6 +137,16 @@ PR evidence may include:
137
137
 
138
138
  Do not claim evidence that was not observed.
139
139
 
140
+ For work with a stable work-item ID, keep one hidden Project Forge navigation marker in the PR body using the exact resolved ID:
141
+
142
+ ```html
143
+ <!-- project-forge-work-item: <ID> -->
144
+ ```
145
+
146
+ The marker is navigation metadata only. It does not prove requirement approval, implementation acceptance, review, CI, merge readiness, delivery completion, deployment, or any other lifecycle state. Never use the marker alone to satisfy a gate.
147
+
148
+ When `/deliver` resumes an existing PR that clearly corresponds to the same ID-backed work item and the marker is absent, adding the marker is routine PR-body maintenance within the existing `/deliver` authorization. Preserve the rest of the existing PR body and do not add or rewrite the marker when the work-item identity is ambiguous. For ad hoc work without a stable work-item ID, omit the marker rather than inventing one.
149
+
140
150
  ## 6. Stop conditions during routine delivery
141
151
 
142
152
  Stop before/during routine writes for:
@@ -398,4 +408,4 @@ The optional learning handoff does not block or replace deployment readiness whe
398
408
 
399
409
  `/deliver` takes reviewed/accepted work through routine branch/commit/push/PR/CI mechanics automatically, then stops once for the merge-and-finalize decision. After successful engineering completion it may prepare disposable learning workbenches and shows an optional learning handoff without creating another gate.
400
410
 
401
- `/cleanup-branches` is the standalone preview-first path for accumulated, previously retained, unrelated, or otherwise out-of-band branch housekeeping.
411
+ `/cleanup-branches` is the standalone preview-first path for accumulated, previously retained, unrelated, or otherwise out-of-band branch housekeeping.
@@ -214,6 +214,28 @@ Rules:
214
214
  - the recorded setup scope comes from the developer's actual selections;
215
215
  - later environment changes belong in mutable project topology, not casual rewrites of Bootstrap provenance.
216
216
 
217
+ ### Builder-owned target namespace
218
+
219
+ Treat reusable remote target definitions as Builder-owned personal state. The Project owns logical environment roles and a logical `targetProfile` binding; it does not own another Builder's physical machine, SSH identity, trust, current address, or readiness.
220
+
221
+ The same logical target name may resolve to a different physical machine for another developer. For example, Project topology may bind `Production -> production`; each Builder may define their own personal `production` target with their own hostname, SSH account, identity, and trust. Do not treat another Builder's target profile, hostname, SSH identity/trust, address, or readiness as authoritative for this workstation.
222
+
223
+ When an already-bound remote environment references `<target>` but this Builder's personal/local profile has no such target, do not describe the Project as broken and do not ask the Builder to gain access to another person's box. Surface the condition explicitly:
224
+
225
+ ```text
226
+ <ENVIRONMENT> TARGET NOT CONFIGURED FOR THIS BUILDER
227
+
228
+ This Project binds <environment> to logical target <target>, but this
229
+ Builder has not defined that target in their personal Project Forge profile.
230
+
231
+ CHOICES
232
+ Define my target `<target>` [RECOMMENDED]
233
+ Sync/import my personal target `<target>` if I already defined it elsewhere
234
+ Keep `<environment>` unavailable on this Builder
235
+ ```
236
+
237
+ `Define my target` uses the supported personal target-profile flow and preserves the Project's existing logical `targetProfile` name. Do not silently substitute a differently named personal target merely because it is reachable; selecting a different logical target is a Project topology rebind and requires its separate explicit decision. Production-class configuration/access remains distinct from Production proof and application-deployment authorization.
238
+
217
239
  ### Define another environment
218
240
 
219
241
  For `Other / define another environment`, collect only material durable facts, normally:
@@ -482,4 +504,4 @@ Useful next actions include:
482
504
  - `/deploy <environment>` — only after readiness and its own authorization contract;
483
505
  - `/tear-down <scope>` — destructive cleanup under its separate gate.
484
506
 
485
- Do not create a second top-level developer-environment-mode workflow. Local/remote workstation convergence belongs here.
507
+ Do not create a second top-level developer-environment-mode workflow. Local/remote workstation convergence belongs here.
@@ -0,0 +1,127 @@
1
+ ---
2
+ name: work-item-status
3
+ description: "Inspect or manage the optional VS Code Explorer decoration that shows evidence-backed Project Forge work-item progress without renaming or editing work-item tickets."
4
+ argument-hint: "[status|enable|disable]"
5
+ user-invocable: true
6
+ disable-model-invocation: true
7
+ ---
8
+
9
+ # Work Item Status
10
+
11
+ Purpose: make local `docs/work-items/<ID>-...` folders easier to scan in VS Code without changing work-item paths, ticket content, or Project Forge workflow authority.
12
+
13
+ Normal use is read-only:
14
+
15
+ ```text
16
+ /work-item-status
17
+ /work-item-status status
18
+ ```
19
+
20
+ The optional editor decoration uses the existing Project Forge progress vocabulary:
21
+
22
+ ```text
23
+ ● IN PROGRESS
24
+ ✓ COMPLETE
25
+ ```
26
+
27
+ No badge means Project Forge is not asserting an execution state for that folder.
28
+
29
+ ## 1. Authority boundary
30
+
31
+ The Explorer badge is a **view of repository evidence**, not a source of truth.
32
+
33
+ Never:
34
+ - rename a work-item folder to add status text or emoji;
35
+ - rewrite the work item's requirements/brief `Status:` field to represent execution progress;
36
+ - create a second tracked work-item status database merely for the badge;
37
+ - infer completion from an expected lifecycle step that was not observed.
38
+
39
+ Normal work-item docs, Git branches, pull requests, CI/review/default-branch evidence, and `/progress` remain authoritative.
40
+
41
+ ## 2. Status evidence
42
+
43
+ The local VS Code extension is deliberately conservative.
44
+
45
+ `● IN PROGRESS` may be shown when the work-item ID is supported by:
46
+ - this checkout's current work branch; or
47
+ - an open pull request whose hidden Project Forge marker or work-item branch identifies the folder ID.
48
+
49
+ `✓ COMPLETE` may be shown when a merged pull request to the repository default branch identifies the work-item ID. New Project Forge delivery PRs should carry the hidden marker:
50
+
51
+ ```html
52
+ <!-- project-forge-work-item: <ID> -->
53
+ ```
54
+
55
+ At runtime, `<ID>` is replaced by the exact stable work-item ID already resolved for delivery. The marker is navigation metadata only. It does not prove acceptance, CI, deployment, or any other lifecycle gate. For older Project Forge PRs, the extension may use the repository's normal ID-backed branch naming as compatibility evidence.
56
+
57
+ When GitHub CLI evidence is unavailable, the extension may still show current-branch `IN PROGRESS` state but must not invent merged/completed state.
58
+
59
+ ## 3. Inspect status
60
+
61
+ Locate the Core-managed installer when present:
62
+
63
+ ```text
64
+ tools/project-forge-vscode/work-item-status/install.py
65
+ ```
66
+
67
+ For `status`, run it read-only:
68
+
69
+ ```text
70
+ python tools/project-forge-vscode/work-item-status/install.py status --json
71
+ ```
72
+
73
+ Also check whether `docs/work-items` exists. If the Project uses an external-only work-item source with no local work-item folders, report the Explorer decoration as not applicable rather than creating a competing local hierarchy.
74
+
75
+ A useful result is:
76
+
77
+ ```text
78
+ WORK-ITEM EXPLORER STATUS
79
+
80
+ Local work-item folders .... PRESENT / NOT PRESENT
81
+ VS Code decorator .......... READY / NOT INSTALLED / ERROR
82
+ Project workflow authority . UNCHANGED
83
+ ```
84
+
85
+ ## 4. Enable
86
+
87
+ `/work-item-status enable` is explicit authorization for one bounded local editor change: install/update the Project Forge-owned extension in this developer's VS Code extensions directory.
88
+
89
+ Run:
90
+
91
+ ```text
92
+ python tools/project-forge-vscode/work-item-status/install.py install --json
93
+ ```
94
+
95
+ The installer may replace older versions of **this same Project Forge extension only**. It must not install unrelated extensions, alter Project files, change work-item docs, or change Git/GitHub state.
96
+
97
+ If installation changes the extension, tell the developer to reload VS Code once. The decorator then refreshes automatically and also exposes:
98
+
99
+ ```text
100
+ Project Forge: Refresh Work Item Status
101
+ ```
102
+
103
+ through the VS Code Command Palette.
104
+
105
+ Failure to install this optional decoration does **not** make Project setup, implementation, review, delivery, or deployment unready.
106
+
107
+ ## 5. Disable
108
+
109
+ `/work-item-status disable` is explicit authorization to remove only Project Forge's own work-item-status extension from this developer's local VS Code extensions directory.
110
+
111
+ Run:
112
+
113
+ ```text
114
+ python tools/project-forge-vscode/work-item-status/install.py uninstall --json
115
+ ```
116
+
117
+ Do not remove any other extension. If files were removed, tell the developer to reload VS Code once.
118
+
119
+ ## 6. Guided ending
120
+
121
+ Return:
122
+ - decorator installation state;
123
+ - whether local `docs/work-items` folders are present;
124
+ - a reminder that badges are evidence-derived and non-authoritative;
125
+ - the smallest relevant next action.
126
+
127
+ Do not turn this optional convenience into a required Project Forge gate.
@@ -0,0 +1,11 @@
1
+ # Project Forge Work Item Status
2
+
3
+ This optional local VS Code extension decorates Project Forge work-item folders in the Explorer from repository evidence.
4
+
5
+ - `●` — in progress, supported by the current work-item branch or an open pull request.
6
+ - `✓` — complete, supported by a merged pull request to the repository default branch.
7
+ - no badge — no status is asserted.
8
+
9
+ The extension is read-only. It does not rename or edit work-item folders/documents and does not maintain a second status database. It reads local Git state and, when available, GitHub CLI pull-request metadata. Project Forge's normal repository/PR/CI evidence remains authoritative.
10
+
11
+ Use **Project Forge: Refresh Work Item Status** from the Command Palette to refresh immediately. The extension also refreshes periodically and when common local Git/work-item evidence changes.
@@ -0,0 +1,274 @@
1
+ 'use strict';
2
+
3
+ const vscode = require('vscode');
4
+ const fs = require('fs');
5
+ const fsp = require('fs/promises');
6
+ const path = require('path');
7
+ const { execFile } = require('child_process');
8
+ const { promisify } = require('util');
9
+
10
+ const execFileAsync = promisify(execFile);
11
+ const WORK_ITEMS_RELATIVE = path.join('docs', 'work-items');
12
+ const ID_FROM_NAME = /^([A-Za-z][A-Za-z0-9]*-\d+)(?:-|$)/;
13
+ const ID_FROM_BRANCH = /(?:^|\/)([A-Za-z][A-Za-z0-9]*-\d+)(?:-|\/|$)/i;
14
+ const MARKER = /<!--\s*project-forge-work-item:\s*([A-Za-z][A-Za-z0-9]*-\d+)\s*-->/gi;
15
+ const REFRESH_INTERVAL_MS = 120000;
16
+
17
+ function normalizeId(value) {
18
+ return value ? value.toUpperCase() : undefined;
19
+ }
20
+
21
+ function workItemIdFromFolderName(name) {
22
+ const match = ID_FROM_NAME.exec(name);
23
+ return match ? normalizeId(match[1]) : undefined;
24
+ }
25
+
26
+ function workItemIdFromBranch(name) {
27
+ const match = ID_FROM_BRANCH.exec(name || '');
28
+ return match ? normalizeId(match[1]) : undefined;
29
+ }
30
+
31
+ function workItemIdsFromPr(pr) {
32
+ const ids = new Set();
33
+ const body = typeof pr.body === 'string' ? pr.body : '';
34
+ MARKER.lastIndex = 0;
35
+ for (let match = MARKER.exec(body); match; match = MARKER.exec(body)) {
36
+ ids.add(normalizeId(match[1]));
37
+ }
38
+ const branchId = workItemIdFromBranch(pr.headRefName);
39
+ if (branchId) {
40
+ ids.add(branchId);
41
+ }
42
+ return ids;
43
+ }
44
+
45
+ async function run(command, args, cwd) {
46
+ try {
47
+ const result = await execFileAsync(command, args, {
48
+ cwd,
49
+ encoding: 'utf8',
50
+ timeout: 6000,
51
+ windowsHide: true,
52
+ maxBuffer: 1024 * 1024,
53
+ });
54
+ return (result.stdout || '').trim();
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ async function readJsonCommand(command, args, cwd) {
61
+ const output = await run(command, args, cwd);
62
+ if (!output) {
63
+ return undefined;
64
+ }
65
+ try {
66
+ return JSON.parse(output);
67
+ } catch {
68
+ return undefined;
69
+ }
70
+ }
71
+
72
+ async function defaultBranch(root) {
73
+ const repo = await readJsonCommand('gh', ['repo', 'view', '--json', 'defaultBranchRef'], root);
74
+ const fromGh = repo && repo.defaultBranchRef && repo.defaultBranchRef.name;
75
+ if (fromGh) {
76
+ return fromGh;
77
+ }
78
+ const remoteHead = await run('git', ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], root);
79
+ if (remoteHead && remoteHead.includes('/')) {
80
+ return remoteHead.slice(remoteHead.lastIndexOf('/') + 1);
81
+ }
82
+ return 'main';
83
+ }
84
+
85
+ async function listWorkItemFolders(root) {
86
+ const directory = path.join(root, WORK_ITEMS_RELATIVE);
87
+ try {
88
+ const entries = await fsp.readdir(directory, { withFileTypes: true });
89
+ return entries
90
+ .filter((entry) => entry.isDirectory())
91
+ .map((entry) => ({
92
+ id: workItemIdFromFolderName(entry.name),
93
+ uri: vscode.Uri.file(path.join(directory, entry.name)),
94
+ }))
95
+ .filter((entry) => entry.id);
96
+ } catch {
97
+ return [];
98
+ }
99
+ }
100
+
101
+ async function githubPrEvidence(root) {
102
+ const [openPrs, mergedPrs, base] = await Promise.all([
103
+ readJsonCommand('gh', ['pr', 'list', '--state', 'open', '--limit', '100', '--json', 'number,body,headRefName,baseRefName,url'], root),
104
+ readJsonCommand('gh', ['pr', 'list', '--state', 'merged', '--limit', '200', '--json', 'number,body,headRefName,baseRefName,mergedAt,url'], root),
105
+ defaultBranch(root),
106
+ ]);
107
+ return {
108
+ openPrs: Array.isArray(openPrs) ? openPrs : [],
109
+ mergedPrs: Array.isArray(mergedPrs) ? mergedPrs : [],
110
+ defaultBranch: base,
111
+ };
112
+ }
113
+
114
+ async function computeRootStatus(root) {
115
+ const folders = await listWorkItemFolders(root);
116
+ if (!folders.length) {
117
+ return { folders, statusById: new Map() };
118
+ }
119
+
120
+ const statusById = new Map();
121
+ const evidence = await githubPrEvidence(root);
122
+
123
+ for (const pr of evidence.mergedPrs) {
124
+ if (pr.baseRefName !== evidence.defaultBranch) {
125
+ continue;
126
+ }
127
+ for (const id of workItemIdsFromPr(pr)) {
128
+ statusById.set(id, {
129
+ state: 'complete',
130
+ detail: `merged PR #${pr.number}`,
131
+ });
132
+ }
133
+ }
134
+
135
+ for (const pr of evidence.openPrs) {
136
+ for (const id of workItemIdsFromPr(pr)) {
137
+ statusById.set(id, {
138
+ state: 'in-progress',
139
+ detail: `open PR #${pr.number}`,
140
+ });
141
+ }
142
+ }
143
+
144
+ const branch = await run('git', ['branch', '--show-current'], root);
145
+ const currentId = workItemIdFromBranch(branch);
146
+ if (currentId) {
147
+ statusById.set(currentId, {
148
+ state: 'in-progress',
149
+ detail: `current branch ${branch}`,
150
+ });
151
+ }
152
+
153
+ return { folders, statusById };
154
+ }
155
+
156
+ class WorkItemDecorationProvider {
157
+ constructor() {
158
+ this._onDidChangeFileDecorations = new vscode.EventEmitter();
159
+ this.onDidChangeFileDecorations = this._onDidChangeFileDecorations.event;
160
+ this.byPath = new Map();
161
+ this.uris = [];
162
+ }
163
+
164
+ provideFileDecoration(uri) {
165
+ const entry = this.byPath.get(path.normalize(uri.fsPath));
166
+ if (!entry) {
167
+ return undefined;
168
+ }
169
+ if (entry.state === 'complete') {
170
+ return {
171
+ badge: '✓',
172
+ color: new vscode.ThemeColor('gitDecoration.addedResourceForeground'),
173
+ tooltip: `Project Forge: COMPLETE — ${entry.detail}`,
174
+ propagate: false,
175
+ };
176
+ }
177
+ if (entry.state === 'in-progress') {
178
+ return {
179
+ badge: '●',
180
+ color: new vscode.ThemeColor('gitDecoration.modifiedResourceForeground'),
181
+ tooltip: `Project Forge: IN PROGRESS — ${entry.detail}`,
182
+ propagate: false,
183
+ };
184
+ }
185
+ return undefined;
186
+ }
187
+
188
+ async refresh() {
189
+ const next = new Map();
190
+ const nextUris = [];
191
+ const roots = (vscode.workspace.workspaceFolders || []).map((folder) => folder.uri.fsPath);
192
+
193
+ for (const root of roots) {
194
+ if (!fs.existsSync(path.join(root, WORK_ITEMS_RELATIVE))) {
195
+ continue;
196
+ }
197
+ const result = await computeRootStatus(root);
198
+ for (const folder of result.folders) {
199
+ nextUris.push(folder.uri);
200
+ const status = result.statusById.get(folder.id);
201
+ if (status) {
202
+ next.set(path.normalize(folder.uri.fsPath), status);
203
+ }
204
+ }
205
+ }
206
+
207
+ const changed = [...this.uris, ...nextUris];
208
+ this.byPath = next;
209
+ this.uris = nextUris;
210
+ if (changed.length) {
211
+ this._onDidChangeFileDecorations.fire(changed);
212
+ }
213
+ }
214
+
215
+ dispose() {
216
+ this._onDidChangeFileDecorations.dispose();
217
+ }
218
+ }
219
+
220
+ function activate(context) {
221
+ const provider = new WorkItemDecorationProvider();
222
+ context.subscriptions.push(provider);
223
+ context.subscriptions.push(vscode.window.registerFileDecorationProvider(provider));
224
+
225
+ let refreshing = false;
226
+ const refresh = async () => {
227
+ if (refreshing) {
228
+ return;
229
+ }
230
+ refreshing = true;
231
+ try {
232
+ await provider.refresh();
233
+ } finally {
234
+ refreshing = false;
235
+ }
236
+ };
237
+
238
+ context.subscriptions.push(
239
+ vscode.commands.registerCommand('projectForge.refreshWorkItemStatus', refresh),
240
+ );
241
+ context.subscriptions.push(
242
+ vscode.window.onDidChangeWindowState((event) => {
243
+ if (event.focused) {
244
+ void refresh();
245
+ }
246
+ }),
247
+ );
248
+
249
+ for (const folder of vscode.workspace.workspaceFolders || []) {
250
+ const head = vscode.workspace.createFileSystemWatcher(
251
+ new vscode.RelativePattern(folder, '.git/HEAD'),
252
+ );
253
+ const refs = vscode.workspace.createFileSystemWatcher(
254
+ new vscode.RelativePattern(folder, '.git/refs/heads/**'),
255
+ );
256
+ const workItems = vscode.workspace.createFileSystemWatcher(
257
+ new vscode.RelativePattern(folder, 'docs/work-items/*'),
258
+ );
259
+ for (const watcher of [head, refs, workItems]) {
260
+ watcher.onDidChange(() => void refresh());
261
+ watcher.onDidCreate(() => void refresh());
262
+ watcher.onDidDelete(() => void refresh());
263
+ context.subscriptions.push(watcher);
264
+ }
265
+ }
266
+
267
+ const timer = setInterval(() => void refresh(), REFRESH_INTERVAL_MS);
268
+ context.subscriptions.push({ dispose: () => clearInterval(timer) });
269
+ void refresh();
270
+ }
271
+
272
+ function deactivate() {}
273
+
274
+ module.exports = { activate, deactivate };
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "project-forge-work-item-status",
3
+ "displayName": "Project Forge Work Item Status",
4
+ "description": "Shows evidence-backed Project Forge work-item status badges in the VS Code Explorer.",
5
+ "version": "0.1.0",
6
+ "publisher": "project-forge",
7
+ "license": "UNLICENSED",
8
+ "engines": {
9
+ "vscode": "^1.90.0"
10
+ },
11
+ "categories": [
12
+ "Other"
13
+ ],
14
+ "keywords": [
15
+ "project-forge",
16
+ "work-items",
17
+ "explorer"
18
+ ],
19
+ "activationEvents": [
20
+ "workspaceContains:docs/work-items/*"
21
+ ],
22
+ "main": "./extension.js",
23
+ "extensionKind": [
24
+ "workspace"
25
+ ],
26
+ "capabilities": {
27
+ "untrustedWorkspaces": {
28
+ "supported": false,
29
+ "description": "The extension reads local Git state and optional GitHub CLI pull-request evidence."
30
+ }
31
+ },
32
+ "contributes": {
33
+ "commands": [
34
+ {
35
+ "command": "projectForge.refreshWorkItemStatus",
36
+ "title": "Project Forge: Refresh Work Item Status"
37
+ }
38
+ ]
39
+ }
40
+ }
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import shutil
8
+ import sys
9
+ import tempfile
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ HERE = Path(__file__).resolve().parent
14
+ SOURCE = HERE / "extension"
15
+ MANIFEST = SOURCE / "package.json"
16
+
17
+
18
+ def load_manifest() -> dict[str, Any]:
19
+ return json.loads(MANIFEST.read_text(encoding="utf-8"))
20
+
21
+
22
+ def extension_identity() -> tuple[str, str]:
23
+ manifest = load_manifest()
24
+ publisher = str(manifest["publisher"]).strip()
25
+ name = str(manifest["name"]).strip()
26
+ version = str(manifest["version"]).strip()
27
+ if not publisher or not name or not version:
28
+ raise RuntimeError("Project Forge VS Code extension manifest is incomplete")
29
+ return f"{publisher}.{name}", version
30
+
31
+
32
+ def default_extensions_root() -> Path:
33
+ override = os.environ.get("VSCODE_EXTENSIONS")
34
+ if override:
35
+ return Path(override).expanduser()
36
+ return Path.home() / ".vscode" / "extensions"
37
+
38
+
39
+ def extension_target(root: Path) -> Path:
40
+ extension_id, version = extension_identity()
41
+ return root / f"{extension_id}-{version}"
42
+
43
+
44
+ def status(root: Path) -> dict[str, Any]:
45
+ extension_id, version = extension_identity()
46
+ target = extension_target(root)
47
+ ready = target.is_dir() and all(
48
+ (target / name).is_file() for name in ("package.json", "extension.js", "README.md")
49
+ )
50
+ return {
51
+ "extensionId": extension_id,
52
+ "version": version,
53
+ "status": "ready" if ready else "not-installed",
54
+ "path": str(target),
55
+ "reloadRequired": False,
56
+ }
57
+
58
+
59
+ def install(root: Path) -> dict[str, Any]:
60
+ extension_id, version = extension_identity()
61
+ root.mkdir(parents=True, exist_ok=True)
62
+ target = extension_target(root)
63
+
64
+ if not SOURCE.is_dir():
65
+ raise RuntimeError(f"Missing Project Forge VS Code extension source: {SOURCE}")
66
+
67
+ existing = status(root)
68
+ if existing["status"] == "ready":
69
+ return existing
70
+
71
+ with tempfile.TemporaryDirectory(prefix="project-forge-vscode-", dir=str(root)) as tmp:
72
+ staged = Path(tmp) / target.name
73
+ shutil.copytree(SOURCE, staged)
74
+ if target.exists():
75
+ shutil.rmtree(target)
76
+ staged.rename(target)
77
+
78
+ prefix = f"{extension_id}-"
79
+ for candidate in root.iterdir():
80
+ if candidate == target or not candidate.is_dir() or not candidate.name.startswith(prefix):
81
+ continue
82
+ shutil.rmtree(candidate)
83
+
84
+ result = status(root)
85
+ result["reloadRequired"] = True
86
+ return result
87
+
88
+
89
+ def uninstall(root: Path) -> dict[str, Any]:
90
+ extension_id, version = extension_identity()
91
+ prefix = f"{extension_id}-"
92
+ removed: list[str] = []
93
+ if root.exists():
94
+ for candidate in root.iterdir():
95
+ if candidate.is_dir() and candidate.name.startswith(prefix):
96
+ shutil.rmtree(candidate)
97
+ removed.append(str(candidate))
98
+ return {
99
+ "extensionId": extension_id,
100
+ "version": version,
101
+ "status": "not-installed",
102
+ "removed": removed,
103
+ "reloadRequired": bool(removed),
104
+ }
105
+
106
+
107
+ def emit(result: dict[str, Any], as_json: bool) -> None:
108
+ if as_json:
109
+ print(json.dumps(result, sort_keys=True))
110
+ return
111
+ print(f"Project Forge Work Item Status: {result['status'].upper()}")
112
+ print(f"Extension: {result['extensionId']}@{result['version']}")
113
+ if result.get("path"):
114
+ print(f"Path: {result['path']}")
115
+ if result.get("removed"):
116
+ print(f"Removed: {len(result['removed'])}")
117
+ if result.get("reloadRequired"):
118
+ print("Reload VS Code to activate the changed extension installation.")
119
+
120
+
121
+ def main() -> int:
122
+ parser = argparse.ArgumentParser(
123
+ description="Install or inspect the optional Project Forge VS Code work-item status decorator."
124
+ )
125
+ parser.add_argument("action", choices=["status", "install", "uninstall"])
126
+ parser.add_argument("--extensions-dir", type=Path, help="Override the VS Code extensions directory.")
127
+ parser.add_argument("--json", action="store_true")
128
+ args = parser.parse_args()
129
+
130
+ root = (args.extensions_dir or default_extensions_root()).expanduser().resolve()
131
+ try:
132
+ if args.action == "install":
133
+ result = install(root)
134
+ elif args.action == "uninstall":
135
+ result = uninstall(root)
136
+ else:
137
+ result = status(root)
138
+ except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as exc:
139
+ if args.json:
140
+ print(json.dumps({"status": "error", "error": str(exc)}, sort_keys=True))
141
+ else:
142
+ print(f"Project Forge Work Item Status: ERROR — {exc}", file=sys.stderr)
143
+ return 1
144
+
145
+ emit(result, args.json)
146
+ return 0
147
+
148
+
149
+ if __name__ == "__main__":
150
+ raise SystemExit(main())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mriekki/project-forge",
3
- "version": "4.10.4",
3
+ "version": "4.11.0",
4
4
  "description": "Public consumer distribution and updater for Project Forge engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {