Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
29ef935
test(schedule): define four-level hierarchy domain contract
seonghobae Aug 15, 2026
bcc5f70
feat(schedule): add four-level hierarchy domain projection
seonghobae Aug 15, 2026
8299740
test(schedule): wire hierarchy domain into unit and coverage gates
seonghobae Aug 15, 2026
ba26b89
docs(test): harden hierarchy evidence and traceability
seonghobae Aug 15, 2026
458cc39
test(schedule): preserve persisted projection fields
seonghobae Aug 15, 2026
8f49810
fix(schedule): isolate hierarchy projection metadata
seonghobae Aug 15, 2026
da31ed0
test(schedule): assert projection immutability
seonghobae Aug 15, 2026
f9cdca2
docs(changelog): describe hierarchy projection precisely
seonghobae Aug 15, 2026
322b8d7
docs(schedule): record hierarchy review repair evidence
seonghobae Aug 15, 2026
9389bdd
merge: reconcile work-item hierarchy with protected develop
seonghobae Aug 16, 2026
f143786
merge(schedule): reconcile hierarchy domain with current develop
seonghobae Aug 16, 2026
b23639e
merge(schedule): reconcile work-item hierarchy with current develop
seonghobae Aug 16, 2026
2f082eb
merge(develop): reconcile hierarchy domain with OpenCode config
seonghobae Aug 17, 2026
ca21114
merge(develop): preserve hierarchy and orchestrator coverage
seonghobae Aug 19, 2026
7e5278f
fix(stack): preserve protected orchestrator changelog
seonghobae Aug 19, 2026
72725c6
fix(stack): reconcile work-item hierarchy with protected develop
seonghobae Aug 20, 2026
36c11dd
Merge branch 'develop' into feat/work-item-hierarchy-domain-287
opencode-agent[bot] Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Added an order-independent four-level Phase → Activity → Task → Duty domain
validator/projection that preserves legacy three-level IDs and never
synthesizes customer work during projection.
- Added deterministic PM analysis for requirements/RFI/RFP readiness, WBS
estimation coverage, dependency risk, and procurement package section checks.
- Preserved PM-analysis research papers, NASA WBS handbook, BCP 14, and JSON
Expand Down
43 changes: 43 additions & 0 deletions docs/doctoring/work-item-hierarchy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Work-item hierarchy domain evidence

Status: **active PR evidence; not protected-`develop` shipped behavior**.

Issue #287 calls for a four-level Phase → Activity → Task → Duty planning model without destroying existing three-level plans. Protected `develop` currently stores explicit `id`, `parentId`, and `depth` fields and its editor validates only depths 1–3. This slice deliberately does not change the browser editor, database schema, APIs, exports, or persisted records. It establishes the framework-independent domain boundary that those adapters can reuse in a later vertical integration.

## Decision and customer invariant

`server/work_item_hierarchy.mjs` defines a fixed four-level vocabulary and two operations: validate persisted relationships and project valid records into a canonical read model. Each projection entry is an immutable wrapper containing an immutable `record` copy plus derived `kind` and `sourceIndex` metadata. Persisted values remain inside `record`, so customer fields named `kind` or `sourceIndex` cannot be silently overwritten by projection metadata. Parent absence is normalized to `null` in the projected record; every other customer-supplied field, ID, relationship, and source order is preserved. Existing three-level plans remain three-level; a Duty is never synthesized merely to make the structure four levels deep.

The validator fails closed on malformed record containers, blank/non-string/unsafe IDs, duplicate IDs, depth outside 1–4, missing parents, parents that are not exactly one level above the child, and parent-reference cycles. Validation is independent of input order so imported or database-returned records do not need to be physically grouped before structural integrity can be established. The implementation uses maps and bounded parent traversal rather than nested full-list scans; a realistic 10,000-record fixture is part of the behavior suite.

This is a domain contract, not a claim that ScopeWeave's current UI already edits Duty records. A later integration must update editor labels/validation, import/export, persistence/API contracts, analytics traversal, browser acceptance evidence, and migration/rollback documentation together before issue #287 can claim four-level support as shipped truth.

## TDD and review evidence

The first branch commit, `29ef935f9356c59b88eda2d1c648acb78848781b`, added `tests/unit/work-item-hierarchy.test.mjs` importing an absent `server/work_item_hierarchy.mjs`. The target module did not exist on the branch or protected base, so the executable contract was RED with `ERR_MODULE_NOT_FOUND` before implementation. Commit `bcc5f70200dc3d2a45fcb6acf0dd427485a93c32` then added the initial production module.

A current-source CodeRabbit review of predecessor head `ba26b89260ba8ac5142fc7c2ea76c0bb4f475036` identified a valid data-integrity defect in that initial projection shape: spreading a persisted record and then assigning derived `kind` and `sourceIndex` could silently replace customer fields with the same names. Regression commit `458cc39ba0a7a6f66bf35ea8d0533c6c4554fddb` changed the executable contract first to require persisted collision fields to survive under a separate `record` structure; that regression was incompatible with the predecessor production shape and therefore established the repair RED condition. Production commit `8f4981060d3c35776657d2fb90c12e7594daaa7b` moved derived metadata onto an immutable wrapper, preserving the normalized record separately. Commit `da31ed0cce6970d54544af6391d3bbbd968565a7` added direct `Object.isFrozen(...)` assertions for the level vocabulary, wrappers, and projected records, and commit `f9cdca28d1e2c276b61019a98453a82de1420c20` corrected the changelog wording from migration to projection.

The repository coverage producer instruments the module and executes the hierarchy suite under `c8`. Hosted exact-current-head Istanbul statement/branch/function/line results and all other applicable checks remain mandatory before merge; queued, pending, predecessor, skipped-required, neutral, or model-only evidence is not promoted to passing.

## Standards traceability

The current published international WBS standard is ISO 21511:2018. ISO marks it as published but under revision (stage 90.92). ISO/DIS 21511 Edition 2 is under development and therefore is research/forward-compatibility context, not a final normative dependency. The implementation follows the stable concept that a WBS is a hierarchical decomposition of project scope while intentionally keeping ScopeWeave's product-specific four-level labels separate from any claim that ISO mandates those exact four names or exactly four levels.

PMI's *Practice Standard for Work Breakdown Structures—Third Edition* likewise treats the WBS as organizing total project scope and explicitly covers predictive, agile, iterative, and incremental life cycles. That supports keeping the domain neutral to delivery method; the Phase/Activity/Task/Duty vocabulary is a ScopeWeave product decision, not an assertion that agile work must fit a waterfall ontology.

No empirical performance or psychometric claim is introduced by this slice, so a peer-reviewed experimental citation would not materially justify the structural validator. The executable 10,000-record regression is the relevant evidence for its algorithmic workload boundary; later UX latency claims must be backed by browser measurements on the integrated UI.

### APA 7 references

International Organization for Standardization. (2018). *Work breakdown structures for project and programme management* (ISO Standard No. 21511:2018). https://www.iso.org/standard/69702.html

International Organization for Standardization. (2026). *Project, programme and portfolio management—Work breakdown structures* (ISO/DIS 21511, Edition 2) [Draft International Standard]. https://www.iso.org/standard/87898.html

Project Management Institute. (2019). *Practice standard for work breakdown structures* (3rd ed.). Project Management Institute. https://www.pmi.org/standards/work-breakdown-structures-third-edition

## Integration and rollback

Integration order for this slice is intentionally narrow: first land the independently reviewed domain contract, then adapt one production boundary at a time while preserving legacy IDs and validating migrations against protected truth. Database object ownership remains with the schema-migration lane; this slice creates no table or migration and therefore cannot conflict with issue #433 / PR #500.

Rollback before adapter integration removes the domain module, focused tests, coverage registrations, this doctoring note, and the changelog entry together. Because no stored record is transformed and no schema is changed, rollback has no data-reversal step. Once future adapters consume the domain, rollback must preserve customer IDs and must not silently flatten or discard fourth-level work.
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
"coverage": "npm run test:coverage",
"server": "node server/server.mjs",
"test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs",
"test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs",
"test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases",
"test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api",
"test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/work-item-hierarchy.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs",
"test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/work_item_hierarchy.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases",
"test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/work-item-hierarchy.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js",
Expand Down
176 changes: 176 additions & 0 deletions server/work_item_hierarchy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
const UNSAFE_IDENTIFIER_KEYS = new Set(['__proto__', 'constructor', 'prototype']);

/**
* Canonical ScopeWeave hierarchy labels indexed by persisted depth minus one.
* The array is frozen so adapters cannot silently redefine the shared domain.
*/
export const WORK_ITEM_LEVELS = Object.freeze(['phase', 'activity', 'task', 'duty']);

/**
* Validate persisted work-item relationships for ScopeWeave's four-level plan.
*
* Validation is order-independent and intentionally does not repair customer
* data. A record must have an opaque non-empty string ID, a depth from 1 to 4,
* and either no parent at depth 1 or an existing parent exactly one level
* above it. Duplicate IDs, cycles, malformed records, and unsafe object-key
* identifiers are reported explicitly so storage/API adapters can fail closed.
*
* @param {unknown} records persisted work-item records to validate
* @returns {{valid: boolean, errors: Array<Record<string, unknown>>}} stable validation result
*/
export function validateWorkItemHierarchy(records) {
if (!Array.isArray(records)) {
return { valid: false, errors: [{ code: 'invalid_records' }] };
}

const errors = [];
const recordById = new Map();

for (let sourceIndex = 0; sourceIndex < records.length; sourceIndex += 1) {
const record = records[sourceIndex];
if (!record || typeof record !== 'object' || Array.isArray(record)) {
errors.push({ code: 'invalid_record', sourceIndex });
continue;
}

const { id } = record;
if (!isValidIdentifier(id)) {
errors.push({ code: 'invalid_id', sourceIndex });
continue;
}

if (recordById.has(id)) {
errors.push({ code: 'duplicate_id', id });
continue;
}

recordById.set(id, record);
}

for (const [id, record] of recordById) {
const { depth } = record;
if (!Number.isInteger(depth) || depth < 1 || depth > WORK_ITEM_LEVELS.length) {
errors.push({ code: 'invalid_depth', id, depth });
continue;
}

const parentId = normalizeParentId(record.parentId);
if (depth === 1) {
if (parentId !== null) {
errors.push({ code: 'invalid_parent_depth', id, parentId, depth, parentDepth: null });
}
continue;
}

if (!isValidIdentifier(parentId) || !recordById.has(parentId)) {
errors.push({ code: 'missing_parent', id, parentId });
continue;
}

const parentDepth = recordById.get(parentId)?.depth;
if (!Number.isInteger(parentDepth) || parentDepth !== depth - 1) {
errors.push({ code: 'invalid_parent_depth', id, parentId, depth, parentDepth });
}
}

const cycleIds = findCycleIds(recordById);
for (const id of cycleIds) {
errors.push({ code: 'cycle', id });
}

return { valid: errors.length === 0, errors };
}

/**
* Create immutable, source-position-preserving projection wrappers for a valid plan.
*
* Existing three-level plans stay three-level. Every persisted field remains in
* the nested `record`, including customer fields named `kind` or `sourceIndex`.
* Canonical hierarchy metadata is stored only on the wrapper, so projection
* cannot silently overwrite persisted values. A fourth-level Duty is represented
* only when a source record already exists; this function never synthesizes work.
*
* @param {unknown} records persisted ScopeWeave work-item records
* @returns {Array<Readonly<{record: Readonly<Record<string, unknown>>, kind: string, sourceIndex: number}>>} canonical projected records
* @throws {Error} when the hierarchy is malformed
*/
export function projectWorkItemHierarchy(records) {
const validation = validateWorkItemHierarchy(records);
if (!validation.valid) {
const codes = validation.errors.map((error) => error.code).join(',');
throw new Error(`Invalid work-item hierarchy: ${codes}`);
}

return records.map((record, sourceIndex) => {
const projectedRecord = Object.freeze({
...record,
parentId: normalizeParentId(record.parentId),
});
return Object.freeze({
record: projectedRecord,
kind: WORK_ITEM_LEVELS[record.depth - 1],
sourceIndex,
});
});
}

/** @param {unknown} id candidate opaque identifier @returns {id is string} */
function isValidIdentifier(id) {
return typeof id === 'string'
&& id.trim().length > 0
&& !UNSAFE_IDENTIFIER_KEYS.has(id);
}

/** @param {unknown} parentId persisted parent value @returns {unknown} canonical parent value */
function normalizeParentId(parentId) {
return parentId === undefined || parentId === null || parentId === '' ? null : parentId;
}

/**
* Find every record that participates in a parent-reference cycle.
* Invalid/missing parents terminate traversal and are handled by relationship
* validation, so cycle detection remains bounded to O(N) map traversal.
*
* @param {Map<string, Record<string, unknown>>} recordById validated ID map
* @returns {string[]} cycle participant IDs in deterministic insertion order
*/
function findCycleIds(recordById) {
const stateById = new Map();
const cycleSet = new Set();

for (const startId of recordById.keys()) {
if (stateById.get(startId) === 2) {
continue;
}

const path = [];
const pathIndex = new Map();
let currentId = startId;

while (recordById.has(currentId) && stateById.get(currentId) !== 2) {
if (pathIndex.has(currentId)) {
const cycleStart = pathIndex.get(currentId);
for (let index = cycleStart; index < path.length; index += 1) {
cycleSet.add(path[index]);
}
break;
}

pathIndex.set(currentId, path.length);
path.push(currentId);
stateById.set(currentId, 1);

const parentId = normalizeParentId(recordById.get(currentId)?.parentId);
if (!isValidIdentifier(parentId) || !recordById.has(parentId)) {
break;
}
currentId = parentId;
}

for (const id of path) {
stateById.set(id, 2);
}
}

return [...recordById.keys()].filter((id) => cycleSet.has(id));
}
Comment thread
seonghobae marked this conversation as resolved.
10 changes: 10 additions & 0 deletions tests/unit/coverage-script-contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,21 @@ assert.match(
/--include=server\/clearfolio\.mjs/,
'the abortable Clearfolio adapter is instrumented',
);
assert.match(
scripts['test:coverage'],
/--include=server\/work_item_hierarchy\.mjs/,
'the work-item hierarchy domain is instrumented',
);
assert.match(
scripts['test:coverage:cases'],
/tests\/unit\/clearfolio-status-signal\.test\.mjs/,
'the Clearfolio signal and HTTP failure regression executes under c8',
);
assert.match(
scripts['test:coverage:cases'],
/tests\/unit\/work-item-hierarchy\.test\.mjs/,
'the work-item hierarchy behavior contract executes under c8',
);
assert.doesNotMatch(
scripts['test:coverage:cases'],
/npm run (?:coverage|test:coverage)(?:\s|$)/,
Expand Down
Loading
Loading